-
투자 배치율${deployment == null ? "—" : `${Math.round(deployment)}%`} / 목표 ${target == null ? "—" : `${Math.round(target)}%`}
-
-
${escapeHtml(allocationNote)}
+ const DRIFT = 22;
+
+ const story = {
+ series: null,
+ basket: null,
+ progress: 0,
+ seekAt: 0,
+ visible: true,
+ frame: null,
+ dirty: true,
+ width: 0,
+ height: 0,
+ };
+
+ // ?scrub=0.6 : 화면 캡처용. 스크롤 대신 고정 진행률로 스테이지를 그린다.
+ const scrubOverride = query.has("scrub") ? clamp(Number(query.get("scrub")) || 0, 0, 1) : null;
+
+ function readStoryScroll() {
+ const rect = el.story.getBoundingClientRect();
+ const max = el.story.offsetHeight - window.innerHeight;
+ story.progress = scrubOverride != null ? scrubOverride : (max > 0 ? clamp(-rect.top / max, 0, 1) : 0);
+ story.visible = rect.bottom > 0;
+ if (!story.visible && !el.chrome.classList.contains("solid")) el.chrome.classList.add("solid");
+ }
+
+ function wakeStory() {
+ if (story.frame == null) story.frame = requestAnimationFrame(storyFrame);
+ }
+
+ function storyFrame() {
+ story.frame = null;
+ if (!story.visible) { paintStoryChrome(); return; }
+ const gap = story.progress - story.seekAt;
+ let moving = false;
+ if (reducedMotion.matches) {
+ if (Math.abs(gap) > 0) { story.seekAt = story.progress; story.dirty = true; }
+ } else if (Math.abs(gap) > 0.0008) {
+ story.seekAt += gap * 0.115;
+ story.dirty = true;
+ moving = true;
+ } else if (gap !== 0) {
+ story.seekAt = story.progress;
+ story.dirty = true;
+ }
+ if (story.dirty) {
+ paintStory();
+ story.dirty = false;
+ }
+ if (moving) story.frame = requestAnimationFrame(storyFrame);
+ }
+
+ function paintStoryChrome() {
+ const p = story.progress;
+ el.meter.style.transform = `scaleX(${p})`;
+ el.meter.classList.toggle("off", p >= 0.995 || !story.visible);
+ el.chrome.classList.toggle("solid", p >= 0.995 || !story.visible);
+ el.storyHint.style.opacity = String(1 - ramp(p, 0, 0.06));
+ }
+
+ function paintStory() {
+ paintStoryChrome();
+ const t = story.seekAt;
+ el.panels.forEach((panel, i) => {
+ const c = CUES[i];
+ const enter = ramp(t, c[0], c[1]);
+ const leave = ramp(t, c[2], c[3]);
+ const o = enter * (1 - leave);
+ const y = (1 - enter) * DRIFT - leave * DRIFT;
+ panel.style.opacity = o.toFixed(3);
+ panel.style.transform = `translate3d(0, ${y.toFixed(2)}px, 0)`;
+ panel.style.pointerEvents = o > 0.6 ? "auto" : "none";
+ });
+ drawStory(t);
+ }
+
+ function storyLayout() {
+ const W = story.width;
+ const H = story.height;
+ const narrow = W < 720;
+ const gutter = clamp(W * 0.034, 16, 44);
+ return {
+ W, H, narrow, gutter,
+ left: gutter,
+ right: W - gutter,
+ top: H * (narrow ? 0.56 : 0.5),
+ bottom: H - (narrow ? 88 : 76),
+ };
+ }
+
+ function drawStory(t) {
+ const canvas = el.storyCanvas;
+ story.width = el.stage.clientWidth;
+ story.height = el.stage.clientHeight;
+ const context = setupCanvas(canvas, story.width, story.height);
+ const L = storyLayout();
+ const s = story.series;
+ context.font = `400 12px ${FONT}`;
+ context.textBaseline = "middle";
+
+ if (!s || s.rows.length === 0) {
+ context.strokeStyle = inkAlpha(0.16);
+ context.beginPath();
+ context.moveTo(L.left, L.bottom);
+ context.lineTo(L.right, L.bottom);
+ context.stroke();
+ context.fillStyle = inkAlpha(0.42);
+ context.textAlign = "left";
+ context.fillText(s ? "아직 기록이 한 건뿐이라 곡선이 없습니다." : "자산 기록을 불러오는 중입니다.", L.left, L.bottom - 18);
+ return;
+ }
+
+ const n = s.rows.length;
+ const tip = t * (n - 1);
+ const i0 = Math.floor(tip);
+ const frac = tip - i0;
+ const x = (i) => L.left + ((L.right - L.left) * i) / Math.max(1, n - 1);
+ const allValues = [...s.value, ...s.principal, ...s.twr];
+ const scale = makeScale(allValues, L.top, L.bottom - 4, 0.1);
+ const wB = ramp(t, 0.3, 0.44);
+ const wA = 1 - wB;
+ const wD = ramp(t, 0.7, 0.82);
+
+ // 눈금
+ context.textAlign = "right";
+ niceTicks(scale.min, scale.max, 4).forEach((v, i) => {
+ const y = scale.y(v);
+ context.strokeStyle = inkAlpha(i === 4 ? 0.34 : 0.12);
+ context.beginPath();
+ context.moveTo(L.left, y);
+ context.lineTo(L.right, y);
+ context.stroke();
+ if (i < 4) {
+ context.fillStyle = inkAlpha(0.42);
+ context.fillText(compactWon(v), L.right, y - 9);
+ }
+ });
+ // 날짜 눈금: 양 끝은 화면 밖으로 넘치지 않게 안쪽 정렬한다.
+ const xTicks = Math.max(1, Math.ceil((n - 1) / (L.narrow ? 2 : 5)));
+ context.fillStyle = inkAlpha(0.42);
+ for (let i = 0; i < n - 1; i += xTicks) {
+ if (n > 1 && i > n - 1 - xTicks * 0.5) break;
+ context.textAlign = i === 0 ? "left" : "center";
+ context.fillText(fmtMD(s.rows[i].date), x(i), L.bottom + 16);
+ }
+ context.textAlign = n > 1 ? "right" : "left";
+ context.fillText(fmtMD(s.rows[n - 1].date), x(n - 1), L.bottom + 16);
+
+ const at = (arr, i) => arr[i];
+ const interp = (arr) => (i0 >= n - 1 ? arr[n - 1] : arr[i0] + (arr[i0 + 1] - arr[i0]) * frac);
+ const tipX = x(tip);
+
+ function traceTo(arr, close) {
+ context.beginPath();
+ for (let i = 0; i <= i0; i += 1) {
+ const px = x(i);
+ const py = scale.y(at(arr, i));
+ if (i === 0) context.moveTo(px, py); else context.lineTo(px, py);
+ }
+ if (i0 < n - 1) context.lineTo(tipX, scale.y(interp(arr)));
+ if (close) {
+ context.lineTo(tipX, L.bottom - 4);
+ context.lineTo(x(0), L.bottom - 4);
+ context.closePath();
+ }
+ }
+
+ // 낙폭 음영 (시간가중 자산 기준, 3번째 패널에서 드러남)
+ if (wD > 0.01 && i0 > 0) {
+ let peak = -Infinity;
+ context.save();
+ context.beginPath();
+ let started = false;
+ for (let i = 0; i <= Math.min(i0 + 1, n - 1); i += 1) {
+ const v = i <= i0 ? s.twr[i] : interp(s.twr);
+ const px = i <= i0 ? x(i) : tipX;
+ peak = Math.max(peak, v);
+ const py = scale.y(v);
+ const peakY = scale.y(peak);
+ if (!started) { context.moveTo(px, peakY); started = true; }
+ context.lineTo(px, peakY);
+ }
+ for (let i = Math.min(i0 + 1, n - 1); i >= 0; i -= 1) {
+ const v = i <= i0 ? s.twr[i] : interp(s.twr);
+ const px = i <= i0 ? x(i) : tipX;
+ context.lineTo(px, scale.y(v));
+ }
+ context.closePath();
+ context.fillStyle = `rgba(37, 87, 201, ${(0.16 * wD).toFixed(3)})`;
+ context.fill();
+ context.restore();
+ if (tip >= s.maxDDIdx && s.maxDD < 0) {
+ const tx = x(s.maxDDIdx);
+ const ty = scale.y(s.twr[s.maxDDIdx]);
+ context.fillStyle = `rgba(37, 87, 201, ${wD.toFixed(3)})`;
+ context.beginPath();
+ context.arc(tx, ty, 3.5, 0, Math.PI * 2);
+ context.fill();
+ context.font = `500 12.5px ${FONT}`;
+ context.textAlign = tx > L.W * 0.7 ? "right" : "left";
+ context.fillText(`최대 낙폭 ${pct(s.maxDD * 100)}`, tx + (tx > L.W * 0.7 ? -10 : 10), ty + 16);
+ context.font = `400 12px ${FONT}`;
+ }
+ }
+
+ // 투자원금(계단, 점선)
+ context.save();
+ context.setLineDash([3, 4]);
+ context.strokeStyle = inkAlpha(0.55 * wA + 0.18 * wB);
+ context.lineWidth = 1;
+ context.beginPath();
+ for (let i = 0; i <= i0; i += 1) {
+ const px = x(i);
+ const py = scale.y(s.principal[i]);
+ if (i === 0) context.moveTo(px, py);
+ else { context.lineTo(px, scale.y(s.principal[i - 1])); context.lineTo(px, py); }
+ }
+ if (i0 < n - 1) context.lineTo(tipX, scale.y(s.principal[i0]));
+ context.stroke();
+ context.restore();
+
+ // 평가금액
+ context.lineJoin = "round";
+ context.lineCap = "round";
+ context.strokeStyle = inkAlpha(1 * wA + 0.22 * wB);
+ context.lineWidth = 1.6;
+ traceTo(s.value, false);
+ context.stroke();
+
+ // 시간가중 자산(적립 제외)
+ if (wB > 0.01) {
+ context.strokeStyle = inkAlpha(wB);
+ context.lineWidth = 1.6;
+ traceTo(s.twr, false);
+ context.stroke();
+ }
+
+ // 적립 시점
+ s.markers.forEach((m) => {
+ if (m.index > tip) return;
+ const mx = x(m.index);
+ const alpha = 0.35 + 0.55 * wA;
+ context.strokeStyle = inkAlpha(0.35 * alpha);
+ context.lineWidth = 1;
+ context.beginPath();
+ context.moveTo(mx, L.bottom - 4);
+ context.lineTo(mx, scale.y(s.value[m.index]) - 26);
+ context.stroke();
+ context.fillStyle = inkAlpha(alpha);
+ context.textAlign = mx > L.W * 0.75 ? "right" : "left";
+ context.font = `500 12px ${FONT}`;
+ context.fillText(`${signedWon(m.amount)} 적립`, mx + (mx > L.W * 0.75 ? -6 : 6), scale.y(s.value[m.index]) - 34);
+ context.font = `400 12px ${FONT}`;
+ });
+
+ // 끝점
+ const emphasizeTwr = wB >= 0.5;
+ const tipValue = emphasizeTwr ? interp(s.twr) : interp(s.value);
+ const tipY = scale.y(tipValue);
+ context.fillStyle = COLORS.ink;
+ context.beginPath();
+ context.arc(tipX, tipY, 4, 0, Math.PI * 2);
+ context.fill();
+ context.strokeStyle = inkAlpha(0.25);
+ context.beginPath();
+ context.arc(tipX, tipY, 9, 0, Math.PI * 2);
+ context.stroke();
+ const rowIdx = Math.min(n - 1, Math.round(tip));
+ const labelRight = tipX < L.W * 0.62;
+ context.textAlign = labelRight ? "left" : "right";
+ const lx = tipX + (labelRight ? 14 : -14);
+ context.fillStyle = COLORS.ink;
+ context.font = `500 13.5px ${FONT}`;
+ context.fillText(emphasizeTwr ? `${pct(interp(s.cr))} 시간가중` : won(tipValue), lx, tipY - 9);
+ context.font = `400 12px ${FONT}`;
+ context.fillStyle = inkAlpha(0.55);
+ context.fillText(fmtMD(s.rows[rowIdx].date), lx, tipY + 9);
+ }
+
+ function renderStoryPanels() {
+ const b = story.basket;
+ const s = story.series;
+ if (!b) {
+ $("p1Eyebrow").textContent = "활성화된 계좌가 없습니다";
+ $("p1Title").innerHTML = '
기록 없음';
+ $("p1Sub").textContent = "config/baskets.yaml에서 모의투자 계좌를 켜면 첫 기록부터 이곳에 그려집니다.";
+ return;
+ }
+ const snap = b.snapshot;
+ const total = snap ? Number(snap.total_value) : null;
+ const principal = Number(b.principal || 0);
+ const profit = total == null ? null : total - principal;
+ $("p1Eyebrow").textContent = `${b.display_name} · ${modeLabel()}${snap ? ` · ${fmtMD(snap.date)} 기준` : ""}`;
+ $("p1Title").innerHTML = total == null
+ ? '
첫 기록 대기'
+ : `
${escapeHtml(nf0.format(Math.round(total)))}원`;
+ $("p1Sub").textContent = total == null
+ ? "모의투자를 한 번 실행하면 총자산, 투자원금, 수익률이 여기에 나타납니다."
+ : `투자원금 ${won(principal)} · 평가손익 ${signedWon(profit)} (${pct(principal > 0 ? (profit / principal) * 100 : null)})`;
+
+ if (!s || s.rows.length < 2) {
+ $("p2Title").innerHTML = '
—';
+ $("p2Sub").textContent = "기록이 이틀 이상 쌓이면 적립을 제외한 운용 성과가 계산됩니다.";
+ $("p3Title").innerHTML = '
—';
+ $("p3Sub").textContent = "고점과 낙폭은 기록이 쌓인 뒤 계산됩니다.";
+ return;
+ }
+ const last = s.rows[s.rows.length - 1];
+ const cr = Number(last.cumulative_return || 0);
+ const lastFlow = s.markers.length ? s.markers[s.markers.length - 1] : null;
+ $("p2Title").innerHTML = `
${escapeHtml(pct(cr))}시간가중수익률 · ${escapeHtml(fmtMD(s.rows[0].date))}부터 ${s.rows.length}거래일`;
+ $("p2Sub").textContent = lastFlow
+ ? `${fmtMD(lastFlow.at)}에 넣은 적립금 ${won(lastFlow.amount)}은 수익이 아니라 원금입니다. 시간가중수익률은 입금 시점의 영향을 걷어내고 운용 결과만 남깁니다.`
+ : "아직 적립 기록이 없어 평가금액과 운용 성과가 같습니다. 적립이 시작되면 입금 시점의 영향을 걷어낸 시간가중수익률로 봅니다.";
+
+ const currentDD = s.dd[s.dd.length - 1] * 100;
+ const fromStart = s.maxDDPeakIdx < 0;
+ const peakLabel = fromStart ? `${fmtMD(s.rows[0].date)} 시작` : `${fmtMD(s.dates[s.maxDDPeakIdx])} 고점`;
+ const troughDate = s.dates[s.maxDDIdx];
+ const fallDays = s.maxDDIdx - Math.max(0, s.maxDDPeakIdx);
+ const recovered = currentDD > -0.005;
+ $("p3Title").innerHTML = `
${escapeHtml(pct(s.maxDD * 100))}${s.maxDD < 0 ? `${escapeHtml(peakLabel)} → ${escapeHtml(fmtMD(troughDate))} 저점` : "아직 고점 아래로 내려간 적이 없습니다"}`;
+ $("p3Sub").textContent = s.maxDD < 0
+ ? `${fromStart ? `시작 자본 ${won(s.initialCapital)}을 고점으로` : `${peakLabel}에서`} ${fmtMD(troughDate)}까지 ${fallDays}거래일 동안 내려온 폭입니다. ${recovered ? "지금은 고점을 회복했습니다." : `지금은 고점보다 ${pct(currentDD, { sign: false })} 낮은 자리에 있습니다.`}`
+ : "기록 기간 내내 새 고점을 갱신했습니다. 낙폭은 첫 하락이 나타난 뒤부터 계산됩니다.";
+ }
+
+ function initStory() {
+ const onScroll = () => { readStoryScroll(); wakeStory(); };
+ window.addEventListener("scroll", onScroll, { passive: true });
+ window.addEventListener("resize", () => { readStoryScroll(); story.dirty = true; wakeStory(); });
+ if ("ResizeObserver" in window) {
+ new ResizeObserver(() => { story.dirty = true; wakeStory(); }).observe(el.stage);
+ }
+ if (document.fonts && document.fonts.ready) {
+ document.fonts.ready.then(() => { story.dirty = true; wakeStory(); drawChart(state.chartRows); });
+ }
+ readStoryScroll();
+ story.seekAt = story.progress;
+ wakeStory();
+ }
+
+ function updateStoryData() {
+ const basket = primaryBasket();
+ story.basket = basket;
+ if (basket && state.series.get(basket.account_key)) {
+ story.series = buildSeries(state.series.get(basket.account_key), state.flows.get(basket.basket) || [], Number(basket.initial_capital || 0));
+ } else {
+ story.series = null;
+ }
+ renderStoryPanels();
+ story.dirty = true;
+ wakeStory();
+ }
+
+ /* ---------- 오늘 ---------- */
+
+ function latestSnapshotDate() {
+ const b = primaryBasket();
+ return b && b.snapshot ? b.snapshot.date : null;
+ }
+ function currentMonthContributionState(basket) {
+ if (!basket || !basket.contribution_plan?.enabled) return "not-planned";
+ if (state.flowStatus.get(basket.basket) !== "ready") return "unknown";
+ const now = new Date();
+ const recorded = (state.flows.get(basket.basket) || []).some((flow) => {
+ const when = parseDate(flow.occurred_at);
+ return when && when.getFullYear() === now.getFullYear() && when.getMonth() === now.getMonth();
+ });
+ return recorded ? "recorded" : "empty";
+ }
+ function runtimeAgeMinutes() {
+ const parsed = parseDate(state.runtime && state.runtime.runtime_file_updated_at);
+ return parsed ? Math.max(0, Math.round((Date.now() - parsed.getTime()) / 60_000)) : null;
+ }
+ function setDecision({ title, description, action = null, actionLabel = "확인하기" }) {
+ el.decisionTitle.textContent = title;
+ el.decisionDescription.textContent = description;
+ el.decisionAction.hidden = !action;
+ el.decisionAction.dataset.action = action || "";
+ el.decisionAction.textContent = actionLabel;
+ }
+ function renderDecision() {
+ const baskets = state.baskets;
+ const halt = state.runtime && state.runtime.trading_halt;
+ const latest = latestSnapshotDate();
+ const ageDays = calendarAgeDays(latest);
+ const issues = (state.evaluations || []).flatMap((item) => item.issues || []);
+ const primary = primaryBasket();
+ const contribution = currentMonthContributionState(primary);
+ el.decisionMeta.textContent = `${modeLabel()}${latest ? ` · 마지막 자산 기록 ${fmtLong(latest)}` : " · 자산 기록 없음"}`;
+
+ if (halt && halt.halted) {
+ setDecision({ title: "거래가 중지된 상태입니다", description: halt.reason || "체결과 장부를 대조하기 전까지 신규 주문이 막혀 있습니다.", action: "operations", actionLabel: "운영 상태 보기" });
+ return;
+ }
+ if (state.coreError) {
+ setDecision({ title: "장부를 읽지 못했습니다", description: "화면의 숫자는 마지막으로 성공한 조회 결과입니다. 연결을 확인한 뒤 다시 시도하세요.", action: "retry", actionLabel: "다시 확인" });
+ return;
+ }
+ if (state.runtimeStatus === "loading") {
+ setDecision({ title: "운영 상태를 확인하고 있습니다", description: "거래 중지 여부와 자동매매 기록을 확인한 뒤 오늘 할 일을 정리합니다." });
+ return;
+ }
+ if (state.runtimeStatus === "error") {
+ setDecision({ title: "운영 상태를 확인할 수 없습니다", description: "거래 중지 여부를 모르는 상태에서는 적립 기록이나 설정 변경을 하지 마세요.", action: "retry", actionLabel: "다시 확인" });
+ return;
+ }
+ if (baskets && !baskets.length) {
+ setDecision({ title: "첫 모의투자 계좌를 켜 주세요", description: "config/baskets.yaml에서 계좌를 활성화하고 모의투자를 한 번 실행하면 첫 기록이 생깁니다.", action: "portfolio", actionLabel: "포트폴리오 보기" });
+ return;
+ }
+ if (baskets && baskets.length && !latest) {
+ setDecision({ title: "첫 자산 기록을 기다리고 있습니다", description: "모의투자를 한 번 실행하면 투자원금, 총자산, 수익률이 분리되어 보입니다.", action: "portfolio", actionLabel: "포트폴리오 보기" });
+ return;
+ }
+ if (ageDays != null && ageDays > 4) {
+ setDecision({ title: `자산 기록이 ${ageDays}일째 멈춰 있습니다`, description: `마지막 기록은 ${fmtLong(latest)}입니다. 자동매매가 멈췄을 수 있으니 운영 상태부터 확인하세요.`, action: "operations", actionLabel: "운영 상태 보기" });
+ return;
+ }
+ const loopMetrics = state.runtime && state.runtime.loop_metrics;
+ const loopLastAt = parseDate(loopMetrics && loopMetrics.last_success) || parseDate(state.runtime && state.runtime.runtime_file_updated_at);
+ const runtimeAge = loopLastAt ? Math.max(0, Math.round((Date.now() - loopLastAt.getTime()) / 60_000)) : null;
+ if (runtimeAge == null || runtimeAge > 720) {
+ setDecision({
+ title: runtimeAge == null ? "자동매매 실행 기록이 없습니다" : `자동매매가 ${sinceCopy(runtimeAge)} 실행되지 않았습니다`,
+ description: runtimeAge == null ? "스케줄러 기록이 보이지 않습니다. 오늘 사이클이 돌았는지 먼저 확인하세요." : `마지막 실행은 ${fmtDT(loopLastAt)}입니다. 적립보다 스케줄러 상태를 먼저 확인하세요.`,
+ action: "operations", actionLabel: "운영 상태 보기",
+ });
+ return;
+ }
+ if (issues.length) {
+ setDecision({ title: `검토가 필요한 항목이 ${issues.length}건 있습니다`, description: "실전 전환은 검토가 끝날 때까지 잠겨 있습니다.", action: "review", actionLabel: "검토 항목 보기" });
+ return;
+ }
+ if (contribution === "unknown") {
+ setDecision({ title: "적립 기록을 확인할 수 없습니다", description: "조회가 끝나기 전에는 같은 적립금을 두 번 기록하지 않도록 기다려 주세요.", action: "retry", actionLabel: "다시 확인" });
+ return;
+ }
+ if (contribution === "empty") {
+ const planned = Number(primary.contribution_plan?.amount || 0);
+ setDecision({ title: "이번 달 적립을 아직 기록하지 않았습니다", description: `${planned > 0 ? `기준은 매월 ${won(planned)}입니다. ` : ""}실제 입금이 끝난 뒤에만 같은 금액을 기록하세요.`, action: "deposit", actionLabel: "적립 기록" });
+ return;
+ }
+ setDecision({ title: "오늘은 손댈 것이 없습니다", description: "계획대로 기록을 쌓는 중입니다. 시세를 매일 확인하거나 전략을 바꿀 이유가 없습니다." });
+ }
+
+ function statusItem(key, value, stateName) {
+ return `
${escapeHtml(key)}${escapeHtml(value)}`;
+ }
+
+ /* ---------- 포트폴리오 ---------- */
+
+ function sparkline(rows) {
+ const values = (rows || []).slice(-60).map((r) => Number(r.total_value || 0)).filter(Number.isFinite);
+ if (values.length < 2) return "";
+ const w = 180;
+ const h = 52;
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+ const span = Math.max(1, max - min);
+ const px = (i) => (i / (values.length - 1)) * w;
+ const py = (v) => 6 + ((max - v) / span) * (h - 12);
+ const d = values.map((v, i) => `${i === 0 ? "M" : "L"}${px(i).toFixed(1)} ${py(v).toFixed(1)}`).join(" ");
+ const last = values[values.length - 1];
+ return `
`;
+ }
+
+ function holdingsTable(basket) {
+ const positions = basket.positions || [];
+ if (!positions.length) return '
아직 보유 종목이 없습니다.
';
+ const total = basket.snapshot ? Number(basket.snapshot.total_value) : null;
+ const targets = basket.target_weights || {};
+ const rows = positions.map((p) => ({
+ ...p,
+ weight: total ? Number(p.invested || 0) / total : null,
+ target: targets[p.symbol] == null ? null : Number(targets[p.symbol]),
+ }));
+ const maxW = Math.max(0.01, ...rows.map((r) => Math.max(r.weight || 0, r.target || 0)));
+ const barWidth = (w) => `${Math.round(((w || 0) / maxW) * 100)}%`;
+ const holdingsValue = basket.holdings_value == null ? null : Number(basket.holdings_value);
+ const holdingsCost = basket.holdings_cost == null ? null : Number(basket.holdings_cost);
+ const holdingsPnl = holdingsValue == null || holdingsCost == null ? null : holdingsValue - holdingsCost;
+ return `
+
현재가는 장부에 저장하지 않으므로 종목별 비중은 매입금액 기준이고, 보유분 평가금액은 총자산에서 현금을 뺀 값입니다.
`;
+ }
+
+ function renderBasketTracks(data) {
+ const baskets = (data && data.baskets) || [];
+ state.baskets = baskets;
+ el.basketTracks.setAttribute("aria-busy", "false");
+ setMode(data && data.mode);
+ markCoreSuccess(data && data.timestamp);
+
+ if (!baskets.length) {
+ el.basketTracks.innerHTML = '
활성화된 계좌가 없습니다.config/baskets.yaml에서 모의투자 계좌를 먼저 켜 주세요.
';
+ el.portfolioAsOf.textContent = "기준일 없음";
+ el.portfolioAsOf.dateTime = "";
+ updateStoryData();
+ return;
+ }
+
+ const firstRender = !state.tracksAnimated && !noMotion();
+ el.basketTracks.innerHTML = sortedBaskets().map((b) => {
+ const snap = b.snapshot;
+ const total = snap ? Number(snap.total_value) : null;
+ const principal = Number(b.principal || 0);
+ const profit = total == null ? null : total - principal;
+ const cr = snap ? Number(snap.cumulative_return) : null;
+ const cashRatio = snap && total > 0 ? (Number(snap.cash) / total) * 100 : null;
+ const deployment = b.deployment_ratio == null ? null : Number(b.deployment_ratio) * 100;
+ const target = b.design_fraction == null ? null : Number(b.design_fraction) * 100;
+ // 장부의 mdd는 그날의 고점 대비 낙폭이므로, 최대낙폭은 기록 전체에서 가장 깊었던 값을 고른다.
+ const maxDD = Math.max(Number(snap ? snap.mdd : 0) || 0, ...(state.series.get(b.account_key) || []).map((r) => Number(r.mdd) || 0));
+ const gap = deployment == null || target == null ? null : target - deployment;
+ const primary = Boolean(b.is_primary);
+ const plan = b.contribution_plan || {};
+ const kind = primary
+ ? `
주력${plan.enabled && Number(plan.amount) > 0 ? ` · 매월 ${won(plan.amount)} 적립` : ""}`
+ : `
관찰 · ${escapeHtml(b.purpose || "장기 관찰용")}`;
+ const flows = state.flows.get(b.basket) || [];
+ const latestFlow = flows[0];
+ const flowState = state.flowStatus.get(b.basket);
+ const note = gap != null && gap > 1
+ ? (primary
+ ? `목표보다 ${Math.round(gap)}%p 낮습니다. ETF를 1주 단위로 사기 때문에 소액에서는 미달이 흔하고, 적립이 쌓이면 좁혀집니다.`
+ : `목표보다 ${Math.round(gap)}%p 낮습니다. 관찰 계좌는 큰 이탈만 점검합니다.`)
+ : "목표 범위 안에서 운용 중입니다.";
+ const overlay = b.overlay;
+ const baseTarget = b.base_stock_fraction == null ? null : Number(b.base_stock_fraction) * 100;
+ const overlayLine = overlay && overlay.enabled
+ ? `
위험 조절 ${escapeHtml(overlay.summary || "")}${baseTarget != null && target != null && Math.abs(baseTarget - target) > 0.5 ? ` · 설계 ${Math.round(baseTarget)}% → 적용 ${Math.round(target)}%` : ""}${overlay.evaluated_at ? ` · ${escapeHtml(fmtDT(overlay.evaluated_at))} 판단` : ""}
`
+ : "";
+ const width = deployment == null ? 0 : clamp(deployment, 0, 100);
+ return `
+
+
+
${kind}
+
${escapeHtml(b.display_name)}
+
${escapeHtml(b.basket)}${snap ? ` · ${escapeHtml(fmtLong(snap.date))} 기준` : " · 첫 기록 대기"}
- ${holdings}
- ${latestFlow ? `최근 적립 · ${formatDate(latestFlow.occurred_at, dateShort)} · +${formatWon(latestFlow.amount)}${latestFlow.note ? ` · ${escapeHtml(latestFlow.note)}` : ""}
` : ""}
- ${flowState === "error" ? '적립 기록을 확인할 수 없습니다. 새 기록을 추가하지 말고 다시 확인하세요.
' : ""}
+ ${sparkline(state.series.get(b.account_key))}
+
+
+ - 총자산
- ${total == null ? "—" : won(total)}
+ - 투자원금
- ${won(principal)}${Number(b.deposits_total) > 0 ? ` 적립 ${compactWon(b.deposits_total)} 포함` : ""}
+ - 평가손익
- ${profit == null ? "—" : signedWon(profit)}${profit == null || principal <= 0 ? "" : ` ${pct((profit / principal) * 100)}`}
+ - 시간가중수익률
- ${cr == null ? "—" : pct(cr)}
+ - 최대낙폭
- ${snap ? pct(-maxDD) : "—"}${snap ? ` 지금 ${pct(-Math.abs(Number(snap.mdd || 0)))}` : ""}
+ - 현금 비중
- ${cashRatio == null ? "—" : pct(cashRatio, { sign: false, digits: 1 })}${snap ? ` ${compactWon(snap.cash)}` : ""}
+
+
+
투자 비중${deployment == null ? "—" : `${Math.round(deployment)}%`}목표 ${target == null ? "—" : `${Math.round(target)}%`}
+
${target == null ? "" : ``}
+
${escapeHtml(note)}
+ ${overlayLine}
+ ${holdingsTable(b)}
+ ${latestFlow ? `최근 적립 ${escapeHtml(fmtMD(latestFlow.occurred_at))} · ${escapeHtml(signedWon(latestFlow.amount))}${latestFlow.note ? ` · ${escapeHtml(latestFlow.note)}` : ""}
` : ""}
+ ${flowState === "error" ? '적립 기록을 읽지 못했습니다. 다시 확인하기 전까지 새 기록을 추가하지 마세요.
' : ""}
`;
- }).join("");
-
- const referenceBasket = primaryBasket() || sortedBaskets().find((basket) => basket.snapshot);
- const referenceDate = referenceBasket && referenceBasket.snapshot && referenceBasket.snapshot.date;
- elements.portfolioAsOf.dateTime = referenceDate || "";
- elements.portfolioAsOf.textContent = referenceDate
- ? `주력 기준 ${formatDate(referenceDate)}`
- : "첫 기록 대기 중";
- renderPortfolioSummary();
-}
-
-async function refreshFlows() {
- const baskets = state.baskets || [];
- const tasks = baskets.map(async (basket) => {
- state.flowStatus.set(basket.basket, "loading");
- try {
- const data = await fetchJson(`/api/cash_flows?basket=${encodeURIComponent(basket.basket)}`, {
- timeout: 10_000,
- key: `flows:${basket.basket}`,
- });
- state.flows.set(basket.basket, (data && data.flows) || []);
- state.flowStatus.set(basket.basket, "ready");
- return true;
- } catch {
- state.flowStatus.set(basket.basket, "error");
- return false;
+ }).join("");
+
+ if (firstRender) {
+ state.tracksAnimated = true;
+ requestAnimationFrame(() => requestAnimationFrame(() => {
+ el.basketTracks.querySelectorAll(".bar i[data-width]").forEach((bar) => { bar.style.width = `${bar.dataset.width}%`; });
+ }));
}
- });
- const results = await Promise.all(tasks);
- state.flowError = results.some((ok) => !ok);
- if (state.baskets) renderBasketTracks({ baskets: state.baskets, mode: state.mode, timestamp: state.lastCoreSuccess });
- renderDecision();
- return !state.flowError;
-}
-
-function ensureChartAccountOptions() {
- const wanted = sortedBaskets().map((basket) => ({
- value: basket.account_key,
- label: basket.display_name,
- }));
- wanted.push({ value: "", label: "레거시 기본 계정" });
- const signature = JSON.stringify(wanted);
- if (elements.chartAccount.dataset.signature === signature) return;
-
- const previous = state.chartAccount;
- elements.chartAccount.dataset.signature = signature;
- elements.chartAccount.innerHTML = wanted.map((option) => (
- `
`
- )).join("");
- const exists = previous !== null && wanted.some((option) => option.value === previous);
- elements.chartAccount.value = exists ? previous : (wanted[0] ? wanted[0].value : "");
- state.chartAccount = elements.chartAccount.value;
-}
-
-function syncChartQuery() {
- const params = new URLSearchParams(window.location.search);
- params.set("days", String(state.chartDays));
- if (state.chartAccount) params.set("account", state.chartAccount);
- else params.delete("account");
- const queryString = params.toString();
- history.replaceState(null, "", `${window.location.pathname}${queryString ? `?${queryString}` : ""}${window.location.hash}`);
-}
-
-function renderChartTable(snapshots) {
- elements.chartRows.innerHTML = snapshots.map((snapshot) => `
-
- |
- ${escapeHtml(formatWon(snapshot.total_value))} |
- ${escapeHtml(formatPercent(snapshot.cumulative_return))} |
+
+ const reference = primaryBasket();
+ const referenceDate = reference && reference.snapshot && reference.snapshot.date;
+ el.portfolioAsOf.dateTime = referenceDate || "";
+ el.portfolioAsOf.textContent = referenceDate ? `${fmtLong(referenceDate)} 기록 기준` : "첫 기록 대기";
+ updateStoryData();
+ }
+
+ async function refreshFlows() {
+ const baskets = state.baskets || [];
+ const results = await Promise.all(baskets.map(async (b) => {
+ state.flowStatus.set(b.basket, "loading");
+ try {
+ const data = await fetchJson(`/api/cash_flows?basket=${encodeURIComponent(b.basket)}`, { timeout: 10_000, key: `flows:${b.basket}` });
+ state.flows.set(b.basket, (data && data.flows) || []);
+ state.flowStatus.set(b.basket, "ready");
+ return true;
+ } catch {
+ state.flowStatus.set(b.basket, "error");
+ return false;
+ }
+ }));
+ state.flowError = results.some((ok) => !ok);
+ return !state.flowError;
+ }
+
+ async function refreshSeries() {
+ const baskets = state.baskets || [];
+ await Promise.all(baskets.map(async (b) => {
+ try {
+ const data = await fetchJson(`/api/snapshots?days=3650&account_key=${encodeURIComponent(b.account_key)}`, { timeout: 15_000, key: `series:${b.account_key}` });
+ state.series.set(b.account_key, (data && data.snapshots) || []);
+ state.seriesStatus.set(b.account_key, "ready");
+ } catch {
+ state.seriesStatus.set(b.account_key, "error");
+ }
+ }));
+ }
+
+ /* ---------- 성과 ---------- */
+
+ const chart = { progress: 1, frame: null, hover: null, geometry: null, series: null, rowsKey: "" };
+
+ function ensureChartAccountOptions() {
+ const wanted = sortedBaskets().map((b) => ({ value: b.account_key, label: b.display_name }));
+ wanted.push({ value: "", label: "레거시 기본 계정" });
+ const signature = JSON.stringify(wanted);
+ if (el.chartAccount.dataset.signature === signature) return;
+ const previous = state.chartAccount;
+ el.chartAccount.dataset.signature = signature;
+ el.chartAccount.innerHTML = wanted.map((o) => ``).join("");
+ const exists = previous !== null && wanted.some((o) => o.value === previous);
+ el.chartAccount.value = exists ? previous : (wanted[0] ? wanted[0].value : "");
+ state.chartAccount = el.chartAccount.value;
+ }
+ function syncChartQuery() {
+ const params = new URLSearchParams(window.location.search);
+ params.set("days", String(state.chartDays));
+ if (state.chartAccount) params.set("account", state.chartAccount); else params.delete("account");
+ const qs = params.toString();
+ history.replaceState(null, "", `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`);
+ }
+ function chartBasket() {
+ return (state.baskets || []).find((b) => b.account_key === state.chartAccount) || null;
+ }
+ function renderChartMaturity() {
+ const basket = chartBasket();
+ if (!basket) { el.chartMaturity.textContent = "레거시 기본 계정은 모의투자 검증과 별개입니다."; return; }
+ const evaluation = (state.evaluations || []).find((item) => item.basket === basket.basket);
+ if (!evaluation) { el.chartMaturity.textContent = ""; return; }
+ const days = Number(evaluation.progress_days || 0);
+ const minimum = Math.max(1, Number(evaluation.min_trading_days || 60));
+ el.chartMaturity.textContent = days < 20
+ ? `운용 ${days}거래일째 · 추세를 판단하기엔 이릅니다`
+ : days < minimum ? `운용 ${days}거래일째 · ${minimum}거래일까지는 기록 무결성을 먼저 봅니다` : `운용 ${days}거래일째 · 장기 검토가 가능한 구간입니다`;
+ }
+
+ function renderChartTable(series) {
+ el.chartRows.innerHTML = series.rows.map((row, i) => `
+ |
+ ${won(row.total_value)} |
+ ${won(series.principal[i])} |
+ ${pct(row.cumulative_return)} |
`).join("");
-}
-
-function renderChartMaturity() {
- const basket = (state.baskets || []).find((item) => item.account_key === state.chartAccount);
- if (!basket) {
- elements.chartMaturity.textContent = "레거시 기본 계정은 모의 운용 검증 트랙과 분리됩니다.";
- return;
- }
- const evaluation = (state.evaluations || []).find((item) => item.basket === basket.basket);
- if (!evaluation) {
- elements.chartMaturity.textContent = "운용 기록 성숙도를 확인하고 있습니다.";
- return;
- }
- const days = Number(evaluation.progress_days || 0);
- const minimum = Math.max(1, Number(evaluation.min_trading_days || 60));
- if (days < 20) {
- elements.chartMaturity.textContent = `운용 ${days}영업일차 · 장기 추세를 판단하기 전입니다.`;
- } else if (days < minimum) {
- elements.chartMaturity.textContent = `운용 ${days}영업일차 · ${minimum}영업일까지 기록 무결성을 우선 확인합니다.`;
- } else {
- elements.chartMaturity.textContent = `운용 ${days}영업일차 · 장기 기록 검토가 가능한 구간입니다.`;
- }
-}
-
-function drawChart(rows) {
- if (!rows.length || elements.chartWrap.hidden) return;
- const canvas = $("chartEquity");
- const width = Math.max(280, elements.chartWrap.clientWidth);
- const height = Math.max(280, elements.chartWrap.clientHeight);
- const ratio = Math.min(2, window.devicePixelRatio || 1);
- canvas.width = Math.round(width * ratio);
- canvas.height = Math.round(height * ratio);
- canvas.style.width = `${width}px`;
- canvas.style.height = `${height}px`;
-
- const context = canvas.getContext("2d");
- context.setTransform(ratio, 0, 0, ratio, 0, 0);
- context.clearRect(0, 0, width, height);
-
- const padding = { top: 22, right: 18, bottom: 38, left: width < 480 ? 58 : 72 };
- const plotWidth = width - padding.left - padding.right;
- const plotHeight = height - padding.top - padding.bottom;
- const values = rows.map((row) => Number(row.total_value || 0));
- let minimum = Math.min(...values);
- let maximum = Math.max(...values);
- const spread = Math.max(1, maximum - minimum);
- minimum -= spread * 0.08;
- maximum += spread * 0.08;
-
- const x = (index) => padding.left + (plotWidth * index) / Math.max(1, rows.length - 1);
- const y = (value) => padding.top + ((maximum - value) / (maximum - minimum)) * plotHeight;
-
- context.font = '10px "IBM Plex Mono", monospace';
- context.fillStyle = "#5d6966";
- context.strokeStyle = "#e5e1d8";
- context.lineWidth = 1;
- context.textAlign = "right";
- context.textBaseline = "middle";
- for (let index = 0; index <= 4; index += 1) {
- const value = maximum - ((maximum - minimum) * index) / 4;
- const lineY = padding.top + (plotHeight * index) / 4;
+ }
+
+ function renderMonthStrip(series) {
+ const months = monthlyReturns(series);
+ el.monthStrip.innerHTML = months.map((m) => `
${escapeHtml(m.label)}${pct(m.value, { digits: 1 })}
`).join("");
+ }
+
+ function equityGeometry(series, width, height) {
+ const padding = { top: 20, right: 12, bottom: 28, left: width < 480 ? 52 : 64 };
+ const n = series.rows.length;
+ const x = (i) => padding.left + ((width - padding.left - padding.right) * i) / Math.max(1, n - 1);
+ const scale = makeScale([...series.value, ...series.principal], padding.top, height - padding.bottom, 0.08);
+ return { padding, x, scale, width, height, n };
+ }
+
+ function drawChart(rows) {
+ if (!chart.series || el.chartWrap.hidden) return;
+ const series = chart.series;
+ const width = Math.max(280, el.chartBox.clientWidth);
+ const height = Math.max(200, el.chartBox.clientHeight);
+ const context = setupCanvas(el.chartEquity, width, height);
+ const g = equityGeometry(series, width, height);
+ chart.geometry = g;
+ const { padding, x, scale, n } = g;
+ context.font = `400 11.5px ${FONT}`;
+ context.textBaseline = "middle";
+
+ context.textAlign = "right";
+ niceTicks(scale.min, scale.max, 4).forEach((v, i) => {
+ const y = scale.y(v);
+ context.strokeStyle = inkAlpha(i === 4 ? 0.34 : 0.12);
+ context.lineWidth = 1;
+ context.beginPath();
+ context.moveTo(padding.left, y);
+ context.lineTo(width - padding.right, y);
+ context.stroke();
+ context.fillStyle = inkAlpha(0.42);
+ context.fillText(compactWon(v), padding.left - 8, y);
+ });
+ const step = Math.max(1, Math.ceil((n - 1) / (width < 480 ? 3 : 6)));
+ context.fillStyle = inkAlpha(0.42);
+ for (let i = 0; i < n - 1; i += step) {
+ if (n > 1 && i > n - 1 - step * 0.5) break;
+ context.textAlign = i === 0 ? "left" : "center";
+ context.fillText(fmtMD(series.rows[i].date), x(i), height - padding.bottom + 14);
+ }
+ context.textAlign = n > 1 ? "right" : "left";
+ context.fillText(fmtMD(series.rows[n - 1].date), x(n - 1), height - padding.bottom + 14);
+
+ const visible = Math.max(1, Math.ceil((n - 1) * chart.progress) + 1);
+ const clipRight = n > 1 ? x(0) + (x(n - 1) - x(0)) * chart.progress : width;
+ context.save();
+ context.beginPath();
+ context.rect(0, 0, clipRight + 1, height);
+ context.clip();
+
+ context.save();
+ context.setLineDash([3, 4]);
+ context.strokeStyle = inkAlpha(0.5);
+ context.lineWidth = 1;
context.beginPath();
- context.moveTo(padding.left, lineY);
- context.lineTo(width - padding.right, lineY);
+ series.principal.forEach((v, i) => {
+ if (i >= visible) return;
+ const px = x(i);
+ const py = scale.y(v);
+ if (i === 0) context.moveTo(px, py);
+ else { context.lineTo(px, scale.y(series.principal[i - 1])); context.lineTo(px, py); }
+ });
context.stroke();
- context.fillText(`${compactWon.format(value)}원`, padding.left - 9, lineY);
- }
-
- context.strokeStyle = "#18201f";
- context.lineWidth = 2;
- context.beginPath();
- rows.forEach((row, index) => {
- const pointX = x(index);
- const pointY = y(values[index]);
- if (index === 0) context.moveTo(pointX, pointY);
- else context.lineTo(pointX, pointY);
- });
- context.stroke();
-
- const tickStep = Math.max(1, Math.ceil((rows.length - 1) / 5));
- context.textAlign = "center";
- context.textBaseline = "top";
- rows.forEach((row, index) => {
- if (index % tickStep !== 0 && index !== rows.length - 1) return;
- context.fillStyle = "#5d6966";
- context.fillText(formatDate(row.date, dateShort), x(index), height - padding.bottom + 12);
- });
-
- if (rows.length <= 14) {
- rows.forEach((row, index) => {
+ context.restore();
+
+ series.markers.forEach((m) => {
+ if (m.index >= visible) return;
+ const mx = x(m.index);
+ context.strokeStyle = inkAlpha(0.28);
context.beginPath();
- context.arc(x(index), y(values[index]), 3, 0, Math.PI * 2);
- context.fillStyle = "#b84a2a";
- context.fill();
- context.strokeStyle = "#fffdf8";
- context.lineWidth = 1;
+ context.moveTo(mx, padding.top);
+ context.lineTo(mx, height - padding.bottom);
context.stroke();
+ context.fillStyle = inkAlpha(0.7);
+ context.font = `500 11.5px ${FONT}`;
+ context.textAlign = mx > width * 0.8 ? "right" : "left";
+ context.fillText(`${signedWon(m.amount)} 적립`, mx + (mx > width * 0.8 ? -5 : 5), padding.top - 8);
+ context.font = `400 11.5px ${FONT}`;
});
- }
-}
-
-function updateChart(snapshots) {
- const rows = Array.isArray(snapshots) ? snapshots : [];
- state.chartRows = rows;
- renderChartTable(rows);
- renderChartMaturity();
-
- if (!rows.length) {
- elements.chartWrap.hidden = true;
- elements.chartEmpty.hidden = false;
- elements.chartSummary.textContent = "아직 선택한 기간의 자산 기록이 없습니다.";
- return;
- }
-
- const first = rows[0];
- const last = rows.at(-1);
- const change = Number(last.total_value || 0) - Number(first.total_value || 0);
- elements.chartSummary.textContent = `${formatDate(first.date, dateShort)}부터 ${formatDate(last.date, dateShort)}까지 ${change >= 0 ? "+" : ""}${formatWon(change)} · 최근 TWR ${formatPercent(last.cumulative_return)}`;
- elements.chartWrap.hidden = false;
- elements.chartEmpty.hidden = true;
- drawChart(rows);
-}
-
-async function refreshChart() {
- ensureChartAccountOptions();
- state.chartAccount = elements.chartAccount.value;
- syncChartQuery();
- try {
- const data = await fetchJson(`/api/snapshots?days=${state.chartDays}&account_key=${encodeURIComponent(state.chartAccount || "")}`, {
- timeout: 15_000,
- key: "chart",
+
+ context.strokeStyle = COLORS.ink;
+ context.lineWidth = 1.6;
+ context.lineJoin = "round";
+ context.beginPath();
+ series.value.forEach((v, i) => {
+ if (i >= visible) return;
+ const px = x(i);
+ const py = scale.y(v);
+ if (i === 0) context.moveTo(px, py); else context.lineTo(px, py);
});
- updateChart((data && data.snapshots) || []);
- } catch {
- elements.chartSummary.textContent = "성과 기록을 불러오지 못했습니다. 연결을 확인한 뒤 다시 시도하세요.";
- elements.chartWrap.hidden = true;
- elements.chartEmpty.hidden = false;
- elements.chartEmpty.innerHTML = "
성과 데이터를 확인할 수 없습니다.기존 장부는 변경되지 않았습니다. 잠시 후 다시 확인하세요.";
- }
-}
-
-const verdictCopy = {
- PASS_CANDIDATE: ["검토 준비", "positive"],
- FAIL_REVIEW: ["재점검 필요", "negative"],
- WAIT: ["관찰 중", "muted"],
-};
-
-function renderEvaluations(evaluations) {
- const items = Array.isArray(evaluations) ? evaluations : [];
- state.evaluations = items;
- elements.basketEval.setAttribute("aria-busy", "false");
- if (!items.length) {
- elements.basketEval.innerHTML = '
검토 중인 포트폴리오가 없습니다.
';
+ context.stroke();
+ if (n <= 14) {
+ series.value.forEach((v, i) => {
+ if (i >= visible) return;
+ context.beginPath();
+ context.arc(x(i), scale.y(v), 2.5, 0, Math.PI * 2);
+ context.fillStyle = COLORS.ink;
+ context.fill();
+ });
+ }
+ context.restore();
+
+ if (chart.hover != null && chart.progress >= 1) {
+ const i = chart.hover;
+ const px = x(i);
+ const py = scale.y(series.value[i]);
+ context.save();
+ context.setLineDash([2, 3]);
+ context.strokeStyle = inkAlpha(0.5);
+ context.beginPath();
+ context.moveTo(px, padding.top);
+ context.lineTo(px, height - padding.bottom);
+ context.stroke();
+ context.restore();
+ context.beginPath();
+ context.arc(px, py, 4.5, 0, Math.PI * 2);
+ context.fillStyle = COLORS.ink;
+ context.fill();
+ context.strokeStyle = "#f2f0ec";
+ context.lineWidth = 2;
+ context.stroke();
+ }
+ drawDrawdown(series);
+ }
+
+ function drawDrawdown(series) {
+ const box = $("drawdownBox");
+ const width = Math.max(280, box.clientWidth);
+ const height = Math.max(80, box.clientHeight);
+ const context = setupCanvas(el.chartDrawdown, width, height);
+ const padding = { top: 10, right: 12, bottom: 8, left: width < 480 ? 52 : 64 };
+ const n = series.rows.length;
+ const x = (i) => padding.left + ((width - padding.left - padding.right) * i) / Math.max(1, n - 1);
+ const minDD = Math.min(-0.005, ...series.dd);
+ const y = (v) => padding.top + ((0 - v) / (0 - minDD)) * (height - padding.top - padding.bottom);
+ context.font = `400 11.5px ${FONT}`;
+ context.textBaseline = "middle";
+ context.textAlign = "right";
+ context.strokeStyle = inkAlpha(0.34);
+ context.beginPath();
+ context.moveTo(padding.left, y(0));
+ context.lineTo(width - padding.right, y(0));
+ context.stroke();
+ context.fillStyle = inkAlpha(0.42);
+ context.fillText("0%", padding.left - 8, y(0));
+ context.fillText(pct(minDD * 100, { digits: 1 }), padding.left - 8, y(minDD));
+ const visible = Math.max(1, Math.ceil((n - 1) * chart.progress) + 1);
+ context.beginPath();
+ context.moveTo(x(0), y(0));
+ series.dd.forEach((v, i) => { if (i < visible) context.lineTo(x(i), y(v)); });
+ context.lineTo(x(Math.min(n - 1, visible - 1)), y(0));
+ context.closePath();
+ context.fillStyle = "rgba(37, 87, 201, 0.16)";
+ context.fill();
+ context.strokeStyle = COLORS.down;
+ context.lineWidth = 1.2;
+ context.beginPath();
+ series.dd.forEach((v, i) => { if (i < visible) { if (i === 0) context.moveTo(x(i), y(v)); else context.lineTo(x(i), y(v)); } });
+ context.stroke();
+ if (chart.hover != null && chart.progress >= 1) {
+ const i = chart.hover;
+ context.beginPath();
+ context.arc(x(i), y(series.dd[i]), 3.5, 0, Math.PI * 2);
+ context.fillStyle = COLORS.down;
+ context.fill();
+ }
+ }
+
+ function animateChartIn() {
+ if (chart.frame) cancelAnimationFrame(chart.frame);
+ if (noMotion() || !chart.series || chart.series.rows.length < 2) {
+ chart.progress = 1;
+ drawChart();
+ return;
+ }
+ chart.progress = 0;
+ const started = performance.now();
+ const duration = 600;
+ const step = (now) => {
+ const t = Math.min(1, (now - started) / duration);
+ chart.progress = 1 - (1 - t) ** 3;
+ drawChart();
+ chart.frame = chart.progress < 1 ? requestAnimationFrame(step) : null;
+ };
+ chart.frame = requestAnimationFrame(step);
+ }
+
+ function hideChartTip() {
+ if (chart.hover == null) return;
+ chart.hover = null;
+ el.chartTip.hidden = true;
+ drawChart();
+ }
+ function handleChartPointer(event) {
+ const series = chart.series;
+ const g = chart.geometry;
+ if (!series || !g || chart.progress < 1) return;
+ const bounds = event.currentTarget.getBoundingClientRect();
+ const px = event.clientX - bounds.left;
+ const plotWidth = g.width - g.padding.left - g.padding.right;
+ const index = clamp(Math.round(((px - g.padding.left) / Math.max(1, plotWidth)) * (g.n - 1)), 0, g.n - 1);
+ if (index === chart.hover) return;
+ chart.hover = index;
+ const row = series.rows[index];
+ const tip = el.chartTip;
+ tip.querySelector("time").textContent = fmtLong(row.date);
+ tip.querySelector("strong").textContent = won(row.total_value);
+ const twr = tip.querySelector("span");
+ twr.textContent = `투자원금 ${won(series.principal[index])} · 시간가중 ${pct(row.cumulative_return)} · 낙폭 ${pct(series.dd[index] * 100, { digits: 1 })}`;
+ twr.className = tone(row.cumulative_return);
+ const tipX = clamp(g.x(index), 110, g.width - 110);
+ tip.style.transform = `translate(${tipX}px, ${g.scale.y(series.value[index])}px) translate(-50%, calc(-100% - 12px))`;
+ tip.hidden = false;
+ drawChart();
+ }
+
+ function updateChart(snapshots) {
+ const rows = Array.isArray(snapshots) ? snapshots : [];
+ const basket = chartBasket();
+ const initial = basket ? Number(basket.initial_capital || 0) : Number(state.legacy?.initial_capital || 0);
+ const flows = basket ? (state.flows.get(basket.basket) || []) : [];
+ const series = buildSeries(rows, flows, initial);
+ const key = JSON.stringify([state.chartAccount, series.rows.map((r) => [r.date, r.total_value])]);
+ const changed = key !== chart.rowsKey;
+ chart.rowsKey = key;
+ chart.series = series;
+ state.chartRows = series.rows;
+ chart.hover = null;
+ el.chartTip.hidden = true;
+ renderChartMaturity();
+
+ if (!series.rows.length) {
+ el.chartWrap.hidden = true;
+ el.chartEmpty.hidden = false;
+ el.chartSummary.textContent = "선택한 기간에 자산 기록이 없습니다.";
+ el.chartRows.innerHTML = "";
+ el.monthStrip.innerHTML = "";
+ return;
+ }
+ const first = series.rows[0];
+ const last = series.rows[series.rows.length - 1];
+ const change = Number(last.total_value || 0) - Number(first.total_value || 0);
+ const deposits = series.markers.reduce((s, m) => s + m.amount, 0);
+ el.chartSummary.textContent = `${fmtMD(first.date)} → ${fmtMD(last.date)} · 평가금액 ${signedWon(change)}${deposits ? ` (적립 ${won(deposits)} 포함)` : ""} · 시간가중 ${pct(last.cumulative_return)}`;
+ el.chartWrap.hidden = false;
+ el.chartEmpty.hidden = true;
+ renderChartTable(series);
+ renderMonthStrip(series);
+ if (changed) animateChartIn(); else drawChart();
+ }
+
+ async function refreshChart() {
+ ensureChartAccountOptions();
+ state.chartAccount = el.chartAccount.value;
+ syncChartQuery();
+ try {
+ const data = await fetchJson(`/api/snapshots?days=${state.chartDays}&account_key=${encodeURIComponent(state.chartAccount || "")}`, { timeout: 15_000, key: "chart" });
+ updateChart((data && data.snapshots) || []);
+ } catch {
+ el.chartSummary.textContent = "성과 기록을 불러오지 못했습니다. 연결을 확인한 뒤 다시 시도하세요.";
+ el.chartWrap.hidden = true;
+ el.chartEmpty.hidden = false;
+ el.chartEmpty.innerHTML = "
성과 기록을 읽지 못했습니다.장부는 바뀌지 않았습니다. 잠시 후 다시 확인하세요.";
+ }
+ }
+
+ /* ---------- 검증 ---------- */
+
+ const verdictCopy = {
+ PASS_CANDIDATE: ["검토 가능", ""],
+ FAIL_REVIEW: ["재점검 필요", "stop"],
+ WAIT: ["관찰 중", "warn"],
+ };
+ function renderEvaluations(evaluations) {
+ const items = Array.isArray(evaluations) ? evaluations : [];
+ state.evaluations = items;
+ el.basketEval.setAttribute("aria-busy", "false");
+ if (!items.length) {
+ el.basketEval.innerHTML = '
검증 중인 계좌가 없습니다.
';
+ renderDecision();
+ return;
+ }
+ const byName = new Map((state.baskets || []).map((b) => [b.basket, b]));
+ const ordered = [...items].sort((a, b) => Number(Boolean(byName.get(b.basket)?.is_primary)) - Number(Boolean(byName.get(a.basket)?.is_primary)));
+ el.basketEval.innerHTML = ordered.map((item) => {
+ const basket = byName.get(item.basket);
+ const name = basket?.display_name || item.basket;
+ const days = Number(item.progress_days || 0);
+ const minimum = Math.max(1, Number(item.min_trading_days || 60));
+ const progress = Math.min(100, Math.round((days / minimum) * 100));
+ const coverage = item.snapshot_coverage == null ? null : Math.round(Number(item.snapshot_coverage) * 100);
+ const [copy, cls] = verdictCopy[item.verdict] || ["관찰 중", "warn"];
+ const issues = (item.issues || []).slice(0, 3);
+ return `
+
${escapeHtml(name)}${basket?.is_primary ? " · 주력" : ""}${escapeHtml(item.basket)}
+
+
+
${days} / ${minimum} 거래일${coverage == null ? "기록 누락 확인 중" : coverage >= 100 ? "기록 누락 없음" : `기록 누락 ${Math.max(0, 100 - coverage)}%`}
+
+
${copy}
+ ${issues.length ? `
${issues.map((i) => `- ${escapeHtml(i)}
`).join("")}
` : ""}
+
`;
+ }).join("");
+ renderChartMaturity();
renderDecision();
- return;
- }
-
- const basketByName = new Map((state.baskets || []).map((basket) => [basket.basket, basket]));
- const ordered = [...items].sort((left, right) => (
- Number(Boolean(basketByName.get(right.basket)?.is_primary))
- - Number(Boolean(basketByName.get(left.basket)?.is_primary))
- ));
- elements.basketEval.innerHTML = `
${ordered.map((item) => {
- const basket = basketByName.get(item.basket);
- const displayName = basket?.display_name || item.basket;
- const isPrimary = Boolean(basket?.is_primary);
- const progressDays = Number(item.progress_days || 0);
- const minimumDays = Math.max(1, Number(item.min_trading_days || 60));
- const progress = Math.min(100, Math.round((progressDays / minimumDays) * 100));
- const coverage = item.snapshot_coverage == null ? null : Math.round(Number(item.snapshot_coverage) * 100);
- const [copy, tone] = verdictCopy[item.verdict] || ["관찰 중", "muted"];
- const issues = (item.issues || []).slice(0, 3);
- return `-
-
${escapeHtml(displayName)}${isPrimary ? '주력' : ""}${copy}
-
- ${progressDays}/${minimumDays} 영업일${coverage == null ? "기록 누락 확인 중" : (coverage >= 100 ? "기록 누락 없음" : `기록 누락 ${Math.max(0, 100 - coverage)}%`)}
- ${issues.length ? `${issues.map((issue) => `- ${escapeHtml(issue)}
`).join("")}
` : ""}
- `;
- }).join("")}
`;
- renderChartMaturity();
- renderDecision();
-}
-
-function trustCard(label, value, stateName = "info", support = "") {
- return `
${escapeHtml(label)}${value}${support ? `${escapeHtml(support)}` : ""}
`;
-}
-
-function marketCopy(regime) {
- const value = String(regime || "").toLowerCase();
- if (value === "bullish") return ["안정", "ok", "신규 매수 허용 구간"];
- if (value === "bearish") return ["방어", "warning", "신규 매수 제한 가능"];
- if (value === "caution") return ["주의", "warning", "포지션 규모 축소 구간"];
- return ["확인 불가", "warning", "시장 상태 데이터 없음"];
-}
-
-function strategyCopy(strategy) {
- const value = String(strategy || "").toLowerCase();
- if (value === "scoring") return "종합 점수형";
- if (value === "basket_rebalance") return "포트폴리오 리밸런싱";
- return strategy || "—";
-}
-
-function signalCopy(signal) {
- const value = String(signal || "").toUpperCase();
- return ({ BUY: "매수", SELL: "매도", HOLD: "대기" })[value] || signal || "—";
-}
-
-function signalSourceCopy(source) {
- const value = String(source || "").toLowerCase();
- return ({ pre_market: "장 시작 전", intraday: "장중", post_market: "장 마감 후" })[value] || source || "—";
-}
-
-function formatAgeMinutes(minutes) {
- if (minutes == null) return "기록 없음";
- if (minutes < 60) return `${minutes}분 전`;
- if (minutes < 1_440) return `${Math.round(minutes / 60)}시간 전`;
- return `${Math.round(minutes / 1_440)}일 전`;
-}
-
-function renderRuntime(runtime) {
- state.runtime = runtime || null;
- state.runtimeStatus = runtime && runtime.trading_halt ? "ready" : "error";
- updateSyncIndicator();
- elements.runtimeOps.setAttribute("aria-busy", "false");
- if (!runtime) {
- elements.haltGuidance.hidden = true;
- elements.runtimeOps.innerHTML = [
- trustCard("거래 안전 상태", "확인 불가", "warning", "거래 중지 상태를 읽지 못함"),
- trustCard("시장 환경", "확인 불가", "warning", "최근 데이터 없음"),
- trustCard("자동 운용", "확인 불가", "warning", "스케줄러 상태 없음"),
- trustCard("증권사 연결", "확인 불가", "warning", "요청 통계 없음"),
- trustCard("데이터 기준", "확인 불가", "warning", "잠시 후 다시 확인"),
- ].join("");
- elements.runtimeMeta.textContent = "일부 운영 정보를 불러오지 못했습니다. 자산 장부는 변경되지 않았습니다.";
- renderSignals(null, null);
- renderWsGap(null);
+ }
+
+ /* ---------- 운영 ---------- */
+
+ function statusRowItem(label, value, detail, stateName) {
+ return `
${escapeHtml(label)}${escapeHtml(value)}${detail ? `${escapeHtml(detail)}` : ""}`;
+ }
+ function marketCopy(regime) {
+ const v = String(regime || "").toLowerCase();
+ if (v === "bullish") return ["안정", "신규 매수 가능", "ok"];
+ if (v === "bearish") return ["방어", "신규 매수 제한", "warning"];
+ if (v === "caution") return ["주의", "포지션 축소 구간", "warning"];
+ return ["확인 불가", "시장 상태 데이터 없음", "warning"];
+ }
+ const strategyCopy = (s) => ({ scoring: "종합 점수형", basket_rebalance: "바스켓 리밸런싱" })[String(s || "").toLowerCase()] || s || "—";
+ const signalCopy = (s) => ({ BUY: "매수", SELL: "매도", HOLD: "보유" })[String(s || "").toUpperCase()] || s || "—";
+ const signalSourceCopy = (s) => ({ pre_market: "장 시작 전", intraday: "장중", post_market: "장 마감 후" })[String(s || "").toLowerCase()] || s || "—";
+
+ function renderRuntime(runtime) {
+ state.runtime = runtime || null;
+ state.runtimeStatus = runtime && runtime.trading_halt ? "ready" : "error";
+ updateSyncIndicator();
+ el.runtimeOps.setAttribute("aria-busy", "false");
+ if (!runtime) {
+ el.haltGuidance.hidden = true;
+ el.runtimeOps.innerHTML = [
+ statusRowItem("거래 안전", "확인 불가", "거래 중지 상태를 읽지 못했습니다", "warning"),
+ statusRowItem("장 상태", "확인 불가", "최근 데이터 없음", "warning"),
+ statusRowItem("자동매매", "확인 불가", "스케줄러 기록 없음", "warning"),
+ statusRowItem("증권사 연결", "확인 불가", "요청 통계 없음", "warning"),
+ statusRowItem("데이터 갱신", "확인 불가", "잠시 후 다시 확인", "warning"),
+ ].join("");
+ el.runtimeMeta.textContent = "운영 정보를 불러오지 못했습니다. 자산 장부는 바뀌지 않았습니다.";
+ el.statusRow.innerHTML = statusItem("거래", "확인 불가", "warning") + statusItem("장 상태", "확인 불가", "warning") + statusItem("자동매매", "확인 불가", "warning") + statusItem("데이터", "확인 불가", "warning");
+ renderSignals(null, null);
+ renderWsGap(null);
+ renderDecision();
+ return;
+ }
+ const halt = runtime.trading_halt;
+ const haltKnown = Boolean(halt && typeof halt.halted === "boolean");
+ const halted = Boolean(halt && halt.halted);
+ const [market, marketSupport, marketState] = marketCopy(runtime.market_regime && runtime.market_regime.regime);
+ const loop = runtime.loop_metrics;
+ const kis = runtime.kis_stats;
+ const updatedAt = runtime.runtime_file_updated_at;
+ const age = runtimeAgeMinutes();
+ const fresh = age != null && age <= 720;
+ const loopElapsed = loop && loop.recent_avg_elapsed_s != null ? `${Number(loop.recent_avg_elapsed_s).toFixed(1)}초` : null;
+ const haltValue = !haltKnown ? "확인 불가" : halted ? "거래 중지" : "운용 가능";
+ const haltDetail = !haltKnown ? "거래 중지 상태를 읽지 못했습니다" : halted ? (halt.reason || "운영자 확인 필요") : "거래 중지 없음";
+ const haltState = !haltKnown ? "warning" : halted ? "error" : "ok";
+ // 자동매매 신선도는 런타임 파일이 아니라 스케줄러 루프의 마지막 성공 시각으로 본다.
+ // 수동 리밸런싱 점검만으로도 런타임 파일은 갱신되므로, 그걸 기준으로 삼으면 멈춘 루프가 정상으로 보인다.
+ const loopLast = parseDate(loop && loop.last_success) || (loop ? parseDate(updatedAt) : null);
+ const loopAge = loopLast ? Math.max(0, Math.round((Date.now() - loopLast.getTime()) / 60_000)) : null;
+ const loopFresh = loopAge != null && loopAge <= 720;
+ const autoValue = loopElapsed ? (loopFresh ? "정상" : `${formatAge(loopAge)} 실행`) : "기록 없음";
+ const autoDetail = loopElapsed ? `마지막 실행 ${fmtDT(loopLast)} · 루프 ${loopElapsed}` : "스케줄러 기록 없음";
+ const autoState = loop && loopFresh ? "ok" : "warning";
+ const kisValue = kis && kis.minute_utilization_pct != null ? `${Number(kis.minute_utilization_pct).toFixed(1)}% 사용` : null;
+ const kisRow = state.mode === "paper"
+ ? statusRowItem("증권사 연결", "모의투자", "실계좌 연결 대상 아님", "info")
+ : statusRowItem("증권사 연결", kisValue ? (fresh ? kisValue : "기록 오래됨") : "기록 없음", kisValue ? `분당 요청 한도 기준${fresh ? "" : ` · ${formatAge(age)}`}` : "요청 통계 없음", kis && fresh ? "ok" : "warning");
+ const dataState = age == null ? "warning" : age > 30 ? "warning" : "ok";
+
+ el.runtimeOps.innerHTML =
+ statusRowItem("거래 안전", haltValue, haltDetail, haltState)
+ + statusRowItem("장 상태", market, marketSupport, marketState)
+ + statusRowItem("자동매매", autoValue, autoDetail, autoState)
+ + kisRow
+ + statusRowItem("데이터 갱신", formatAge(age), updatedAt ? fmtDT(updatedAt) : "스케줄러 데이터 없음", dataState);
+ el.statusRow.innerHTML =
+ statusItem("거래", haltValue, haltState)
+ + statusItem("장 상태", market, marketState)
+ + statusItem("자동매매", autoValue, autoState)
+ + statusItem("데이터", formatAge(age), dataState);
+
+ const meta = [];
+ if (runtime.strategy) meta.push(`전략 ${strategyCopy(runtime.strategy)}`);
+ if (updatedAt) meta.push(`스케줄러 기록 ${fmtDT(updatedAt)}`);
+ el.runtimeMeta.textContent = meta.join(" · ");
+ el.haltGuidance.hidden = !halted;
+ if (halted) $("haltGuidanceReason").textContent = halt.reason || "신규 매수는 직접 해제하기 전까지 막혀 있습니다.";
+ renderSignals(runtime.signals_today, runtime.signals_date);
+ renderWsGap(runtime);
renderDecision();
- return;
- }
-
- const halt = runtime.trading_halt;
- const haltKnown = Boolean(halt && typeof halt.halted === "boolean");
- const halted = Boolean(halt && halt.halted);
- const [market, marketState, marketSupport] = marketCopy(runtime.market_regime && runtime.market_regime.regime);
- const loop = runtime.loop_metrics;
- const kis = runtime.kis_stats;
- const updatedAt = runtime.runtime_file_updated_at;
- const ageMinutes = updatedAt ? Math.max(0, Math.round((Date.now() - (parseDate(updatedAt)?.getTime() || Date.now())) / 60_000)) : null;
- const freshnessState = ageMinutes == null ? "warning" : (ageMinutes > 30 ? "warning" : "ok");
- const runtimeIsFresh = ageMinutes != null && ageMinutes <= 720;
- const loopElapsed = loop && loop.recent_avg_elapsed_s != null
- ? `${Number(loop.recent_avg_elapsed_s).toFixed(1)}초`
- : null;
- const loopValue = loopElapsed
- ? (runtimeIsFresh ? loopElapsed : "기록 오래됨")
- : "기록 없음";
- const loopSupport = loopElapsed
- ? (runtimeIsFresh ? "최근 루프 평균" : `최근 루프 ${loopElapsed} · ${formatAgeMinutes(ageMinutes)}`)
- : "스케줄러 상태 없음";
- const kisValue = kis && kis.minute_utilization_pct != null
- ? `${Number(kis.minute_utilization_pct).toFixed(1)}%`
- : null;
- const kisCard = state.mode === "paper"
- ? trustCard("증권사 연결", "모의 운용", "info", "실계좌 연결 대상 아님")
- : trustCard("증권사 연결", kisValue ? (runtimeIsFresh ? kisValue : "기록 오래됨") : "기록 없음", kis && runtimeIsFresh ? "info" : "warning", kisValue ? (runtimeIsFresh ? "분당 요청 한도 사용률" : `최근 사용률 ${kisValue} · ${formatAgeMinutes(ageMinutes)}`) : "요청 통계 없음");
-
- elements.runtimeOps.innerHTML =
- trustCard("거래 안전 상태", !haltKnown ? "확인 불가" : (halted ? "거래 중지" : "운용 가능"), !haltKnown ? "warning" : (halted ? "error" : "ok"), !haltKnown ? "거래 중지 상태를 읽지 못함" : (halted ? (halt.reason || "운영자 확인 필요") : "거래 중지 없음")) +
- trustCard("시장 환경", market, marketState, marketSupport) +
- trustCard("자동 운용", loopValue, loop && runtimeIsFresh ? "ok" : "warning", loopSupport) +
- kisCard +
- trustCard("데이터 기준", formatAgeMinutes(ageMinutes), freshnessState, updatedAt ? formatDate(updatedAt, dateTime) : "스케줄러 데이터 없음");
-
- const meta = [];
- if (runtime.strategy) meta.push(`운용 전략 ${strategyCopy(runtime.strategy)}`);
- if (updatedAt) meta.push(`스케줄러 마지막 기록 ${formatDate(updatedAt, dateTime)}`);
- elements.runtimeMeta.textContent = meta.join(" · ");
- elements.haltGuidance.hidden = !halted;
- if (halted) {
- $("haltGuidanceReason").textContent = halt.reason || "신규 매수는 명시적으로 해제하기 전까지 차단됩니다.";
- }
- renderSignals(runtime.signals_today, runtime.signals_date);
- renderWsGap(runtime);
- renderDecision();
-}
-
-function renderSignals(signals, signalsDate) {
- const table = $("signalsTableWrap");
- const empty = $("signalEmpty");
- const error = $("signalError");
- const count = $("signalCount");
- if (signals == null) {
- table.hidden = true;
- empty.hidden = true;
- error.hidden = false;
- count.textContent = "확인 불가";
- return;
- }
- const isToday = !signalsDate || signalsDate === localIsoDate();
- const rows = isToday && Array.isArray(signals) ? signals : [];
- count.textContent = `${won.format(rows.length)}건`;
- table.hidden = !rows.length;
- empty.hidden = Boolean(rows.length);
- error.hidden = true;
- empty.textContent = isToday
- ? "오늘 기록된 신호가 없습니다. 이상 상태가 아닙니다."
- : `오늘 기록된 신호가 없습니다. 마지막 신호 기록은 ${formatDate(signalsDate)}입니다.`;
- $("signalRows").innerHTML = rows.map((signal) => `
-
- |
- ${escapeHtml(signal.symbol || "—")} |
- ${escapeHtml(signalCopy(signal.signal))} |
- ${Number.isFinite(Number(signal.score)) ? Number(signal.score).toFixed(2) : "—"} |
- ${escapeHtml(signalSourceCopy(signal.source))} |
+ }
+
+ function renderSignals(signals, signalsDate) {
+ const table = $("signalsTableWrap");
+ const empty = $("signalEmpty");
+ const error = $("signalError");
+ const count = $("signalCount");
+ if (signals == null) {
+ table.hidden = true; empty.hidden = true; error.hidden = false; count.textContent = "확인 불가";
+ return;
+ }
+ const isToday = !signalsDate || signalsDate === localIsoDate();
+ const rows = isToday && Array.isArray(signals) ? signals : [];
+ count.textContent = `${rows.length}건`;
+ table.hidden = !rows.length;
+ empty.hidden = Boolean(rows.length);
+ error.hidden = true;
+ empty.textContent = isToday ? "오늘 생성된 신호가 없습니다. 이상 상태는 아닙니다." : `오늘 생성된 신호가 없습니다. 마지막 신호는 ${fmtLong(signalsDate)}입니다.`;
+ $("signalRows").innerHTML = rows.map((s) => `
+ |
+ ${escapeHtml(s.symbol || "—")} |
+ ${escapeHtml(signalCopy(s.signal))} |
+ ${Number.isFinite(Number(s.score)) ? Number(s.score).toFixed(2) : "—"} |
+ ${escapeHtml(signalSourceCopy(s.source))} |
`).join("");
-}
-
-function renderWsGap(runtime) {
- const gap = runtime && runtime.ws_gap;
- const summary = $("wsGapSummary");
- const table = $("wsGapTableWrap");
- const empty = $("wsGapEmpty");
- const unavailable = $("wsGapNA");
- if (!gap || !gap.available) {
- summary.innerHTML = trustCard("웹소켓", "정보 없음", "warning", "스케줄러 기록 대기");
- table.hidden = true;
- empty.hidden = true;
- unavailable.hidden = false;
- return;
- }
-
- const gaps = gap.recent_gaps || [];
- unavailable.hidden = true;
- summary.innerHTML =
- trustCard("웹소켓 상태", gap.is_connected ? "연결됨" : "연결 끊김", gap.is_connected ? "ok" : "error") +
- trustCard("최근 공백", `${won.format(Number(gap.total_gap_count || 0))}건`, gap.total_gap_count > 0 ? "warning" : "ok");
- table.hidden = !gaps.length;
- empty.hidden = Boolean(gaps.length);
- $("wsGapRows").innerHTML = [...gaps].reverse().map((item) => `
-
- | ${escapeHtml(formatDate(item.disconnect_at, dateTime))} |
- ${escapeHtml(formatDate(item.reconnect_at, dateTime))} |
- ${escapeHtml(`${Number(item.gap_seconds || 0).toFixed(1)}초`)} |
- ${escapeHtml((item.affected_symbols || []).join(", ") || "—")} |
- ${item.rest_backfill_performed ? `${won.format(Number(item.rest_backfill_count || 0))}건` : "미수행"} |
- ${item.blackswan_cooldown_triggered ? '안전 정지' : (item.blackswan_checked ? "정상" : "—")} |
+ }
+
+ function renderWsGap(runtime) {
+ const gap = runtime && runtime.ws_gap;
+ const summary = $("wsGapSummary");
+ const table = $("wsGapTableWrap");
+ const empty = $("wsGapEmpty");
+ const na = $("wsGapNA");
+ if (!gap || !gap.available) {
+ summary.innerHTML = statusRowItem("웹소켓", "정보 없음", "스케줄러 기록 대기", "warning");
+ table.hidden = true; empty.hidden = true; na.hidden = false;
+ return;
+ }
+ const gaps = gap.recent_gaps || [];
+ na.hidden = true;
+ summary.innerHTML = statusRowItem("웹소켓", gap.is_connected ? "연결됨" : "연결 끊김", "", gap.is_connected ? "ok" : "warning")
+ + statusRowItem("최근 끊김", `${nf0.format(Number(gap.total_gap_count || 0))}건`, "", gap.total_gap_count > 0 ? "warning" : "ok");
+ table.hidden = !gaps.length;
+ empty.hidden = Boolean(gaps.length);
+ $("wsGapRows").innerHTML = [...gaps].reverse().map((g) => `
+ | ${escapeHtml(fmtDT(g.disconnect_at))} |
+ ${escapeHtml(fmtDT(g.reconnect_at))} |
+ ${escapeHtml(`${Number(g.gap_seconds || 0).toFixed(1)}초`)} |
+ ${escapeHtml((g.affected_symbols || []).join(", ") || "—")} |
+ ${g.rest_backfill_performed ? `${nf0.format(Number(g.rest_backfill_count || 0))}건` : "안 함"} |
+ ${g.blackswan_cooldown_triggered ? '안전 정지' : g.blackswan_checked ? "정상" : "—"} |
`).join("");
-}
-
-function renderLegacy(portfolio) {
- state.legacy = portfolio || null;
- if (!portfolio) {
- $("summary").innerHTML = trustCard("레거시 계정", "확인 불가", "warning");
- $("positionsWrap").hidden = true;
- $("noPositions").hidden = false;
- return;
- }
- $("summary").innerHTML =
- trustCard("총 평가금", formatWon(portfolio.total_value), "info") +
- trustCard("총 수익률", formatPercent(portfolio.total_return), Number(portfolio.total_return) >= 0 ? "ok" : "error") +
- trustCard("현금", formatWon(portfolio.cash), "info") +
- trustCard("실현 손익", formatWon(portfolio.realized_pnl), Number(portfolio.realized_pnl) >= 0 ? "ok" : "error") +
- trustCard("최대 낙폭", formatPercent(-Math.abs(Number(portfolio.mdd || 0)), { sign: false }), "warning") +
- trustCard("보유 종목", `${won.format(Number(portfolio.position_count || 0))}개`, "info");
-
- const positions = portfolio.positions || [];
- $("positionsWrap").hidden = !positions.length;
- $("noPositions").hidden = Boolean(positions.length);
- $("positions").innerHTML = positions.map((position) => `
-
- | ${escapeHtml(position.symbol || "—")} |
- ${won.format(Number(position.quantity || 0))} |
- ${escapeHtml(formatWon(position.avg_price))} |
- ${escapeHtml(formatWon(position.current_price))} |
- ${escapeHtml(formatWon(position.current_value))} |
- ${escapeHtml(formatPercent(position.pnl_rate))} |
+ }
+
+ function renderLegacy(portfolio) {
+ state.legacy = portfolio || null;
+ if (!portfolio) {
+ $("summary").innerHTML = statusRowItem("레거시 계정", "확인 불가", "", "warning");
+ $("positionsWrap").hidden = true;
+ $("noPositions").hidden = false;
+ return;
+ }
+ $("summary").innerHTML =
+ statusRowItem("총자산", won(portfolio.total_value), "", "info")
+ + statusRowItem("수익률", pct(portfolio.total_return), "", "info")
+ + statusRowItem("현금", won(portfolio.cash), "", "info")
+ + statusRowItem("실현손익", signedWon(portfolio.realized_pnl), "", "info")
+ + statusRowItem("최대낙폭", pct(-Math.abs(Number(portfolio.mdd || 0))), "", "info")
+ + statusRowItem("보유 종목", `${nf0.format(Number(portfolio.position_count || 0))}개`, "", "info");
+ const positions = portfolio.positions || [];
+ $("positionsWrap").hidden = !positions.length;
+ $("noPositions").hidden = Boolean(positions.length);
+ $("positions").innerHTML = positions.map((p) => `
+ | ${escapeHtml(p.symbol || "—")} |
+ ${nf0.format(Number(p.quantity || 0))} |
+ ${won(p.avg_price)} |
+ ${won(p.current_price)} |
+ ${won(p.current_value)} |
+ ${pct(p.pnl_rate)} |
`).join("");
-}
-
-function latestSnapshotDate() {
- const basket = primaryBasket() || sortedBaskets().find((item) => item.snapshot);
- return basket && basket.snapshot ? basket.snapshot.date : null;
-}
-
-function currentMonthContributionState(basket) {
- if (!basket || !basket.contribution_plan?.enabled) return "not-planned";
- if (state.flowStatus.get(basket.basket) !== "ready") return "unknown";
- const now = new Date();
- const recorded = (state.flows.get(basket.basket) || []).some((flow) => {
- const when = parseDate(flow.occurred_at);
- return when && when.getFullYear() === now.getFullYear() && when.getMonth() === now.getMonth();
- });
- return recorded ? "recorded" : "empty";
-}
-
-function runtimeAgeMinutes() {
- const updatedAt = state.runtime && state.runtime.runtime_file_updated_at;
- const parsed = parseDate(updatedAt);
- return parsed ? Math.max(0, Math.round((Date.now() - parsed.getTime()) / 60_000)) : null;
-}
-
-function setDecision({ title, description, meta = "", action = null, actionLabel = "확인하기" }) {
- elements.decisionTitle.textContent = title;
- elements.decisionDescription.textContent = description;
- elements.decisionMeta.innerHTML = meta;
- elements.decisionAction.hidden = !action;
- elements.decisionAction.dataset.action = action || "";
- elements.decisionAction.textContent = actionLabel;
-}
-
-function renderDecision() {
- const baskets = state.baskets;
- const halt = state.runtime && state.runtime.trading_halt;
- const latest = latestSnapshotDate();
- const ageDays = calendarAgeDays(latest);
- const issues = (state.evaluations || []).flatMap((item) => item.issues || []);
- const primary = primaryBasket();
- const contributionState = currentMonthContributionState(primary);
- const modeCopy = state.mode === "live" ? "실전 운용" : (state.mode === "paper" ? "모의 운용" : "모드 확인 불가");
- const meta = `
${escapeHtml(modeCopy)}${latest ? ` · 최근 자산 기록 ${escapeHtml(formatDate(latest))}` : " · 자산 기록 없음"}`;
-
- if (halt && halt.halted) {
- setDecision({
- title: "거래가 안전하게 중지되어 있습니다",
- description: halt.reason || "체결 또는 장부 상태를 확인하기 전까지 신규 주문을 막고 있습니다.",
- meta,
- action: "operations",
- actionLabel: "운용 상태 보기",
- });
- return;
- }
- if (state.coreError) {
- setDecision({
- title: "자산 데이터를 확인할 수 없습니다",
- description: "이전 화면을 최신 데이터로 표시하지 않았습니다. 연결을 확인한 뒤 다시 시도하세요.",
- meta: state.lastCoreSuccess ? `마지막 성공 ${escapeHtml(dateTime.format(state.lastCoreSuccess))}` : "아직 성공한 갱신이 없습니다.",
- action: "retry",
- actionLabel: "지금 다시 확인",
- });
- return;
}
- if (state.runtimeStatus === "loading") {
- setDecision({
- title: "거래 안전 상태를 확인하고 있습니다",
- description: "거래 중지 여부와 자동 운용 기록을 확인한 뒤 오늘의 판단을 표시합니다.",
- meta,
- });
- return;
- }
- if (state.runtimeStatus === "error") {
- setDecision({
- title: "거래 안전 상태를 확인할 수 없습니다",
- description: "거래 중지 여부가 확인되기 전에는 적립 기록이나 운용 판단을 진행하지 마세요.",
- meta,
- action: "retry",
- actionLabel: "안전 상태 다시 확인",
- });
- return;
- }
- if (baskets && !baskets.length) {
- setDecision({
- title: "첫 모의 운용 포트폴리오를 연결하세요",
- description: "안전 기본값을 유지한 채 포트폴리오를 활성화하고 첫 모의 운용 기록을 만들어야 합니다.",
- meta: "설정 → 모의 운용 1회 → 첫 자산 기록 확인",
- action: "portfolio",
- actionLabel: "시작 순서 보기",
- });
- return;
- }
- if (baskets && baskets.length && !latest) {
- setDecision({
- title: "첫 모의 운용 기록을 기다리고 있습니다",
- description: "모의 운용을 한 번 실행하면 원금, 자산, 운용 수익률을 분리해 볼 수 있습니다.",
- meta,
- action: "portfolio",
- actionLabel: "포트폴리오 확인",
- });
- return;
- }
- if (ageDays != null && ageDays > 4) {
- setDecision({
- title: "자산 기록이 오래되었습니다",
- description: `마지막 자산 기록이 ${ageDays}일 전입니다. 자동 운용이 중단됐을 수 있으니 상태를 확인하세요.`,
- meta,
- action: "operations",
- actionLabel: "운용 상태 확인",
- });
- return;
- }
- const runtimeAge = runtimeAgeMinutes();
- if (runtimeAge == null || runtimeAge > 720) {
- setDecision({
- title: "자동 운용 기록이 오래되었습니다",
- description: runtimeAge == null
- ? "최근 스케줄러 기록이 없습니다. 오늘 사이클이 실행됐는지 먼저 확인하세요."
- : `마지막 자동 운용 기록이 ${formatAgeMinutes(runtimeAge)}입니다. 적립보다 실행 상태를 먼저 확인하세요.`,
- meta,
- action: "operations",
- actionLabel: "운용 기록 확인",
+
+ /* ---------- 적립 기록 ---------- */
+
+ function updateDepositCopy() {
+ const d = $("depositDescription");
+ if (!d) return;
+ d.textContent = state.mode === "live"
+ ? "실제 계좌에 입금이 끝난 뒤 같은 금액을 기록하세요. 주문은 나가지 않고 실전 성과 계산에만 반영됩니다."
+ : "입금은 수익이 아니므로 수익률 계산에서 분리됩니다. 실제 입금이 끝난 뒤 같은 금액을 기록하세요.";
+ }
+ function resetDepositForm() {
+ state.depositConfirming = false;
+ state.depositRequestId = null;
+ el.depositForm.reset();
+ el.depositFields.hidden = false;
+ el.depositConfirm.hidden = true;
+ el.depositBack.hidden = true;
+ el.depositSubmit.textContent = "내용 확인";
+ el.depositSubmit.disabled = false;
+ el.depositError.hidden = true;
+ el.depositError.textContent = "";
+ document.querySelectorAll("[data-amount]").forEach((b) => b.setAttribute("aria-pressed", "false"));
+ }
+ function openDeposit() {
+ if (!canRecordDeposit()) { showToast("장부와 거래 상태를 먼저 다시 확인하세요.", "error"); return; }
+ resetDepositForm();
+ const select = $("depBasket");
+ select.innerHTML = sortedBaskets().map((b) => `
`).join("");
+ const primary = primaryBasket();
+ if (primary) select.value = primary.basket;
+ updateDepositCopy();
+ if (!el.depositDialog.open) el.depositDialog.showModal();
+ window.setTimeout(() => select.focus(), 0);
+ }
+ function closeDeposit() { if (el.depositDialog.open) el.depositDialog.close(); }
+ function showDepositError(message, field = null) {
+ el.depositError.textContent = message;
+ el.depositError.hidden = false;
+ if (field) field.focus();
+ }
+ function depositValues() {
+ return { basket: $("depBasket").value, amount: Number($("depAmount").value), note: $("depNote").value.trim() };
+ }
+ function showDepositConfirmation(values) {
+ state.depositRequestId = window.crypto?.randomUUID ? window.crypto.randomUUID() : `deposit-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ const selected = $("depBasket").selectedOptions[0];
+ $("confirmBasket").textContent = selected ? selected.textContent : values.basket;
+ $("confirmAmount").textContent = won(values.amount);
+ $("confirmMode").textContent = state.mode === "live" ? "실전 장부" : "모의투자 장부";
+ el.depositFields.hidden = true;
+ el.depositConfirm.hidden = false;
+ el.depositBack.hidden = false;
+ el.depositSubmit.textContent = "기록하기";
+ el.depositError.hidden = true;
+ state.depositConfirming = true;
+ el.depositBack.focus();
+ }
+ function showDepositFields() {
+ state.depositConfirming = false;
+ state.depositRequestId = null;
+ el.depositFields.hidden = false;
+ el.depositConfirm.hidden = true;
+ el.depositBack.hidden = true;
+ el.depositSubmit.textContent = "내용 확인";
+ $("depBasket").focus();
+ }
+ async function submitDeposit(values) {
+ el.depositSubmit.disabled = true;
+ el.depositSubmit.textContent = "기록하는 중";
+ el.depositError.hidden = true;
+ try {
+ const data = await fetchJson("/api/deposit", {
+ timeout: 15_000, key: "deposit",
+ options: { method: "POST", headers: { "Content-Type": "application/json", "X-Requested-With": "quant-dashboard", "Idempotency-Key": state.depositRequestId }, body: JSON.stringify(values) },
+ });
+ if (!data || !data.ok) throw new Error((data && data.error) || "기록에 실패했습니다.");
+ closeDeposit();
+ showToast(`${state.mode === "live" ? "실전 입금" : "모의투자 적립"} ${won(data.amount)}을 기록했습니다.`);
+ await refreshCore();
+ } catch (error) {
+ showDepositError(`기록하지 못했습니다. ${error.message || "연결을 확인한 뒤 다시 시도하세요."}`);
+ el.depositSubmit.disabled = false;
+ el.depositSubmit.textContent = "기록하기";
+ }
+ }
+
+ let toastTimer = null;
+ function showToast(message, kind = "ok") {
+ const toast = $("toast");
+ window.clearTimeout(toastTimer);
+ toast.textContent = message;
+ toast.dataset.kind = kind;
+ toast.classList.add("show");
+ toastTimer = window.setTimeout(() => toast.classList.remove("show"), 5_000);
+ }
+
+ /* ---------- 갱신 주기 ---------- */
+
+ // 주기 갱신은 숨은 탭에서 쉬지만, 첫 로드는 탭이 뒤에 있어도 반드시 한 번 채운다
+ // (백그라운드로 연 탭이 빈 화면으로 남던 문제).
+ async function refreshCore(force = false) {
+ if (!force && document.visibilityState === "hidden") return;
+ state.coreStatus = "loading";
+ updateSyncIndicator();
+ const basketTask = fetchJson("/api/baskets", { timeout: 12_000, key: "baskets" })
+ .then(async (data) => {
+ renderBasketTracks(data);
+ if (!boot.finished) bootStep();
+ await Promise.allSettled([refreshFlows(), refreshSeries()]);
+ if (!boot.finished) { bootStep(); bootStep(); }
+ renderBasketTracks({ baskets: state.baskets, mode: state.mode, timestamp: state.lastCoreSuccess });
+ await refreshChart();
+ renderDecision();
+ return data;
+ });
+ const legacyTask = fetchJson("/api/portfolio", { timeout: 15_000, key: "legacy" }).then(renderLegacy);
+ const results = await Promise.allSettled([basketTask, legacyTask]);
+ if (results[0].status === "rejected") {
+ state.coreError = results[0].reason || new Error("바스켓 조회 실패");
+ state.coreStatus = "error";
+ el.basketTracks.setAttribute("aria-busy", "false");
+ el.basketTracks.innerHTML = '
포트폴리오를 불러오지 못했습니다.이전 화면의 숫자는 최신이 아닐 수 있습니다.
';
+ finishBoot();
+ } else if (results.some((r) => r.status === "rejected") || state.flowError) {
+ state.coreStatus = "partial";
+ } else {
+ state.coreStatus = "ready";
+ }
+ updateSyncIndicator();
+ renderDecision();
+ }
+
+ async function refreshSlow(force = false) {
+ if (!force && document.visibilityState === "hidden") return;
+ if (!state.runtime) { state.runtimeStatus = "loading"; updateSyncIndicator(); }
+ const evalTask = fetchJson("/api/basket_evaluation", { timeout: 30_000, key: "evaluation" })
+ .then((data) => renderEvaluations((data && data.evaluations) || []))
+ .catch(() => {
+ el.basketEval.setAttribute("aria-busy", "false");
+ el.basketEval.innerHTML = '
검증 상태를 불러오지 못했습니다. 잠시 후 다시 확인하세요.
';
+ });
+ const runtimeTask = fetchJson("/api/runtime", { timeout: 30_000, key: "runtime" }).then(renderRuntime).catch(() => renderRuntime(null));
+ await Promise.allSettled([evalTask, runtimeTask]);
+ }
+
+ async function refreshAll(force = false) {
+ await Promise.allSettled([refreshCore(force), refreshSlow(force)]);
+ }
+
+ /* ---------- 이벤트 ---------- */
+
+ function initScrollSpy() {
+ if (!("IntersectionObserver" in window)) return;
+ const links = [...document.querySelectorAll(".nav > a")];
+ const targets = links.map((a) => document.querySelector(a.getAttribute("href"))).filter(Boolean);
+ const active = new Set();
+ const spy = new IntersectionObserver((entries) => {
+ entries.forEach((entry) => { if (entry.isIntersecting) active.add(entry.target.id); else active.delete(entry.target.id); });
+ const current = targets.map((t) => t.id).filter((id) => active.has(id)).at(-1) || null;
+ links.forEach((a) => {
+ if (current && a.getAttribute("href") === `#${current}`) a.setAttribute("aria-current", "true");
+ else a.removeAttribute("aria-current");
+ });
+ }, { rootMargin: "-30% 0px -55% 0px", threshold: 0 });
+ targets.forEach((t) => spy.observe(t));
+ }
+
+ function wireEvents() {
+ el.openDeposit.addEventListener("click", openDeposit);
+ $("closeDepositButton").addEventListener("click", closeDeposit);
+ $("depositCancelButton").addEventListener("click", closeDeposit);
+ el.depositBack.addEventListener("click", showDepositFields);
+ $("retryButton").addEventListener("click", () => refreshAll(true));
+
+ el.decisionAction.addEventListener("click", () => {
+ const action = el.decisionAction.dataset.action;
+ if (action === "deposit") openDeposit();
+ else if (action === "retry") refreshAll();
+ else if (action) document.getElementById(action)?.scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "start" });
});
- return;
- }
- if (issues.length) {
- setDecision({
- title: "확인할 운영 항목이 있습니다",
- description: `${issues.length}개 항목을 검토해야 합니다. 실전 전환은 계속 잠긴 상태입니다.`,
- meta,
- action: "review",
- actionLabel: "검토 항목 보기",
+
+ el.chartAccount.addEventListener("change", () => { state.chartAccount = el.chartAccount.value; refreshChart(); });
+ $("chartRange").addEventListener("click", (event) => {
+ const button = event.target.closest("button[data-days]");
+ if (!button) return;
+ state.chartDays = Number(button.dataset.days);
+ document.querySelectorAll("#chartRange button").forEach((b) => b.setAttribute("aria-pressed", String(b === button)));
+ refreshChart();
});
- return;
- }
- if (contributionState === "unknown") {
- setDecision({
- title: "적립 기록을 확인할 수 없습니다",
- description: "조회 상태가 확인되기 전에는 같은 적립금을 다시 기록하지 마세요.",
- meta,
- action: "retry",
- actionLabel: "적립 기록 다시 확인",
+ el.chartEquity.addEventListener("pointermove", handleChartPointer);
+ el.chartEquity.addEventListener("pointerdown", handleChartPointer);
+ el.chartEquity.addEventListener("pointerleave", hideChartTip);
+
+ document.querySelectorAll("[data-amount]").forEach((button) => {
+ button.addEventListener("click", () => {
+ $("depAmount").value = button.dataset.amount;
+ document.querySelectorAll("[data-amount]").forEach((b) => b.setAttribute("aria-pressed", String(b === button)));
+ $("depAmount").focus();
+ });
});
- return;
- }
- if (contributionState === "empty") {
- const plannedAmount = Number(primary.contribution_plan?.amount || 0);
- setDecision({
- title: "이번 달 실제 입금 여부를 확인하세요",
- description: `${plannedAmount > 0 ? `운용 기준은 월 ${formatWon(plannedAmount)}입니다. ` : ""}실제 입금 또는 모의 적립이 완료된 경우에만 장부에 기록하세요.`,
- meta,
- action: "deposit",
- actionLabel: "적립금 기록",
+
+ el.depositForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ if (!canRecordDeposit()) { showDepositError("장부와 거래 상태를 최신으로 확인한 뒤 다시 시도하세요."); updateDepositAvailability(); return; }
+ const values = depositValues();
+ if (!state.depositConfirming) {
+ if (!values.basket) { showDepositError("계좌를 선택하세요.", $("depBasket")); return; }
+ if (!Number.isFinite(values.amount) || values.amount <= 0) { showDepositError("0원보다 큰 금액을 입력하세요.", $("depAmount")); return; }
+ showDepositConfirmation(values);
+ return;
+ }
+ await submitDeposit(values);
});
- return;
- }
- setDecision({
- title: "오늘은 할 일이 없습니다",
- description: "계획을 유지하며 기록을 더 쌓는 중입니다. 매일 시세를 확인하거나 전략을 바꿀 필요가 없습니다.",
- meta,
- });
-}
-
-function updateDepositCopy() {
- const description = $("depositDescription");
- if (!description) return;
- description.textContent = state.mode === "live"
- ? "실제 계좌에 입금이 완료된 뒤 같은 금액을 장부에 기록하세요. 주문은 실행되지 않지만 실전 성과 계산에 반영됩니다."
- : "모의 적립금은 수익이 아니므로 입출금 제외 수익률 계산에서 분리됩니다. 기록 전 포트폴리오와 금액을 다시 살펴보세요.";
-}
-
-function resetDepositForm() {
- state.depositConfirming = false;
- state.depositRequestId = null;
- elements.depositForm.reset();
- elements.depositFields.hidden = false;
- elements.depositConfirm.hidden = true;
- elements.depositBack.hidden = true;
- elements.depositSubmit.textContent = "내용 확인";
- elements.depositSubmit.disabled = false;
- elements.depositError.hidden = true;
- elements.depositError.textContent = "";
- document.querySelectorAll("[data-amount]").forEach((button) => button.setAttribute("aria-pressed", "false"));
-}
-
-function openDeposit() {
- if (!canRecordDeposit()) {
- showToast("장부와 거래 안전 상태를 먼저 다시 확인하세요.", "error");
- return;
- }
- resetDepositForm();
- const select = $("depBasket");
- const baskets = sortedBaskets();
- select.innerHTML = baskets.map((basket) => `
`).join("");
- const primary = primaryBasket();
- if (primary) select.value = primary.basket;
- updateDepositCopy();
- if (!elements.depositDialog.open) elements.depositDialog.showModal();
- window.setTimeout(() => select.focus(), 0);
-}
-
-function closeDeposit() {
- if (elements.depositDialog.open) elements.depositDialog.close();
-}
-
-function showDepositError(message, field = null) {
- elements.depositError.textContent = message;
- elements.depositError.hidden = false;
- if (field) field.focus();
-}
-
-function depositValues() {
- const basket = $("depBasket").value;
- const amount = Number($("depAmount").value);
- const note = $("depNote").value.trim();
- return { basket, amount, note };
-}
-
-function showDepositConfirmation(values) {
- state.depositRequestId = window.crypto?.randomUUID
- ? window.crypto.randomUUID()
- : `deposit-${Date.now()}-${Math.random().toString(16).slice(2)}`;
- const selected = $("depBasket").selectedOptions[0];
- $("confirmBasket").textContent = selected ? selected.textContent : values.basket;
- $("confirmAmount").textContent = formatWon(values.amount);
- $("confirmMode").textContent = state.mode === "live" ? "실전 장부" : "모의 장부";
- elements.depositFields.hidden = true;
- elements.depositConfirm.hidden = false;
- elements.depositBack.hidden = false;
- elements.depositSubmit.textContent = state.mode === "live" ? "실전 입금 기록" : "모의 적립금 기록";
- elements.depositError.hidden = true;
- state.depositConfirming = true;
- elements.depositBack.focus();
-}
-
-function showDepositFields() {
- state.depositConfirming = false;
- state.depositRequestId = null;
- elements.depositFields.hidden = false;
- elements.depositConfirm.hidden = true;
- elements.depositBack.hidden = true;
- elements.depositSubmit.textContent = "내용 확인";
- $("depBasket").focus();
-}
-
-async function submitDeposit(values) {
- elements.depositSubmit.disabled = true;
- elements.depositSubmit.textContent = "기록하는 중…";
- elements.depositError.hidden = true;
- try {
- const data = await fetchJson("/api/deposit", {
- timeout: 15_000,
- key: "deposit",
- options: {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- "X-Requested-With": "quant-dashboard",
- "Idempotency-Key": state.depositRequestId,
- },
- body: JSON.stringify(values),
- },
+ el.depositDialog.addEventListener("close", resetDepositForm);
+ el.depositDialog.addEventListener("click", (event) => {
+ if (event.target !== el.depositDialog) return;
+ const b = el.depositDialog.getBoundingClientRect();
+ const inside = event.clientX >= b.left && event.clientX <= b.right && event.clientY >= b.top && event.clientY <= b.bottom;
+ if (!inside) closeDeposit();
});
- if (!data || !data.ok) throw new Error((data && data.error) || "기록에 실패했습니다.");
- closeDeposit();
- showToast(`${state.mode === "live" ? "실전 입금" : "모의 적립금"} ${formatWon(data.amount)}을 기록했습니다.`);
- await refreshCore();
- } catch (error) {
- showDepositError(`기록하지 못했습니다. ${error.message || "연결을 확인한 뒤 다시 시도하세요."}`);
- elements.depositSubmit.disabled = false;
- elements.depositSubmit.textContent = state.mode === "live" ? "실전 입금 기록" : "모의 적립금 기록";
- }
-}
-
-let toastTimer = null;
-function showToast(message, kind = "ok") {
- const toast = $("toast");
- window.clearTimeout(toastTimer);
- toast.textContent = message;
- toast.dataset.kind = kind;
- toast.classList.add("show");
- toastTimer = window.setTimeout(() => toast.classList.remove("show"), 5_000);
-}
-
-async function refreshCore() {
- if (document.visibilityState === "hidden") return;
- state.coreStatus = "loading";
- updateSyncIndicator();
- const basketTask = fetchJson("/api/baskets", { timeout: 12_000, key: "baskets" })
- .then(async (data) => {
- renderBasketTracks(data);
- await Promise.allSettled([refreshFlows(), refreshChart()]);
- return data;
+
+ window.addEventListener("online", () => refreshAll());
+ window.addEventListener("offline", () => {
+ state.coreError = new Error("오프라인");
+ state.coreStatus = "error";
+ updateSyncIndicator();
+ renderDecision();
});
- const legacyTask = fetchJson("/api/portfolio", { timeout: 15_000, key: "legacy" })
- .then(renderLegacy);
-
- const results = await Promise.allSettled([basketTask, legacyTask]);
- const basketResult = results[0];
- if (basketResult.status === "rejected") {
- state.coreError = basketResult.reason || new Error("바스켓 조회 실패");
- state.coreStatus = "error";
- elements.basketTracks.setAttribute("aria-busy", "false");
- elements.basketTracks.innerHTML = '
포트폴리오를 불러오지 못했습니다. 이전 데이터를 최신으로 표시하지 않았습니다.
';
- } else if (results.some((result) => result.status === "rejected") || state.flowError) {
- state.coreStatus = "partial";
- } else {
- state.coreStatus = "ready";
- }
- updateSyncIndicator();
- renderDecision();
-}
-
-async function refreshSlow() {
- if (document.visibilityState === "hidden") return;
- if (!state.runtime) {
- state.runtimeStatus = "loading";
- updateSyncIndicator();
+ document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") refreshAll(); });
}
- const evalTask = fetchJson("/api/basket_evaluation", { timeout: 30_000, key: "evaluation" })
- .then((data) => renderEvaluations((data && data.evaluations) || []))
- .catch(() => {
- elements.basketEval.setAttribute("aria-busy", "false");
- elements.basketEval.innerHTML = '
모의 운용 검증 상태를 불러오지 못했습니다. 잠시 후 다시 확인하세요.
';
- });
- const runtimeTask = fetchJson("/api/runtime", { timeout: 30_000, key: "runtime" })
- .then(renderRuntime)
- .catch(() => renderRuntime(null));
- await Promise.allSettled([evalTask, runtimeTask]);
-}
-
-async function refreshAll() {
- await Promise.allSettled([refreshCore(), refreshSlow()]);
-}
-
-function wireEvents() {
- elements.openDeposit.addEventListener("click", openDeposit);
- $("closeDepositButton").addEventListener("click", closeDeposit);
- $("depositCancelButton").addEventListener("click", closeDeposit);
- elements.depositBack.addEventListener("click", showDepositFields);
- $("retryButton").addEventListener("click", refreshAll);
-
- elements.decisionAction.addEventListener("click", () => {
- const action = elements.decisionAction.dataset.action;
- if (action === "deposit") openDeposit();
- else if (action === "retry") refreshAll();
- else if (action) document.getElementById(action)?.scrollIntoView({ behavior: "smooth", block: "start" });
- });
-
- elements.chartAccount.addEventListener("change", () => {
- state.chartAccount = elements.chartAccount.value;
- refreshChart();
- });
-
- $("chartRange").addEventListener("click", (event) => {
- const button = event.target.closest("button[data-days]");
- if (!button) return;
- state.chartDays = Number(button.dataset.days);
- document.querySelectorAll("#chartRange button").forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
- refreshChart();
- });
-
- document.querySelectorAll("[data-amount]").forEach((button) => {
- button.addEventListener("click", () => {
- $("depAmount").value = button.dataset.amount;
- document.querySelectorAll("[data-amount]").forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
- $("depAmount").focus();
- });
- });
- elements.depositForm.addEventListener("submit", async (event) => {
- event.preventDefault();
- if (!canRecordDeposit()) {
- showDepositError("장부와 거래 안전 상태를 최신으로 확인한 뒤 다시 시도하세요.");
- updateDepositAvailability();
- return;
+ async function main() {
+ wireEvents();
+ initStory();
+ initScrollSpy();
+ if ("ResizeObserver" in window) {
+ new ResizeObserver(() => drawChart()).observe(el.chartBox);
}
- const values = depositValues();
- if (!state.depositConfirming) {
- if (!values.basket) {
- showDepositError("포트폴리오를 선택하세요.", $("depBasket"));
- return;
- }
- if (!Number.isFinite(values.amount) || values.amount <= 0) {
- showDepositError("0원보다 큰 금액을 입력하세요.", $("depAmount"));
- return;
- }
- showDepositConfirmation(values);
- return;
- }
- await submitDeposit(values);
- });
-
- elements.depositDialog.addEventListener("close", resetDepositForm);
- elements.depositDialog.addEventListener("click", (event) => {
- if (event.target !== elements.depositDialog) return;
- const bounds = elements.depositDialog.getBoundingClientRect();
- const inside = event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom;
- if (!inside) closeDeposit();
- });
-
- window.addEventListener("online", refreshAll);
- window.addEventListener("offline", () => {
- state.coreError = new Error("오프라인");
- state.coreStatus = "error";
- updateSyncIndicator();
- renderDecision();
- });
-
- document.addEventListener("visibilitychange", () => {
- if (document.visibilityState === "visible") refreshAll();
- });
-}
-
-async function boot() {
- wireEvents();
- if ("ResizeObserver" in window) {
- new ResizeObserver(() => drawChart(state.chartRows)).observe(elements.chartWrap);
- }
- document.querySelectorAll("#chartRange button").forEach((button) => {
- button.setAttribute("aria-pressed", String(Number(button.dataset.days) === state.chartDays));
- });
- await refreshAll();
- window.setInterval(refreshCore, 30_000);
- window.setInterval(refreshSlow, 60_000);
-}
-
-boot();
+ document.querySelectorAll("#chartRange button").forEach((b) => b.setAttribute("aria-pressed", String(Number(b.dataset.days) === state.chartDays)));
+ window.setTimeout(finishBoot, 8000);
+ await refreshAll(true);
+ finishBoot();
+ window.setInterval(() => refreshCore(), 30_000);
+ window.setInterval(() => refreshSlow(), 60_000);
+ }
+
+ main();
+})();
diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html
index ba270063..2c635584 100644
--- a/monitoring/templates/dashboard.html
+++ b/monitoring/templates/dashboard.html
@@ -2,309 +2,279 @@
-
-
+
+
-
눈금 NUNGUM — 오래 투자하기 위한 기준과 기록
+
눈금 — 오래 투자하기 위한 기준과 기록
-
+
-
본문으로 바로가기
+
본문으로 건너뛰기
-