diff --git a/.github/workflows/safety-regression.yml b/.github/workflows/safety-regression.yml index 9f7c7fd7..e4764d60 100644 --- a/.github/workflows/safety-regression.yml +++ b/.github/workflows/safety-regression.yml @@ -20,10 +20,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@v7.0.0 with: python-version: "3.12" cache: pip @@ -38,6 +38,15 @@ jobs: run: | python -m py_compile main.py api/websocket_handler.py core/data_collector.py core/order_executor.py core/paper_preflight.py core/scheduler.py core/live_gate.py core/target_weight_rotation.py monitoring/liquidate_trigger.py tools/target_weight_rotation_pilot.py tools/paper_pilot_control.py + - name: Set up Node for dashboard tests + uses: actions/setup-node@v7.0.0 + with: + node-version: "22" + package-manager-cache: false + + - name: Check dashboard date calculations and refresh behavior + run: node --test tests/js/dashboard.test.cjs + - name: Run safety regression tests env: TMPDIR: ${{ runner.temp }} diff --git a/docs/DASHBOARD_REVIEW_20260922.md b/docs/DASHBOARD_REVIEW_20260922.md new file mode 100644 index 00000000..3d8d12aa --- /dev/null +++ b/docs/DASHBOARD_REVIEW_20260922.md @@ -0,0 +1,63 @@ +# 계좌 화면 오류·성능 점검 + +2026년 9월 22일, 날짜와 월별 수익률 표시, 새로고침, 많은 기록을 조회할 때의 속도를 점검했습니다. 기존 HTML·CSS·JavaScript와 Python 서버를 유지했습니다. + +## 날짜와 월별 수익률 + +브라우저 시간대를 미국으로 바꾸자 9월 22일 기록이 21일로 표시됐습니다. 월초 기록이 전월에 포함되면서 같은 계좌의 8월 수익률도 서울에서는 `+0.4%`, 미국에서는 `+1.4%`로 달라졌습니다. + +화면의 날짜, 조회 시작일, 월별 집계, 이번 달 적립 여부, 오늘 생성된 신호를 한국 시간으로 맞췄습니다. 장부에 시간대 없이 저장된 시각은 한국 시간으로 읽고, `Z`나 UTC 오프셋이 있는 시각은 해당 시각을 기준으로 변환합니다. 과거 기록을 나중에 복원했을 때 이후 입금이 과거 원금에 포함되지 않는지도 확인했습니다. + +서울·로스앤젤레스·런던 시간대에서 같은 계좌를 열어 날짜와 월별 수익률이 일치하는 것을 확인했습니다. + +## 새로고침과 조회 실패 + +- 새로고침 버튼과 자동 갱신이 겹치면 진행 중인 요청을 취소하던 동작을 고쳤습니다. 같은 조회가 끝날 때까지 결과를 함께 기다립니다. 실제 화면에서 버튼을 세 번 연달아 눌러도 계좌 요청은 한 번만 전송됐습니다. +- 적립금을 기록한 뒤에는 이전 조회가 끝난 다음 계좌를 다시 읽습니다. 적립 직전 금액으로 화면이 되돌아가지 않도록 했습니다. +- 차트 조회에 실패하면 이전 계좌의 표와 낙폭도 함께 숨깁니다. 내려받기와 날짜 선택 버튼도 비활성화합니다. + +## 기록이 많을 때 + +일별 표는 펼쳤을 때 만들고, 100개씩 넘겨 볼 수 있게 바꿨습니다. 표를 스크롤해도 열 제목은 위에 남습니다. 날짜 슬라이더와 CSV에는 조회 기간의 전체 기록을 유지합니다. + +아래는 합성 일별 기록 2,500개로 비교한 결과입니다. 창 크기 1280×900, Chromium CPU 4배 감속 조건에서 수정 전후를 번갈아 세 번 실행한 중앙값입니다. 수정 전 기준은 `3be97d0`입니다. + +| 항목 | 수정 전 | 수정 후 | +| --- | ---: | ---: | +| 표를 접은 첫 화면의 DOM 요소 | 15,665개 | 669개 | +| 접힌 표를 만드는 데 쓴 시간 | 112.2ms | 0.4ms | +| 화면에 준비하는 표 행 수 | 2,500개 | 펼친 페이지의 최대 100개 | +| 표 한 번 생성하는 시간 | 112.2ms | 7.3ms | +| 차트 데이터 반영 함수 실행 시간 | 235.4ms | 170.1ms | + +수정 전에는 표를 미리 만들었고, 수정 후에는 펼친 시점에 만듭니다. 위 시간은 해당 JavaScript 함수의 실행 시간이며, 전체 페이지 로딩 시간이나 실사용자의 INP를 뜻하지 않습니다. 합성 데이터와 감속 환경에서 측정한 값이라 기기·기록 수에 따라 달라집니다. 측정 중 긴 작업이 남아 있어 모든 상황에서 지연이 없다는 의미도 아닙니다. + +[측정값과 모바일 확인 결과](../reports/research/dashboard_review_20260922.json) + +## 서버 처리 + +계좌·성과·적립 기록을 읽는 동안 다른 요청이 기다리지 않도록 DB 조회를 별도 스레드에서 처리합니다. 운영 상태와 검증 자료는 서버 앱별로 캐시하고, 동시에 요청한 경우 한 번만 수집합니다. 동시 요청 6개를 보냈을 때 수집 횟수가 6회에서 1회로 줄었습니다. + +캐시는 수집을 마친 뒤 60초 동안 유지합니다. 모의투자·실전 모드가 바뀌면 다시 수집하고, 실패한 조회는 다음 요청에서 재시도합니다. 거래 중지 여부는 캐시와 별개로 매번 확인합니다. + +## 확인한 사용 흐름 + +- 320px·390px·1280px 화면에서 가로 넘침과 JavaScript 오류 없음 +- 모션 줄이기 설정, 입체·평면 전환, 날짜 슬라이더의 Home·End 키 조작 +- 2,500개 기록에서 이전·다음 페이지 이동, 화면에는 100행, CSV에는 2,500행 유지 +- 조회 실패 시 이전 표 숨김, 버튼 연속 클릭 시 중복 요청 방지 +- 표의 페이지 버튼 높이 40px, 스크롤 중 열 제목 고정 + +날짜·화면 로직은 `node --test tests/js/dashboard.test.cjs`, 서버와 매매 관련 회귀 테스트는 `python -m pytest tests -q`로 확인합니다. Node.js는 화면 테스트에만 사용하며 프로그램 실행에는 필요하지 않습니다. + +로컬 전체 테스트는 Python 2,091개와 JavaScript 19개가 통과했습니다. Python 테스트에서 나온 경고 2개는 기존 라이브러리의 지원 종료 예정 API에 관한 내용입니다. + +GitHub 자동 검사에서 지원이 끝난 Node 20 기반 액션 경고도 확인했습니다. 공식 릴리스를 확인해 checkout은 7.0.1, setup-python과 setup-node는 7.0.0으로 업데이트했습니다. 프로그램의 Python 버전이나 설치 방법은 바꾸지 않았습니다. + +## 참고 자료 + +- [MDN Date](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date): 기기 시간대에 따라 달라지는 날짜 필드와 UTC 필드의 차이 +- [Python asyncio.to_thread](https://docs.python.org/3/library/asyncio-task.html#asyncio.to_thread): 동기 I/O가 이벤트 루프를 막지 않도록 실행하는 방법 +- [aiohttp의 앱별 상태 관리](https://docs.aiohttp.org/en/stable/web_advanced.html#data-sharing-aka-no-singletons-please): 전역 상태 대신 앱 안에서 조회 결과 관리 +- [Web Vitals](https://web.dev/articles/vitals): 실험실 측정과 실제 사용자 지표의 구분 +- [checkout 7.0.1](https://github.com/actions/checkout/releases/tag/v7.0.1), [setup-python 7.0.0](https://github.com/actions/setup-python/releases/tag/v7.0.0), [setup-node 7.0.0](https://github.com/actions/setup-node/releases/tag/v7.0.0): 자동 검사 도구의 공식 릴리스 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2eae33b7..4e44e364 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -20,6 +20,12 @@ **입체**와 **평면**은 같은 수익률을 다른 모양으로 보여 줍니다. 입체 차트의 띠 너비가 별도의 투자 지표를 뜻하지는 않습니다. +날짜와 시각은 모두 한국 시간 기준입니다. 해외에서 접속해도 거래 날짜와 월별 수익률은 같습니다. + +**낙폭과 일별 기록 자세히 보기**를 열면 날짜별 금액이 표로 나옵니다. 기록이 많으면 100개씩 나눠 보여 줍니다. **이전 기록**, **다음 기록**으로 넘길 수 있고, CSV에는 조회 기간의 모든 기록이 담깁니다. + +![날짜별 평가금액과 수익률](images/history-table-20260922.png) + [계좌 화면 크게 보기](images/readme-account-20260922.png) ## 내역 저장 diff --git a/docs/images/history-table-20260922.png b/docs/images/history-table-20260922.png new file mode 100644 index 00000000..1997de3a Binary files /dev/null and b/docs/images/history-table-20260922.png differ diff --git a/monitoring/static/dashboard.css b/monitoring/static/dashboard.css index 09c1422b..43ab9e46 100644 --- a/monitoring/static/dashboard.css +++ b/monitoring/static/dashboard.css @@ -762,6 +762,39 @@ h1 { .history-details > summary { color: var(--ink-soft); } +.history-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 16px 0 8px; + font-size: 12px; + font-variant-numeric: tabular-nums; +} +.history-pagination button { + min-height: 40px; + padding: 0 12px; + border: 1px solid var(--rule-strong); + color: var(--ink); + background: transparent; + cursor: pointer; +} +.history-pagination button:disabled { + opacity: 0.4; + cursor: default; +} +.history-pagination button:not(:disabled):hover { + background: var(--surface); +} +#historyTableScroll { + max-height: min(560px, 65vh); + scrollbar-gutter: stable; +} +.history-details th { + position: sticky; + top: 0; + background: var(--surface); +} .chart-title { display: flex; justify-content: space-between; diff --git a/monitoring/static/dashboard.js b/monitoring/static/dashboard.js index f1121391..1fed31ae 100644 --- a/monitoring/static/dashboard.js +++ b/monitoring/static/dashboard.js @@ -46,34 +46,48 @@ } function parseDate(value) { if (!value) return null; - const text = String(value); + if (value instanceof Date) + return Number.isNaN(value.getTime()) ? null : new Date(value.getTime()); + const text = String(value).trim().replace(" ", "T"); + // 장부의 시간대 없는 시각은 한국 시간이다. Z·UTC 오프셋이 있으면 그대로 읽는다. const normalized = /^\d{4}-\d{2}-\d{2}$/.test(text) ? `${text}T00:00:00+09:00` - : /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(text) - ? `${text.replace(" ", "T")}+09:00` + : /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?$/.test(text) + ? `${text}+09:00` : text; const parsed = new Date(normalized); return Number.isNaN(parsed.getTime()) ? null : parsed; } const pad2 = (n) => String(n).padStart(2, "0"); + const DAY_MS = 86_400_000; + const KST_OFFSET_MS = 9 * 60 * 60 * 1000; + // 표시용 날짜만 +9시간 옮겨 UTC 필드로 읽는다. 시간 비교에는 원래 시각을 쓴다. + function koreaClock(value) { + const instant = parseDate(value); + return instant ? new Date(instant.getTime() + KST_OFFSET_MS) : null; + } + function koreaDate(value) { + return koreaClock(value)?.toISOString().slice(0, 10) || ""; + } function fmtLong(value) { - const d = parseDate(value); + const d = koreaClock(value); return d - ? `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일` + ? `${d.getUTCFullYear()}년 ${d.getUTCMonth() + 1}월 ${d.getUTCDate()}일` : "—"; } function fmtMD(value) { - const d = parseDate(value); - return d ? `${d.getMonth() + 1}월 ${d.getDate()}일` : "—"; + const d = koreaClock(value); + return d ? `${d.getUTCMonth() + 1}월 ${d.getUTCDate()}일` : "—"; } function fmtDT(value) { - const d = parseDate(value); + const d = koreaClock(value); return d - ? `${d.getMonth() + 1}월 ${d.getDate()}일 ${pad2(d.getHours())}:${pad2(d.getMinutes())}` + ? `${d.getUTCMonth() + 1}월 ${d.getUTCDate()}일 ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}` : "—"; } - function fmtHM(d) { - return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; + function fmtHM(value) { + const d = koreaClock(value); + return d ? `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}` : "—"; } function formatAge(minutes) { if (minutes == null) return "기록 없음"; @@ -82,20 +96,13 @@ if (minutes < 1440) return `${Math.round(minutes / 60)}시간 전`; return `${Math.round(minutes / 1440)}일 전`; } - function localIsoDate(value = new Date()) { - return `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}`; - } function calendarAgeDays(value) { - const parsed = parseDate(value); - if (!parsed) return null; - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const target = new Date( - parsed.getFullYear(), - parsed.getMonth(), - parsed.getDate(), + const date = koreaDate(value); + if (!date) return null; + return Math.max( + 0, + Math.round((parseDate(koreaDate(new Date())) - parseDate(date)) / DAY_MS), ); - return Math.max(0, Math.floor((today - target) / 86_400_000)); } function escapeHtml(value) { return String(value == null ? "" : value) @@ -317,9 +324,8 @@ if (!d) return null; const created = parseDate(row.created_at); // 과거 날짜를 나중에 복원한 행의 저장 시각으로 미래 입금을 당겨 넣지 않는다. - if (created && localIsoDate(created) === localIsoDate(d)) return created; - d.setHours(23, 59, 59, 999); - return d; + if (created && koreaDate(created) === koreaDate(d)) return created; + return new Date(`${koreaDate(d)}T23:59:59.999+09:00`); } /** 스냅샷·입금 기록으로 평가금액, 투자원금(계단), 시간가중 자산, 낙폭 시계열을 만든다. */ @@ -412,13 +418,14 @@ const out = []; let prevIndex = null; let current = null; + const currentYear = koreaDate(new Date()).slice(0, 4); series.dates.forEach((d, i) => { - const key = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`; + const key = koreaDate(d).slice(0, 7); if (!current || current.key !== key) { if (current) out.push(current); current = { key, - label: `${d.getFullYear() !== new Date().getFullYear() ? `${String(d.getFullYear()).slice(2)}년 ` : ""}${d.getMonth() + 1}월`, + label: `${key.slice(0, 4) !== currentYear ? `${key.slice(2, 4)}년 ` : ""}${Number(key.slice(5, 7))}월`, startIndex: prevIndex, endIndex: i, }; @@ -604,14 +611,9 @@ 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 month = koreaDate(new Date()).slice(0, 7); 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 koreaDate(flow.occurred_at).slice(0, 7) === month; }); return recorded ? "recorded" : "empty"; } @@ -1003,6 +1005,9 @@ perspective: 1, selected: null, paintFrame: null, + tableSeries: null, + tablePage: null, + tableRenderedPage: null, }; function ensureChartAccountOptions() { @@ -1076,21 +1081,53 @@ } function renderChartTable(series) { + if (!$("historyDetails").open || !series.rows.length) return; + const lastPage = Math.ceil(series.rows.length / 100) - 1; + const page = clamp(chart.tablePage ?? lastPage, 0, lastPage); + if (chart.tableSeries === series && chart.tableRenderedPage === page) + return; + const start = page * 100; + const end = Math.min(start + 100, series.rows.length); el.chartRows.innerHTML = series.rows + .slice(start, end) .map( - (row, i) => ` + (row, offset) => ` ${won(row.total_value)} - ${won(series.principal[i])} + ${won(series.principal[start + offset])} ${pct(row.cumulative_return)} `, ) .join(""); + chart.tableSeries = series; + chart.tableRenderedPage = page; + $("historyTablePosition").textContent = + `${start + 1}–${end} / ${series.rows.length}개 기록`; + $("historyTablePrev").disabled = page === 0; + $("historyTableNext").disabled = page === lastPage; + } + + function moveHistoryTable(direction) { + if (!chart.series || $("historyDetails").hidden) return; + const lastPage = Math.ceil(chart.series.rows.length / 100) - 1; + const currentPage = clamp(chart.tablePage ?? lastPage, 0, lastPage); + chart.tablePage = clamp(currentPage + direction, 0, lastPage); + // 마지막 페이지는 새 기록이 추가돼도 최근 날짜를 계속 보여 준다. + if (chart.tablePage === lastPage) chart.tablePage = null; + renderChartTable(chart.series); + $("historyTableScroll").scrollTop = 0; + } + + function clearChartDetails() { + $("historyDetails").hidden = true; + el.chartRows.innerHTML = ""; + chart.tableSeries = null; + chart.tableRenderedPage = null; } function renderMonthStrip(series, history) { const visibleMonths = new Set( - series.dates.map((d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`), + series.dates.map((d) => koreaDate(d).slice(0, 7)), ); // 조회 기간이 월 중간에서 시작해도 전월 말 기준 수익률을 사용한다. const months = monthlyReturns(history).filter((month) => @@ -1489,6 +1526,7 @@ chart.selected === chart.series?.rows.length - 1; const previousDate = chart.series?.rows[chart.selected]?.date; const sameAccount = state.chartDataAccount === state.chartAccount; + if (!sameAccount) chart.tablePage = null; chart.series = series; state.chartDataAccount = state.chartAccount; state.chartRows = series.rows; @@ -1502,6 +1540,8 @@ $("historyLatest").disabled = !series.rows.length; $("historyStart").textContent = fmtMD(series.rows[0]?.date); $("historyEnd").textContent = fmtMD(series.rows.at(-1)?.date); + // 접힌 표의 수천 개 행을 매 갱신마다 만들지 않는다. + clearChartDetails(); if (!series.rows.length) { el.chartWrap.hidden = true; @@ -1521,6 +1561,7 @@ 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; + $("historyDetails").hidden = false; renderChartTable(series); renderMonthStrip(series, history); const previousIndex = @@ -1543,8 +1584,10 @@ $("performance").setAttribute("aria-busy", "true"); $("exportHistory").disabled = true; $("historyCursor").disabled = true; + $("historyLatest").disabled = true; // 계좌가 바뀌면 이전 숫자를 남기지 않는다. 오래 걸린 이전 응답도 폐기한다. if (state.chartDataAccount !== account) { + clearChartDetails(); el.chartWrap.hidden = true; el.chartEmpty.hidden = false; el.chartEmpty.innerHTML = @@ -1562,9 +1605,9 @@ ); const cached = state.series.get(account); // 전체 기록은 갱신 주기에 한 번 읽는다. 기간·계좌 변경 때 같은 데이터를 다시 받지 않는다. - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - days); - cutoff.setHours(0, 0, 0, 0); + const cutoff = new Date( + parseDate(koreaDate(new Date())).getTime() - days * DAY_MS, + ); const data = cached ? { snapshots: cached.filter((row) => parseDate(row.date) >= cutoff) } : await fetchJson( @@ -1580,6 +1623,7 @@ updateChart(data?.snapshots || []); } catch { if (request !== state.chartRequest) return; + clearChartDetails(); el.chartSummary.textContent = "성과 기록을 읽지 못했습니다. 운영 영역의 다시 확인을 눌러주세요."; el.chartWrap.hidden = true; @@ -1821,7 +1865,7 @@ count.textContent = "확인 불가"; return; } - const isToday = !signalsDate || signalsDate === localIsoDate(); + const isToday = !signalsDate || signalsDate === koreaDate(new Date()); const rows = isToday && Array.isArray(signals) ? signals : []; count.textContent = `${rows.length}건`; table.hidden = !rows.length; @@ -2055,7 +2099,9 @@ showToast( `${state.mode === "live" ? "실전 입금" : "모의투자 적립"} ${won(data.amount)}을 기록했습니다.`, ); - await refreshCore(); + // 적립 전에 시작한 조회가 있으면 끝낸 뒤 새 금액을 다시 읽는다. + await coreRefresh; + await refreshCore(true); } catch (error) { showDepositError( `기록하지 못했습니다. ${error.message || "연결을 확인한 뒤 다시 시도하세요."}`, @@ -2079,8 +2125,19 @@ // 주기 갱신은 숨은 탭에서 쉬지만, 첫 로드는 탭이 뒤에 있어도 반드시 한 번 채운다 // (백그라운드로 연 탭이 빈 화면으로 남던 문제). - async function refreshCore(force = false) { + let coreRefresh = null; + let slowRefresh = null; + function refreshCore(force = false) { if (!force && document.visibilityState === "hidden") return; + // 타이머·탭 복귀·버튼 클릭이 겹쳐도 진행 중인 조회를 취소하지 않는다. + if (!coreRefresh) + coreRefresh = loadCore().finally(() => { + coreRefresh = null; + }); + return coreRefresh; + } + + async function loadCore() { state.coreStatus = "loading"; updateSyncIndicator(); const basketTask = fetchJson("/api/baskets", { @@ -2122,8 +2179,16 @@ renderDecision(); } - async function refreshSlow(force = false) { + function refreshSlow(force = false) { if (!force && document.visibilityState === "hidden") return; + if (!slowRefresh) + slowRefresh = loadSlow().finally(() => { + slowRefresh = null; + }); + return slowRefresh; + } + + async function loadSlow() { if (!state.runtime) { state.runtimeStatus = "loading"; updateSyncIndicator(); @@ -2254,8 +2319,17 @@ ); $("exportHistory").addEventListener("click", exportHistory); $("historyDetails").addEventListener("toggle", () => { - if ($("historyDetails").open && chart.series) drawDrawdown(chart.series); + if ($("historyDetails").hidden || !chart.series) return; + if ($("historyDetails").open) { + renderChartTable(chart.series); + drawDrawdown(chart.series); + } else { + el.chartRows.innerHTML = ""; + chart.tableSeries = null; + } }); + $("historyTablePrev").addEventListener("click", () => moveHistoryTable(-1)); + $("historyTableNext").addEventListener("click", () => moveHistoryTable(1)); $("chartView").addEventListener("click", (event) => { const button = event.target.closest("button[data-view]"); if (button) setChartView(button.dataset.view); diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html index cb13e4fe..7c7f1baf 100644 --- a/monitoring/templates/dashboard.html +++ b/monitoring/templates/dashboard.html @@ -265,7 +265,7 @@

수익의 흐름

-
+