// 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 */}
{/* 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 (
);
})()}
);
}
// ═══════════════════════════════════════════════════════════════════
// 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 (
);
}
// ═══════════════════════════════════════════════════════════════════
// 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 */}
);
}
// ═══════════════════════════════════════════════════════════════════
// 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까지.
아래 후보 중 한 종목의 표시된 수량을 매도하세요.
);
}
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 (