// Portfolio.jsx — 포트폴리오 관리 (트레이딩 데스크 실무). 주식/채권 데스크 분리.
// 화면 순서: 종합 요약 → 보유 현황 → 리스크 요인 분석 → 투자 시뮬레이션 + AI 최적화 → 스트레스 + 시나리오.
// 외부 클릭 닫힘 훅
function useOutside(ref, onOut) {
React.useEffect(() => {const h = (e) => {if (ref.current && !ref.current.contains(e.target)) onOut();};
document.addEventListener('mousedown', h);return () => document.removeEventListener('mousedown', h);}, [onOut]);
}
// 담당자 검색 콤보박스 (사번·이름·부서·팀)
function PersonPicker({ value, onChange, people }) {
const POOL = people || PEOPLE;
const [open, setOpen] = React.useState(false);
const [q, setQ] = React.useState('');
const ref = React.useRef(null);
useOutside(ref, () => setOpen(false));
const sel = PMAP[value];
const list = POOL.filter((p) => {const s = (p.name + p.emp + p.dept + p.team).toLowerCase();return s.includes(q.toLowerCase());});
return (
{open &&
setQ(e.target.value)} placeholder="사번·이름·부서 검색"
style={{ border: 0, outline: 'none', flex: 1, background: 'transparent', fontFamily: T.font, fontWeight: 500, fontSize: 12.5, color: T.c800 }} />
{list.map((p) => {const on = p.emp === value;
return
;})}
{list.length === 0 &&
검색 결과가 없어요.
}
}
);
}
// 조직 선택 드롭다운
function DeptPicker({ value, onChange, depts, asset }) {
const LIST = depts || DEPT_LIST;
const [open, setOpen] = React.useState(false);
const ref = React.useRef(null);
useOutside(ref, () => setOpen(false));
const countOf = (d) => PEOPLE.filter((p) => p.dept === d && (!asset || p.asset === asset)).length;
return (
{open &&
{LIST.map((d) => {const on = d === value;const n = countOf(d);
return
;})}
}
);
}
function KPIStat({ label, value, unit, sub, color, subColor }) {
return (
{label}
{value}
{unit && {unit}}
{sub &&
{sub}
}
);
}
// 섹션 헤더 (번호 스텝)
function StepHead({ n, title, sub }) {
return (
{n}
{title}
{sub && {sub}}
);
}
// 딜러 한도 현황 — SA(표준방법) 한도 · 투자한도 · 손절매한도 · 비율 한도. 한도 설정은 리스크총괄부, 딜러는 조회.
function DealerLimitsCard({ emp, asset, pf }) {
const lim = dealerLimit(emp);
if (!lim) return null;
const tiles = [
{ label: 'SA 한도', pair: lim.sa, unit: '억', sub: '표준방법(SA) 산출 시장리스크' },
{ label: '투자한도', pair: lim.invest, unit: '억', sub: '배정 투자가능금액 대비 집행액' }];
const zc = stopCounts(pf);
const flagged = zc[1] + zc[2] + zc[3] + zc[4];
const delta = asset === '주식' ? Math.round(kospiDelta(pf)) : null;
return (
{delta != null && 델타(KOSPI) {delta.toLocaleString()}억}
{flagged > 0 && 손절 구간 도달 {flagged}종목}
} />
{tiles.map((t, i) =>
{t.label}
= 100 ? T.alert : limUtil(t.pair) >= 80 ? T.warn : T.c900, letterSpacing: '-.02em', whiteSpace: 'nowrap' }}>{t.pair[0].toLocaleString()}
/ {t.pair[1].toLocaleString()}{t.unit}
{t.sub} · 소진 = 100 ? T.alert : limUtil(t.pair) >= 80 ? T.warn : T.c600 }}>{limUtil(t.pair).toFixed(1)}%
)}
손절매한도 · 구간 도달 종목 수
{STOP_ZONES.slice(1).map((z) => {const n = zc[z.v];const on = n > 0;
return {z.l} {n};})}
정상 {zc[0]}종목 · 종목별 구간은 아래 보유 종목 참조
{lim.ratio &&
비율 한도 (단일 종목 집중도)
= 100 ? T.alert : (lim.ratio.max / lim.ratio.cap * 100) >= 80 ? T.warn : T.c900, letterSpacing: '-.02em' }}>{lim.ratio.max.toFixed(2)}
/ {lim.ratio.cap.toFixed(1)}%
시가총액 비중 기준 · 최대 {lim.ratio.n}
}
);
}
function Portfolio({ asset, preset }) {
const people = peopleOf(asset);
const depts = deptsOf(asset);
const [mode, setMode] = React.useState(preset && preset.dept ? '조직' : '담당자');
const [emp, setEmp] = React.useState(preset && preset.emp ? preset.emp : people[0].emp);
const [dept, setDept] = React.useState(preset && preset.dept ? preset.dept : depts[0]);
const [tool, setTool] = React.useState('trade');
const [stress, setStress] = React.useState('single');
const [comp, setComp] = React.useState('종목');
const [holds, setHolds] = React.useState(null);
const [val, setVal] = React.useState(null);
React.useEffect(() => { if (preset) { if (preset.emp) { setMode('담당자'); setEmp(preset.emp); } else if (preset.dept) { setMode('조직'); setDept(preset.dept); } } }, [preset]);
// 선택된 소유자 → 기준 포트폴리오
const ownerKey = mode === '담당자' ? 'p:' + emp : 'd:' + dept;
const owner = React.useMemo(() => {
if (mode === '담당자') {const p = PMAP[emp];return { kind: 'person', name: p.name, sub: `${p.dept} · ${p.team} · 사번 ${p.emp}`, title: p.title, base: p.pf };}
const dp = deptPortfolio(dept, asset);return { kind: 'dept', name: dept, sub: `${asset} 담당 ${people.filter((p) => p.dept === dept).length}명 합산 · 부서 포트폴리오`, base: dp };
}, [mode, emp, dept]);
// 소유자 변경 시 working holds·평가액 리셋
React.useEffect(() => {setHolds(owner.base.holds.map((h) => ({ ...h })));setVal(owner.base.value);setTool('trade');}, [ownerKey]);
const pf = { holds: holds || owner.base.holds, value: val != null ? val : owner.base.value, limit: owner.base.limit };
const m = summarize(pf);
const contribs = riskContribs(pf.holds);
const cmap = {};contribs.forEach((c) => cmap[c.id] = c.frac);
const commit = React.useCallback((h, v) => {setHolds(h);if (v != null) setVal(v);}, []);
const dirty = holds && JSON.stringify(holds) !== JSON.stringify(owner.base.holds.map((h) => ({ ...h }))) || val != null && Math.round(val) !== Math.round(owner.base.value);
// 구성 데이터
const compKeys = asset === '주식' ? ['종목', '국가', '섹터'] : ['종목', '국가', '등급'];
const compData = React.useMemo(() => {
if (comp === '종목') return pf.holds.slice().sort((a, b) => b.w - a.w).map((h, i) => ({ label: UMAP[h.id].name, value: h.w, color: PAL[i % PAL.length] }));
const keyOf = comp === '국가' ? (u) => u.country : comp === '섹터' ? (u) => u.sector : (u) => u.rating || '기타';
const g = {};pf.holds.forEach((h) => {const k = keyOf(UMAP[h.id]);g[k] = (g[k] || 0) + h.w;});
return Object.entries(g).sort((a, b) => b[1] - a[1]).map(([label, value], i) => ({ label, value: +value.toFixed(1), color: comp === '국가' ? COUNTRY_HUE[label] || PAL[i % PAL.length] : PAL[i % PAL.length] }));
}, [pf.holds, comp]);
// AI 컨텍스트 등록
React.useEffect(() => {
const top = pf.holds.slice().sort((a, b) => (cmap[b.id] || 0) - (cmap[a.id] || 0))[0];
window.setAIContext && window.setAIContext({
screen: `${asset} 포트폴리오 관리 · ${owner.name}`,
summary: `${owner.sub} · 평가액 ${fmtEok(pf.value)}`,
facts: [
`전체 수익률(연) ${m.ret.toFixed(1)}%`,
`포트폴리오 리스크(CVaR 99%/1일) ${fmtEok(m.risk)}`,
`리스크 한도 ${fmtEok(pf.limit)} · 잔여 ${fmtEok(m.remain)} · 소진율 ${m.util.toFixed(1)}%`,
top ? `위험기여 1위 ${UMAP[top.id].name} (${(cmap[top.id] * 100).toFixed(0)}%)` : ''].
filter(Boolean),
suggestions: asset === '주식' ?
['국가별 리스크 기여가 가장 큰 곳은?', '이 포트폴리오에서 리스크 기여가 큰 종목은?', 'COVID 시나리오에서 예상 손실은?'] :
['Net DV01이 큰 포지션은?', '금리 100bp 상승 시 손실은?', '신용 스프레드 노출을 줄이려면?']
});
}, [ownerKey, holds, val]);
const reset = () => {setHolds(owner.base.holds.map((h) => ({ ...h })));setVal(owner.base.value);};
return (
{/* 선택 바 */}
{asset} 포트폴리오 불러오기
{mode === '담당자' ?
:
}
{dirty &&
편집됨}
{dirty &&
원장 복원}
기준 {FIRM.asOf}
{/* ① 종합 요약 */}
{owner.kind === 'dept' ? : owner.name[0]}
{owner.name}{owner.title && {owner.title}}
{owner.sub}
{asset} 데스크
= 100 ? 'alert' : m.util >= 80 ? 'warn' : 'ok'} dot>{m.util >= 100 ? '한도 초과' : m.util >= 80 ? '한도 임박' : '한도 내 정상'}
0 ? '+' : '') + m.ret.toFixed(1)} unit="%" color={dirColor(m.ret)} sub="가중평균 기대수익" />
= 80 ? T.warn : T.c400} />
{asset === '주식' &&
}
한도 소진 = 100 ? T.alert : m.util >= 80 ? T.warn : T.c700 }}>{m.util.toFixed(1)}%
한도 {Math.round(pf.limit).toLocaleString()}억 · 잔여 {Math.round(m.remain).toLocaleString()}억
{mode === '담당자' &&
}
{/* ② 포트폴리오(보유 종목) 현황 */}
} />
{asset} {pf.holds.length}종목} />
{const u = UMAP[h.id];return { _key: h.id, id: h.id, name: u.name, country: u.country, sector: u.sector, rating: u.rating, dur: u.dur, w: h.w, ret: u.ret, vol: u.vol, ctr: (cmap[h.id] || 0) * 100, ctrAmt: (cmap[h.id] || 0) * m.risk, dv01: u.type === '채권' ? pf.value * h.w / 100 * u.dur * 0.0001 : null };})}
cols={[
{ k: 'name', t: '종목', w: '17%', render: (r) => {r.name} },
{ k: 'country', t: '국가', w: 58, render: (r) => {r.country} },
...(asset === '주식' ? [
{ k: 'sector', t: '섹터', render: (r) => {r.sector} },
{ k: 'fac', t: '주 노출 요인', render: (r) => {mainFactorsOf(r.id).map((fid) => {const f = FMAP[fid];
return {f.name === '변동성·개별' ? '변동성' : f.name};})}, sortVal: (r) => mainFactorsOf(r.id)[0] },
] : [
{ k: 'rating', t: '등급', w: 58, render: (r) => {r.rating} },
{ k: 'dur', t: '듀레이션', num: true, render: (r) => {r.dur.toFixed(1)}Y, sortVal: (r) => r.dur },
{ k: 'dv01', t: 'DV01', num: true, render: (r) => {bfMn(r.dv01)}백만, sortVal: (r) => r.dv01 },
]),
{ k: 'w', t: '비중', num: true, render: (r) => {r.w.toFixed(1)}%, sortVal: (r) => r.w },
{ k: 'ret', t: '수익률', num: true, render: (r) => , sortVal: (r) => r.ret },
{ k: 'stopz', t: '손절 구간', num: true, render: (r) => , sortVal: (r) => stopZoneOf(r.ret) * 1000 - r.ret },
{ k: 'ctr', t: '리스크 기여', num: true, render: (r) => 20 ? T.alert : r.ctr > 12 ? T.warn : T.c400} w={48} />{r.ctr.toFixed(1)}%, sortVal: (r) => r.ctr },
{ k: 'ctrAmt', t: '기여 위험액', num: true, render: (r) => {Math.round(r.ctrAmt).toLocaleString()}억, sortVal: (r) => r.ctrAmt }]
} />
{/* ③ 리스크 요인 분석 */}
{asset === '주식' ?
:
}
{/* ④ 투자 시뮬레이션 + AI 최적화 */}
} />
{tool === 'trade' &&
}
{tool === 'opt' &&
}
{/* ⑤ 스트레스 테스트 */}
} />
{stress === 'single' && }
{stress === 'scenario' && }
);
}
Object.assign(window, { Portfolio, PersonPicker, DeptPicker, KPIStat, StepHead, DealerLimitsCard });