// Screens that match the original Streamlit dashboard pages 1:1. // Original pages: Home, Accounts, Tax, Cash flow, History // No invented features — every section maps to something in dashboard/dashboard/app.py. // // Each screen receives a `nav` object with: tab(id), focusAccount(name) // Per-render data is read via `window.KIS_DATA.get()` so the active user // (combined / 조범진 / 박수현) controls what shows. // ═══════════════════════════════════════════════════════════════════ // HOME — original Streamlit Home: hero metrics + value chart + gain chart // + monthly P/L bars + yearly P/L bars + account cards + flows // Period toggle (1주/1달/3달/6달/1년) drives the value chart. // ═══════════════════════════════════════════════════════════════════ function HomeScreen({ nav }) { const D = window.KIS_DATA.get(); const { hero, valueSeries, costSeries, investedSeries, gainSeries, marginSeries, dateSeries, accounts, dailyFlows, monthlyPL, yearlyPL } = D; // generatedAt lives on the container, not the per-user slice — read it there. const genAt = window.KIS_DATA.generatedAt; const [period, setPeriod] = React.useState("1m"); const [chartTab, setChartTab] = React.useState("value"); // value | gain | monthly | yearly return (
USD {D.fxUsdKrw.toFixed(2)} } /> {/* Massive hero — total value + lifetime return */}
총자산 · {D.displayName}
₩{fmtKRW(hero.totalValue)}
{fmtSigned(hero.totalGain)} { hero.netInvested > 0 ? fmtPct((hero.totalGain / hero.netInvested) * 100) : "—" } 총수익
{/* Big chart card with sub-tabs */}
{/* Chart-type sub-tabs */}
{[ { id: "value", label: "자산 추이" }, { id: "gain", label: "이익·마진" }, { id: "monthly", label: "월별" }, { id: "yearly", label: "연도별" }, ].map(t => ( ))}
{/* Chart body */}
{chartTab === "value" && ( )} {chartTab === "gain" && ( )} {chartTab === "monthly" && ( )} {chartTab === "yearly" && ( )}
{/* Period toggle (only relevant for value/gain) */} {(chartTab === "value" || chartTab === "gain") && (
)}
{/* 4-card metric grid — net invested, daily delta, realized, unrealized. Total gain + return % moved up to the hero header per user request. */}
{(() => { // Delta vs the most recent prior trading day. Always label it // "전일 대비" — we no longer surface the explicit basis date // (it could read as a stale 3-day-old date whenever a refresh // gap leaves the prior snapshot a few days back; data itself is // sound, so the date caveat is just noise). const basis = hero.todayBasis || ""; const when = basis ? "전일 대비" : "최근 변동"; return ( ); })()}
{/* Account cards */} nav.tab("accounts")} />
{accounts.map(a => ( nav.focusAccount(a.id)} /> ))}
nav.tab("flow")} />
{dailyFlows.slice(0, 4).map((f, i) => )}
); } // ═══════════════════════════════════════════════════════════════════ // ACCOUNTS — original behavior: shows ALL accounts stacked, each with metrics + holdings. // If `focus` is set, only that account renders (with "← Show all" button at top). // ═══════════════════════════════════════════════════════════════════ function AccountsScreen({ nav, focus }) { const D = window.KIS_DATA.get(); const list = focus ? D.accounts.filter(a => a.id === focus) : D.accounts; return (
{focus && (
)} {!focus && (
전체 자산
₩{fmtKRW(D.accounts.reduce((s, a) => s + a.value, 0))}
)} {list.map(a => )}
); } function AccountSection({ a, nav }) { // The API now sends per-account holdings on `a.holdings`. The previous // code filtered the GLOBAL holdings list by bucket, which displayed every // user-wide US holding on every US account. const holdings = a.holdings || []; return (
{a.name}
{a.id}
보유 종목
{holdings.length > 0 ? (
{holdings.map(h => ( nav && nav.openHolding && nav.openHolding(h.code)} /> ))}
) : (
이 계좌에는 보유 종목이 없습니다.
)}
); } function HoldingCard({ h, onClick }) { const isUs = h.market !== "KRX"; // The API now ships valueKrw / costKrw / plKrw so we never have to guess // whether `qty * live` is in KRW or USD. avg / live are still per-share // in their native unit (USD for US, KRW for KRX) for the secondary line. return (
{h.ticker}
{h.qty}주 · 평단 {isUs ? `$${(h.avg||0).toFixed(2)}` : `₩${fmtKRW(h.avg)}`} · 현재가 {isUs ? `$${(h.live||0).toFixed(2)}` : `₩${fmtKRW(h.live)}`}
{onClick && }
₩{fmtKRW(h.valueKrw)}
{fmtSigned(h.plKrw, true)} · {fmtPct(h.plPct)}
); } // ═══════════════════════════════════════════════════════════════════ // HOLDING DETAIL — tap a holding from Accounts → opens this screen // Sections: live price + P/L, position summary, realized sale history, // dividends received for this ticker // ═══════════════════════════════════════════════════════════════════ function HoldingDetailScreen({ nav, code }) { const D = window.KIS_DATA.get(); const h = D.holdings.find(x => x.code === code); if (!h) { return (
nav.back && nav.back()} />
이 종목 코드 ({code})에 대한 보유 데이터가 없어요. KIS 데이터 새로고침 후 다시 시도하세요.
); } const isUs = h.market !== "KRX"; // KRW totals come from the API (valueKrw / costKrw / plKrw); avg / live // stay per-share (USD for US, KRW for KRX) for the price hero. const valueKrw = h.valueKrw; const costKrw = h.costKrw; const plKrw = h.plKrw; // sales for this ticker const sales = isUs ? D.usSales.filter(s => s.symbol === h.code) : D.krxSales.filter(s => s.ticker === h.code || s.name === h.ticker); // Dividend filter — the API populates `r.ticker` from the resolved-events // `symbol` field (a human-readable Korean ETF name for KRX, a brokerage // label for US holdings). Match against the holding's code, ticker, or // display name with substring tolerance because the names aren't always // identical (e.g. "RISE 미국나스닥100" vs "KB RISE 미국나스닥100증권상장지수 // 투자신탁(주식)" for the same instrument). const matchesHolding = (s) => { if (!s) return false; if (s === h.code || s === h.ticker || s === h.name) return true; const a = s.replace(/\s+/g, ""); const tk = (h.ticker || "").replace(/\s+/g, ""); const nm = (h.name || "").replace(/\s+/g, ""); return (tk && (a.includes(tk) || tk.includes(a))) || (nm && (a.includes(nm) || nm.includes(a))); }; const dividends = D.ledger.filter(r => r.tag === "dividend" && (matchesHolding(r.ticker) || (r.note && matchesHolding(r.note)))); return (
nav.back && nav.back()} /> {/* Hero: current price + change since avg */}
{h.market} · {h.qty}주 보유
{isUs ? `$${(h.live||0).toFixed(2)}` : `₩${fmtKRW(h.live)}`}
{fmtPct(h.plPct)}
평단 대비 {(() => { const d = (h.live || 0) - (h.avg || 0); const sign = d >= 0 ? "+" : "−"; const abs = Math.abs(d); return isUs ? `${sign}$${abs.toFixed(2)}` : `${sign}₩${fmtKRW(abs)}`; })()}
{/* Position summary */}
{/* Sales history */} {sales.length > 0 && ( <>
{sales.map((s, i) => (
매도 {s.qty}주 {s.date}
{fmtSigned(s.plKrw, true)}
))}
)} {/* Dividends */} {dividends.length > 0 && ( <>
{dividends.map((r, i) => (
배당 {r.date}{r.note ? ` · ${r.note}` : ""}
{r.amountUsd != null ? ( <> {r.amountUsd >= 0 ? "+" : "−"}${Math.abs(r.amountUsd).toFixed(2)} ≈ {r.amountKrw >= 0 ? "+" : "−"}₩{fmtKRW(Math.abs(r.amountKrw), true)} ) : ( {fmtSigned(r.amountKrw, true)} )}
))}
)}
); } // ═══════════════════════════════════════════════════════════════════ // TAX — Korean capital gains tax page (original Streamlit Tax page) // Sections: gauge + harvest calculator preview + year history // ═══════════════════════════════════════════════════════════════════ function TaxScreen({ nav }) { const D = window.KIS_DATA.get(); const t = D.tax; // Clamp the deduction usage to [0, annualDeduction] — a YTD LOSS would // otherwise produce negative usage (`-40% 사용`) and `headroom > 250만`, // i.e. nonsense that suggests you've gained extra deduction. Codex R10 // #3 finding. The signed YTD itself is still rendered above so the user // sees the loss, but the gauge math reflects only realized GAINS. const ytdGain = Math.max(0, t.usYtdRealized); const taxable = Math.max(0, ytdGain - t.annualDeduction); const taxOwed = Math.round(taxable * t.rate); const headroom = Math.max(0, t.annualDeduction - ytdGain); const pct = t.annualDeduction > 0 ? Math.min(100, (ytdGain / t.annualDeduction) * 100) : 0; const overLimit = ytdGain > t.annualDeduction; return (
미국 실현손익 YTD
₩{fmtKRW(t.usYtdRealized)}
250만원 비과세 한도 중 {pct.toFixed(0)}% 사용
{/* Gauge bar */}
{/* Deduction-limit reference line, pinned just inside the right edge (the gauge's full scale == the 250만 limit). left:100% put it outside the overflow:hidden box, so it never rendered. */}
한도 ₩{fmtKRW(t.annualDeduction, true)}
0 ? "up" : "down"} /> 0 ? "down" : ""} />
{/* Harvest calculator hint + per-position recommendations. Algorithm ported from dashboard/app.py:1142-1208 — fill the ₩2.5M annual deduction by selling integer shares of a US position with embedded gains, then rebuy next trading day to step up cost basis tax-free. Korea has no wash-sale rule so this works.*/} {headroom > 0 && (
HARVEST
이번 연도 ₩{fmtKRW(headroom, true)} 한도 미사용
연말 매도→다음날 재매수로 비과세 평단 step-up 가능. 12/26까지. 아래 후보 중 한 종목의 표시된 수량을 매도하세요.
{(t.harvestCandidates && t.harvestCandidates.length > 0) ? (
{t.harvestCandidates.map((c, i) => (
{c.symbol} {i === 0 && ★ 추천}
{c.account}
{c.qtyToSell}주 매도
{c.qtyHeld}주 보유 · 평단 ${(c.costUsd || 0).toFixed(2)} → 현재 ${(c.liveUsd || 0).toFixed(2)} {" "}({fmtPct(c.gainPctUsd)})
실현이익
₩{fmtKRW(c.realizedKrw, true)}
왕복 수수료
−₩{fmtKRW(c.feeKrw, true)}
세금 절감
₩{fmtKRW(c.taxSavedKrw, true)}
순이익
{fmtSigned(c.netBenefitKrw, true)}
{!c.fillsHeadroom && (
⚠ 보유 전부 팔아도 한도가 ₩{fmtKRW(headroom - c.realizedKrw, true)} 남음 — 다른 종목과 합쳐야 채움
)}
))}
) : (
현재 평가이익이 있는 미국 종목이 없어 harvest할 게 없어요.
)}
)}
{D.yearPL.map(y => ( ))}
연도 국내 미국 합계
{y.year} = 0 ? "var(--up)" : "var(--down)" }}> {fmtSigned(y.korean, true)} = 0 ? "var(--up)" : "var(--down)" }}> {fmtSigned(y.us, true)} = 0 ? "var(--up)" : "var(--down)" }}> {fmtSigned(y.korean + y.us, true)}
); } // ═══════════════════════════════════════════════════════════════════ // CASH FLOW — original Streamlit "Cash flow" page // Sections: deposits/withdrawals/net summary, transfers list, dividend/interest/fee ledger // ═══════════════════════════════════════════════════════════════════ function CashFlowScreen({ nav }) { const D = window.KIS_DATA.get(); const [filter, setFilter] = React.useState("all"); const ts = D.transfersSum; // Lifetime totals come from the API's `ledgerTotals` (computed off the // unsliced data). The `ledger` array itself is capped at 50 rows for the // list UI, so summing it client-side under-counts. Fall back to summing // when totals weren't sent (older API response). const lt = D.ledgerTotals || {}; const divTotal = lt.dividend != null ? lt.dividend : D.ledger.filter(r => r.tag === "dividend").reduce((s, r) => s + Math.abs(r.amountKrw), 0); const intTotal = lt.interest != null ? lt.interest : D.ledger.filter(r => r.tag === "interest").reduce((s, r) => s + Math.abs(r.amountKrw), 0); const feeTotal = lt.fee != null ? lt.fee : D.ledger.filter(r => r.tag === "fee").reduce((s, r) => s + Math.abs(r.amountKrw), 0); const filters = [ { id: "all", label: "전체" }, { id: "transfer", label: "입출금" }, { id: "dividend", label: "배당" }, { id: "interest", label: "이자" }, { id: "fee", label: "수수료" }, ]; const showTransfers = filter === "all" || filter === "transfer"; const showLedger = filter === "all" || ["dividend", "interest", "fee"].includes(filter); const ledgerList = filter === "all" ? D.ledger : D.ledger.filter(r => r.tag === filter); return (
현금 이동 (출금만 기록)
{fmtKRW(ts.withdrawals)}원
입금은 매수−매도로 자동 추정. 출금만 직접 기록하면 됨.
{/* Filter chips */}
{filters.map(f => ( ))}
{/* Summary cards */}
{showTransfers && ( <>
{D.transfers.map((t, i) => )}
)} {showLedger && ( <>
{ledgerList.map((r, i) => )}
)}
); } function TransferRow({ t }) { return (
{t.type === "deposit" ? "입금" : "출금"} {t.date}{t.note ? ` · ${t.note}` : ""}
= 0 ? "up" : "down"}`} style={{ fontWeight: 700 }}> {t.amountKrw >= 0 ? "+" : "−"}₩{fmtKRW(Math.abs(t.amountKrw), true)}
); } function LedgerRow({ r }) { const labels = { dividend: "배당", interest: "이자", fee: "수수료" }; const cls = r.tag === "fee" ? "down" : "up"; const sign = r.amountKrw >= 0 ? "+" : "−"; // USD-originated rows (US dividends) carry both the original USD amount // and an FX-converted KRW. Show USD as the secondary line — the user // wanted to see that the dividend was paid in dollars, not just won. const isUsd = r.amountUsd != null; return (
{r.tag === "dividend" ? "₩" : r.tag === "interest" ? "%" : "F"}
{labels[r.tag]}{r.ticker ? ` · ${r.ticker}` : ""} {r.date}{r.note ? ` · ${r.note}` : ""}
{isUsd ? ( <> {r.amountUsd >= 0 ? "+" : "−"}${Math.abs(r.amountUsd).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ≈ {sign}₩{fmtKRW(Math.abs(r.amountKrw), true)} ) : ( {sign}₩{fmtKRW(Math.abs(r.amountKrw), true)} )}
); } // ═══════════════════════════════════════════════════════════════════ // HISTORY — original "History" page with US/KRX sub-tabs // Per-sale rows from realized trades // ═══════════════════════════════════════════════════════════════════ function HistoryScreen({ nav }) { const D = window.KIS_DATA.get(); const [sub, setSub] = React.useState("us"); return (
{/* Sub-tab pills */}
{[ { id: "us", label: "미국 매도" }, { id: "krx", label: "국내 매도" }, ].map(s => ( ))}
{sub === "us" ? (
{D.usSales.length}건
{D.usSales.map((s, i) => ( ))}
날짜 종목 수량 실현손익
{s.date.slice(5)} {s.symbol} {s.qty} = 0 ? "var(--up)" : "var(--down)" }} className="num"> {fmtSigned(s.plKrw, true)}
) : (
{D.krxSales.length}건
{D.krxSales.map((s, i) => ( ))}
날짜 종목 수량 실현손익
{s.date.slice(5)}
{s.name}
{s.ticker}
{s.qty} = 0 ? "var(--up)" : "var(--down)" }} className="num"> {fmtSigned(s.plKrw, true)}
)}
); } // ── Inline table styles ───────────────────────────────────── const th = { padding: "10px 12px", textAlign: "left", fontSize: 11, fontWeight: 700, color: "var(--fg-tertiary)", textTransform: "uppercase", letterSpacing: "0.04em", }; const td = { padding: "10px 12px", fontSize: 13, color: "var(--fg)", }; Object.assign(window, { HomeScreen, AccountsScreen, TaxScreen, CashFlowScreen, HistoryScreen, HoldingDetailScreen, });