From 59859cdcc730264bcace17c3229246b492630cd1 Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Wed, 21 Oct 2026 17:24:01 +0900 Subject: [PATCH] =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=ED=9B=84=EC=97=90=EB=8F=84=20=EB=AA=A8=EC=9D=98=EC=9A=B4?= =?UTF-8?q?=EC=9A=A9=20=EB=88=84=EC=A0=81=20=EC=84=B1=EA=B3=BC=EC=99=80=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EB=B9=84=EA=B5=90=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- core/paper_performance.py | 289 ++++++++++++++++++++++ core/paper_validation.py | 8 +- docs/RESEARCH_OPERATIONS_20261013.md | 18 ++ monitoring/static/operations.js | 36 ++- tests/js/operations.test.cjs | 122 ++++++++++ tests/test_paper_performance.py | 352 +++++++++++++++++++++++++++ tools/paper_validation.py | 104 +++++++- 7 files changed, 924 insertions(+), 5 deletions(-) create mode 100644 core/paper_performance.py create mode 100644 tests/test_paper_performance.py diff --git a/core/paper_performance.py b/core/paper_performance.py new file mode 100644 index 0000000..bf77083 --- /dev/null +++ b/core/paper_performance.py @@ -0,0 +1,289 @@ +"""Read-only account history across frozen validation cohorts. + +This is a derived view of the hash-checked journal, not another enrollment or a +current database audit. Version boundaries never contribute to a version's +forward-test sample. No historical event, timestamp or promotion rule is changed. +""" +from __future__ import annotations + +from collections import defaultdict +import math + +from core import paper_validation as pv + + +REPORT_VERSION = 1 +LIMITATIONS = [ + "전체 누적 성과는 저장 당시 검증된 동일 계좌의 관측을 연결한 값이며 현재 DB를 다시 감사한 결과가 아닙니다.", + "버전 변경 경계의 수익률은 계좌 누적 성과에만 포함하며 어느 버전의 개선 효과로도 귀속하지 않습니다.", + "변경 전후 비교는 같은 수의 수익 구간을 사용해도 시장 상황이 다르므로 코드 변경의 인과 효과나 우열을 입증하지 않습니다.", + "전체 누적 관측을 현재 버전의 검증 표본으로 합산하지 않습니다. 비용은 기존 TWR에 반영된 범위이며 경계 비용을 0으로 추정하지 않습니다.", +] + + +def _blocking_issues(summary, observation): + # Freshness and benchmark failures do not invalidate an already saved NAV. + allowed = {"close_sessions_missing", "assessment_stale", "collection_stale", + "policy_changed_pending_collection", "current_policy_unavailable"} + return sorted({issue for issue in summary.get("issues", []) + observation.get("issues", []) + if issue not in allowed and not issue.startswith("benchmark_")}) + + +def _point_issues(point): + cohort, row, summary = point + issues = _blocking_issues(summary, row) + if not summary: + issues.append("assessment_missing") + try: + pv._number(row["twr_index"], True) + pv._number(row["total_value"], True) + if pv._dt(row["measured_at"]).astimezone(pv.KST).date().isoformat() != row["date"]: + issues.append("close_date_mismatch") + if row.get("return_method") == pv.DIRECT_CLOSE_METHOD: + if pv._number(row["initial_capital"], True) != pv._number(cohort["initial_capital"], True): + issues.append("initial_capital_changed") + except (ValueError, TypeError, KeyError): + issues.append("close_value_invalid") + return sorted(set(issues)) + + +def _cutoff_rows(row, name): + values = row.get("ledger", {}).get("cutoff_ledger", {}).get(name) + if not isinstance(values, list): + raise ValueError("cutoff_ledger_missing") + result = {item["id"]: item["source_hash"] for item in values} + if len(result) != len(values) or not all(result.values()): + raise ValueError("cutoff_ledger_invalid") + return result + + +def _close_identity(point): + cohort, row, _ = point + return (cohort["fingerprint"].get("source_database"), row.get("valuation_id"), row.get("source_hash"), + row.get("measured_at"), row.get("twr_index"), row.get("return_method")) + + +def _link_issues(left, right, holidays): + a, previous, _ = left + b, current, _ = right + issues = _point_issues(left) + _point_issues(right) + for key in ("basket", "account_key"): + if not a.get(key) or a.get(key) != b.get(key): + issues.append("account_changed") + source = a.get("fingerprint", {}).get("source_database") + if not source or source != b.get("fingerprint", {}).get("source_database"): + issues.append("source_database_changed") + if not previous.get("calendar_hash") or previous.get("calendar_hash") != current.get("calendar_hash"): + issues.append("calendar_changed") + try: + if len(pv._sessions(previous["date"], current["date"], holidays)) != 2: + issues.append("nonadjacent_close_sessions") + except (ValueError, TypeError): + issues.append("trading_calendar_unavailable") + method = previous.get("return_method") + if not method or method != current.get("return_method"): + issues.append("return_method_changed") + elif method == pv.DIRECT_CLOSE_METHOD: + try: + if (current.get("first_close_in_series") is not False + or current.get("baseline_valuation_id") != previous["valuation_id"] + or current.get("baseline_source_hash") != previous["source_hash"] + or pv._dt(current["baseline_measured_at"]) != pv._dt(previous["measured_at"])): + issues.append("close_baseline_link_unverified") + if previous["initial_capital"] != current["initial_capital"]: + issues.append("initial_capital_changed") + denominator = pv._number(previous["total_value"], True) + pv._number(current["close_flow_since"]) + expected = pv._number(current["total_value"], True) / pv._number(denominator, True) + actual = pv._number(current["twr_index"], True) / pv._number(previous["twr_index"], True) + if not math.isclose(actual, expected, rel_tol=1e-8, abs_tol=1e-8): + issues.append("close_twr_link_mismatch") + for name in ("trades", "flows"): + old, new = _cutoff_rows(previous, name), _cutoff_rows(current, name) + if any(new.get(key) != value for key, value in old.items()): + issues.append("cutoff_ledger_revised_or_missing") + except (ValueError, TypeError, KeyError, AttributeError, ZeroDivisionError): + issues.append("close_link_unverified") + elif method != "linked_measurements_start_period_flows" or a["cohort_id"] != b["cohort_id"]: + # Legacy intraday baselines cannot prove a close link across versions. + issues.append("close_baseline_link_unverified") + return sorted(set(issues)) + + +def _benchmark_key(point): + cohort, row, _ = point + quote = row.get("benchmark") + spec = cohort.get("benchmark", {}) + if not quote or any(issue.startswith("benchmark_") for issue in row.get("issues", [])): + return None + try: + pv._number(quote["close"], True) + if (quote["date"] != row["date"] or quote["symbol"] != spec["symbol"] + or quote["basis"] != spec["basis"] or not quote["source"]): + return None + return quote["symbol"], quote["basis"], quote["source"], spec.get("provider") + except (ValueError, TypeError, KeyError): + return None + + +def _metrics(points, holidays): + rows = [point[1] for point in points] + result = {"start_date": rows[0]["date"] if rows else None, "end_date": rows[-1]["date"] if rows else None, + "twr_pct": None, "benchmark_return_pct": None, "excess_return_pct": None, + "drawdown_pct": None, "drawdown_scope": "observed_closes_only", + "return_method": "linked_stored_close_twr_start_period_flows", "issues": []} + blocking = {issue for point in points for issue in _point_issues(point)} + for left, right in zip(points, points[1:]): + blocking.update(_link_issues(left, right, holidays)) + if blocking: + return {**result, "status": "continuity_unverified", "issues": sorted(blocking)} + if len(rows) < 2: + return {**result, "status": "insufficient_sample"} + result["twr_pct"] = pv._round((rows[-1]["twr_index"] / rows[0]["twr_index"] - 1) * 100) + peak, drawdown = rows[0]["twr_index"], 0.0 + for row in rows: + peak = max(peak, row["twr_index"]) + drawdown = min(drawdown, row["twr_index"] / peak - 1) + result["drawdown_pct"] = pv._round(drawdown * 100) + keys = [_benchmark_key(point) for point in points] + if all(keys) and len(set(keys)) == 1: + benchmark = (rows[-1]["benchmark"]["close"] / rows[0]["benchmark"]["close"] - 1) * 100 + result.update(benchmark_return_pct=pv._round(benchmark), + excess_return_pct=pv._round(result["twr_pct"] - benchmark)) + else: + result["issues"].append("benchmark_history_not_comparable") + return {**result, "status": "observed"} + + +def _segments(points, holidays): + segments = [] + for point in points: + if not segments or _link_issues(segments[-1][-1], point, holidays): + segments.append([]) + segments[-1].append(point) + return segments + + +def _comparison(before, after, before_points, after_points, holidays): + transition = {"status": "awaiting_observations", "start_date": None, "end_date": None, + "twr_pct": None, "benchmark_return_pct": None, "excess_return_pct": None, "issues": []} + if before_points and after_points: + transition = _metrics([before_points[-1], after_points[0]], holidays) + transition["status"] = "linked" if transition["status"] == "observed" else "unlinked" + matched = {"status": "insufficient_sample", "intervals": 0, "before": None, "after": None, + "twr_change_pp": None, "excess_change_pp": None, "issues": []} + if before_points and after_points: + # Fixed anchoring: trailing old-version vs leading new-version windows. + # Never search for the best-performing interval after seeing returns. + old = _segments(before_points, holidays)[-1] + new = _segments(after_points, holidays)[0] + count = min(len(old), len(new)) - 1 + if count > 0: + old_window, new_window = old[-count - 1:], new[:count + 1] + a, b = _metrics(old_window, holidays), _metrics(new_window, holidays) + comparable = (a["status"] == b["status"] == "observed" + and before["account_key"] == after["account_key"] + and before["fingerprint"]["source_database"] == after["fingerprint"]["source_database"] + and old_window[-1][1].get("return_method") == new_window[0][1].get("return_method") + and before.get("initial_capital") == after.get("initial_capital") + and transition["status"] == "linked") + matched.update(status="observed" if comparable else "unavailable", intervals=count, before=a, after=b) + if comparable: + matched["twr_change_pp"] = pv._round(b["twr_pct"] - a["twr_pct"]) + if (a["excess_return_pct"] is not None and b["excess_return_pct"] is not None + and _benchmark_key(old_window[0]) == _benchmark_key(new_window[0])): + matched["excess_change_pp"] = pv._round(b["excess_return_pct"] - a["excess_return_pct"]) + else: + matched["issues"].append("benchmark_history_not_comparable") + else: + matched["issues"].append("comparison_basis_unverified") + return {"before_cohort_id": before["cohort_id"], "after_cohort_id": after["cohort_id"], + "changed_at": after["started_at"], "change_reasons": after["registration_reason"], + "transition": transition, "matched_window": matched} + + +def build_performance_history(events, summaries, holidays): + """Derive history from existing evidence; never write or fetch missing data.""" + groups = defaultdict(list) + for event in events: + if event["kind"] == "cohort_registered": + cohort = event["data"] + groups[cohort["basket"], cohort["account_key"]].append(cohort) + reports = [] + for (basket, account), cohorts in sorted(groups.items()): + versions, all_points, by_cohort = [], [], {} + conflicts, conflict_dates = set(), set() + for cohort in cohorts: + key = cohort["cohort_id"] + summary = summaries.get(key, {}) + original_dates = {} + for event in events: + if event["kind"] == "close_observed" and event["data"].get("cohort_id") == key: + original = event["data"]["observation"] + identity = _close_identity((cohort, original, summary)) + if original["date"] in original_dates and original_dates[original["date"]] != identity: + conflicts.add("duplicate_close_conflict") + conflict_dates.add(original["date"]) + original_dates[original["date"]] = identity + points = [(cohort, row, summary) for row in pv._observations(events, key)] + by_cohort[key] = points + all_points.extend(points) + all_points.sort(key=lambda point: (point[1]["date"], point[0]["started_at"])) + # Identical repeats do not inflate account samples; conflicts block totals. + unique = {} + for point in all_points: + day = point[1]["date"] + if day in unique: + prior = unique[day] + if _close_identity(prior) != _close_identity(point): + conflicts.add("duplicate_close_conflict") + conflict_dates.add(day) + if _point_issues(point): + conflicts.update(_point_issues(point)) + conflict_dates.add(day) + else: + unique[day] = point + points = list(unique.values()) + # A conflicting day must not look like a valid segment or daily return. + points = [(cohort, row, {**summary, "issues": sorted(set(summary.get("issues", [])) | conflicts)}) + if row["date"] in conflict_dates else (cohort, row, summary) + for cohort, row, summary in points] + for cohort in cohorts: + key = cohort["cohort_id"] + summary = summaries.get(key, {}) + own_points = [(c, row, {**s, "issues": sorted(set(s.get("issues", [])) | conflicts)}) + if row["date"] in conflict_dates else (c, row, s) + for c, row, s in by_cohort[key]] + by_cohort[key] = own_points + metric = _metrics(own_points, holidays) + versions.append({**{k: summary.get(k) for k in ("active", "ended_at")}, + **{k: cohort[k] for k in ("cohort_id", "started_at", "registration_reason", "fingerprint")}, + "observations": len(own_points), + "return_observations": sum(not _link_issues(a, b, holidays) for a, b in zip(own_points, own_points[1:])), + "performance": metric, "issues": sorted(set(summary.get("issues", [])) | set(metric["issues"]))}) + parts = _segments(points, holidays) + performance = _metrics(points, holidays) + if conflicts: + performance.update(status="continuity_unverified", twr_pct=None, benchmark_return_pct=None, + excess_return_pct=None, drawdown_pct=None) + performance["issues"] = sorted(set(performance["issues"]) | conflicts) + dates = set(unique) + expected = set() + if dates: + expected.update(pv._sessions(min(dates), max(dates), holidays)) + for cohort in cohorts: + expected.update(summaries.get(cohort["cohort_id"], {}).get("coverage", {}).get("missing_sessions", [])) + missing = sorted(expected - dates) + reports.append({"basket": basket, "account_key": account, "report_version": REPORT_VERSION, + "status": performance["status"], "observations": len(points), + "return_observations": sum(not _link_issues(a, b, holidays) for a, b in zip(points, points[1:])), + "version_count": len(cohorts), "performance": performance, + "coverage": {"missing_sessions": missing, + "benchmark_missing_sessions": [p[1]["date"] for p in points if not _benchmark_key(p)]}, + "issues": sorted(set(performance["issues"]) | ({"close_sessions_missing"} if missing else set())), + "segments": [{"observations": len(part), "return_observations": max(0, len(part) - 1), + "performance": _metrics(part, holidays)} for part in parts], + "versions": versions, + "comparisons": [_comparison(a, b, by_cohort[a["cohort_id"]], by_cohort[b["cohort_id"]], holidays) + for a, b in zip(cohorts, cohorts[1:])], + "research_only": True, "profitability_pass": None, "limitations": LIMITATIONS}) + return reports diff --git a/core/paper_validation.py b/core/paper_validation.py index 37e6463..09531b7 100644 --- a/core/paper_validation.py +++ b/core/paper_validation.py @@ -741,7 +741,8 @@ def read_paper_validation(output_dir=None): code. An explicit artifact directory retains its own historical policy context. """ result = {"schema_version": 1, "research_only": True, "automatic_live_promotion": False, - "status": "not_started", "updated_at": None, "cohorts": [], "issues": [], "limitations": LIMITATIONS} + "status": "not_started", "updated_at": None, "cohorts": [], "performance_history": [], + "issues": [], "limitations": LIMITATIONS} try: events = _load_events(_directory(output_dir)) if not events: @@ -805,6 +806,11 @@ def read_paper_validation(output_dir=None): row["status"] = "stale" row["issues"] = sorted(set(row["issues"]) | {"collection_stale"}) result["cohorts"] = sorted(summaries.values(), key=lambda row: (not row["active"], row["basket"], row["started_at"]), reverse=False) + from core.paper_performance import build_performance_history + try: + result["performance_history"] = build_performance_history(events, summaries, _calendar()) + except (OSError, ValueError, TypeError, KeyError, AttributeError, OverflowError): + result["issues"].append("performance_history_unavailable") result["status"] = ("unavailable" if result["issues"] else "data_gaps" if any(row["status"] in {"data_gaps", "stale"} for row in active) else "observing" if active else "inactive") return result diff --git a/docs/RESEARCH_OPERATIONS_20261013.md b/docs/RESEARCH_OPERATIONS_20261013.md index a6d4343..67a1cc9 100644 --- a/docs/RESEARCH_OPERATIONS_20261013.md +++ b/docs/RESEARCH_OPERATIONS_20261013.md @@ -47,6 +47,24 @@ - 비교지수는 배당 제외 가격지수다. 현금·방어 자산 비중이 다른 계좌와의 수익률 차이를 위험조정 알파로 부르지 않는다. 비용은 NAV에 이미 반영돼 재차 차감하지 않는다. - 읽기 전용 장부의 과거 행이 수정·누락되거나 자료가 늦어지면 무결성 문제로 표시한다. 자료 부족이나 표본 요건 충족이 자동 실계좌 승격으로 이어지지 않는다. +## 코드 변경 이후의 누적 성과와 버전 비교 (2026-10-21) + +운용 코드가 바뀌어도 **계좌 전체의 누적 성과는 이어서 조회**한다. `paper_validation status`와 운영 대시보드는 전체 누적 성과, 종료된 버전을 포함한 버전별 성과, 변경 전후 비교를 함께 보여준다. 현재 버전의 전진 검증 표본과 전체 운용 이력은 따로 관리한다. 기존 이벤트를 수정하거나 과거 종가를 새 버전의 표본으로 소급 등록하지 않는다. + +`core/paper_performance.py`는 해시 검사를 통과한 기존 관측 기록에서 조회용 보고서를 계산한다. 이 모듈과 화면·CLI 변경은 운용 코드 지문에 포함되지 않으므로 이번 표시 기능 추가로 검증 묶음을 다시 시작하지 않는다. 보고서의 `report_version`은 조회 계산 방식의 버전이며, 검증 프로토콜이나 매매 정책의 버전과 구분한다. + +- 같은 계좌·원천 DB·계산 방식·초기자본의 인접 거래일만 연결한다. 직접 종가 방식은 직전 종가 ID·해시·시각, 입출금 조정 TWR 비율, 누적 거래·입출금 원장 해시가 이어지는지도 확인한다. 저장 당시 평가에 원장 오류가 있었다면 정상 성과로 연결하지 않는다. +- 연결이 불명확하거나 중간 종가가 빠졌으면 전체 기간 수익률은 표시하지 않고, 근거가 있는 연속 구간만 나누어 보여준다. 동일 날짜 중복은 표본을 늘리지 않으며 서로 다른 원장 값이 충돌하면 해당 날짜와 인접 수익 구간의 집계를 보류한다. +- 비교지수의 종목·계산 기준·제공처가 달라지거나 날짜가 맞지 않으면 전체 비교지수 수익률과 차이는 표시하지 않는다. 확인 가능한 계좌 수익률은 별도로 유지한다. +- 변경 전 마지막 종가부터 변경 후 첫 종가까지의 수익률은 **변경 경계 구간**으로 표시하고 전체 누적 성과에만 포함한다. 어느 한 버전의 성과나 개선 효과로 귀속하지 않는다. 새 버전 첫 관측의 빈 거래 목록을 경계 비용 0원으로 해석하지 않는다. +- 변경 전후 비교는 이전 버전의 마지막 N개 수익 구간과 이후 버전의 첫 N개 수익 구간을 사용한다. N은 양쪽에서 확인되는 연속 구간 수 중 작은 값이다. 경계 구간은 제외하며, 비교할 구간이 없거나 연결 기준을 확인하지 못하면 수치를 보류한다. 기간이 같아도 시장 상황은 다르므로 변경 효과나 통계적 우열을 뜻하지 않는다. + +수익률은 기존 장부의 입출금 조정 TWR 지수를 비율로 연결하며 단순 합산하지 않는다. 구간별 수익률을 기하 연결한다는 계산 원리는 [CFA Institute의 GIPS 계산 기준](https://www.gipsstandards.org/standards/gips-standards-for-firms/gips-standards-handbook-for-firms/)을 참고했다. 현재 장부는 입출금을 구간 시작으로 간주하는 근사치이며 GIPS 준수 성과라는 뜻은 아니다. 이 조회는 저장 당시 관측의 연결을 확인하며 현재 운영 DB를 다시 감사하거나 변경하지 않는다. + +2026-10-21 저장 자료에서 두 바스켓 모두 기존 6개와 새 버전 1개를 합친 **종가 7개·수익 구간 6개**를 확인했다. 10월 13~21일 누적 TWR은 `kr_diversified_hold` -1.45905727%, `kr_pocket` -1.43007355%다. 새 버전 내부 수익 구간은 아직 0개이므로 변경 전후 비교와 수익성 판정은 보류한다. 최종 검증 자료는 `output/paper_performance_review_20261021/`에 보존한다. + +Python 관련 검사 108개(기존 수집·검증 82개, 누적 성과 26개), JavaScript 45개를 통과했다. 운영 DB와 기존 이벤트 69개는 조회 전후 해시가 같았다. 실제 브라우저에서 누적값·종료 버전·비교 보류 표시와 폭 390px의 요약 화면을 확인했다. 브라우저 창 최소화로 스크린샷은 남기지 못했으며 화면 구조·표시 문구 검증과 구분한다. 자동 갱신 후 계좌별 펼침 상태 보존도 검사했다. + ## 실행 명령 프로젝트 루트 PowerShell에서 실행한다. 수집 도구는 운영 장부에 주문이나 입출금을 기록하지 않는다. diff --git a/monitoring/static/operations.js b/monitoring/static/operations.js index ce75c4e..f96461c 100644 --- a/monitoring/static/operations.js +++ b/monitoring/static/operations.js @@ -5,7 +5,7 @@ const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]); const finite = (v) => typeof v === "number" && Number.isFinite(v); const num = (v, digits = 1) => finite(v) ? v.toLocaleString("ko-KR", { maximumFractionDigits: digits }) : "—"; - const status = (v) => ({ ready: "자료 준비됨", completed: "수집 완료", running: "수집 중", partial: "일부 자료 부족", attention: "확인 필요", unavailable: "자료 없음", stale: "갱신 지연", not_started: "시작 기록 없음", interrupted: "수집 중단", disabled: "자동 수집 꺼짐", collecting: "관측 누적 중", insufficient_sample: "관측 부족", observing: "관측 누적 중", awaiting_forward: "새 종가 관측 대기", ready_for_review: "표본 요건 충족 · 성과 검토 필요", data_gaps: "관측 결측", corrupt: "기록 확인 필요", disk_pressure: "저장 공간 부족", failed: "수집 실패", timeout: "수집 시간 초과", inactive: "활성 검증 없음" })[String(v).toLowerCase()] || v || "미확인"; + const status = (v) => ({ ready: "자료 준비됨", completed: "수집 완료", running: "수집 중", partial: "일부 자료 부족", attention: "확인 필요", unavailable: "자료 없음", stale: "갱신 지연", not_started: "시작 기록 없음", interrupted: "수집 중단", disabled: "자동 수집 꺼짐", collecting: "관측 누적 중", insufficient_sample: "관측 부족", observing: "관측 누적 중", awaiting_forward: "새 종가 관측 대기", ready_for_review: "표본 요건 충족 · 성과 검토 필요", data_gaps: "관측 결측", corrupt: "기록 확인 필요", disk_pressure: "저장 공간 부족", failed: "수집 실패", timeout: "수집 시간 초과", inactive: "활성 검증 없음", observed: "관측 성과", continuity_unverified: "전체 기간 연결 미확인", linked: "수익률 연결 확인", unlinked: "연결 보류", awaiting_observations: "종가 관측 대기" })[String(v).toLowerCase()] || v || "미확인"; const when = (v) => { if (!v) return "미확인"; const d = new Date(v); @@ -17,6 +17,33 @@ const metric = (label, value, note = "") => `
${esc(label)}${esc(value)}${esc(note)}
`; const issueLabel = (value) => ({ dart_status_010: "DART 인증키가 등록되지 않았습니다. 키를 갱신해야 공시를 수집할 수 있습니다.", dart_api_key_missing: "DART 인증키 설정이 필요합니다.", close_sessions_missing: "예상 거래일의 종가 기록이 누락됐습니다.", benchmark_missing: "동일 날짜의 비교지수 자료가 없습니다.", benchmark_source_changed: "비교지수 공급자가 변경되어 비교를 보류합니다.", assessment_stale: "최신 거래일까지 평가가 갱신되지 않았습니다.", collection_stale: "자동 수집 완료 기록이 오래됐습니다.", execution_observation_schema_missing: "새 체결 기록 구조가 아직 적용되지 않았습니다. 다음 운영 시작 시 적용됩니다.", no_new_execution_observations: "아직 새 체결 관측 기록이 없습니다." })[value] || value; const issues = (values) => (values || []).map((value) => `

${esc(issueLabel(typeof value === "string" ? value : value.message || value.code || "자료 확인 필요"))}

`).join(""); + const pct = (value, unit = "%") => finite(value) ? `${num(value, 2)}${unit}` : "—"; + const period = (performance = {}) => `${performance?.start_date || "—"} ~ ${performance?.end_date || "—"}`; + const changeReason = (reason) => ({ first_registration: "최초 등록", config_changed: "운용 설정 변경", code_changed: "운용 코드 변경", protocol_changed: "검증 기준 변경", source_database_changed: "원천 원장 변경" })[reason] || reason; + const changeReasons = (values) => (values || []).map(changeReason).join(" · ") || "변경 사유 없음"; + + function performanceCells(performance = {}) { + return [performance?.twr_pct, performance?.benchmark_return_pct, performance?.excess_return_pct, performance?.drawdown_pct].map((value, index) => `${pct(value, index === 2 ? "%p" : "%")}`).join(""); + } + + function renderPerformanceHistory(history) { + const detailKey = (section) => esc(JSON.stringify([history.account_key || history.basket || "", section])); + const versions = history.versions || []; + const performance = history.performance || {}; + const disconnected = history.status === "continuity_unverified"; + const summary = disconnected ? {} : performance; + const versionRows = versions.map((version, index) => `버전 ${index + 1} · ${version.active ? "현재" : "종료"}
${esc(String(version.fingerprint?.code || "—").slice(0, 10))}${esc(period(version.performance))}
등록 ${esc(when(version.started_at))}${version.ended_at ? ` · 종료 ${esc(when(version.ended_at))}` : ""}
${esc(changeReasons(version.registration_reason))}
${num(version.observations, 0)} / ${num(version.return_observations, 0)}${performanceCells(version.performance)}${issues(version.issues) || "—"}`).join(""); + const segments = disconnected ? (history.segments || []).map((segment, index) => `

연속 구간 ${index + 1} · ${esc(period(segment.performance))} · 종가 ${num(segment.observations, 0)}개 / 수익 구간 ${num(segment.return_observations, 0)}개 · 입출금 조정 수익률 ${pct(segment.performance?.twr_pct)} · 비교지수 ${pct(segment.performance?.benchmark_return_pct)} · 차이 ${pct(segment.performance?.excess_return_pct, "%p")}

`).join("") : ""; + const comparisons = (history.comparisons || []).map((comparison) => { + const transition = comparison.transition || {}; + const window = comparison.matched_window || {}; + const before = versions.findIndex((version) => version.cohort_id === comparison.before_cohort_id); + const after = versions.findIndex((version) => version.cohort_id === comparison.after_cohort_id); + const rows = window.status === "observed" ? [["변경 전", window.before], ["변경 후", window.after]].map(([label, value]) => `${label}${esc(period(value))}${performanceCells(value)}`).join("") : ""; + return `

버전 ${before < 0 ? "—" : before + 1} → 버전 ${after < 0 ? "—" : after + 1} · ${esc(when(comparison.changed_at))}

${esc(changeReasons(comparison.change_reasons))}

변경 경계 구간: ${esc(status(transition.status))} · ${esc(period(transition))}${transition.status === "linked" ? ` · 입출금 조정 수익률 ${pct(transition.twr_pct)} · 비교지수 ${pct(transition.benchmark_return_pct)} · 차이 ${pct(transition.excess_return_pct, "%p")}` : ""}

${issues(transition.issues)}

동일 길이 비교: ${esc(status(window.status))} · 각 ${num(window.intervals, 0)}개 수익 구간

${rows ? `
${rows}
변경 전 마지막 구간과 변경 후 첫 구간 · 변경 경계 구간 제외
구분관측 기간입출금 조정 수익률비교지수차이최대 낙폭

변경 후 − 변경 전: 수익률 ${pct(window.twr_change_pp, "%p")} · 비교지수 대비 차이 ${pct(window.excess_change_pp, "%p")}

` : '

변경 전후에 비교 가능한 수익 구간이 쌓여야 수치를 표시합니다.

'}${issues(window.issues)}
`; + }).join(""); + return `

${esc(history.basket || history.account_key)} · 전체 누적 성과

${esc(status(history.status))}

${esc(period(performance))} · 종가 ${num(history.observations, 0)}개 / 수익 구간 ${num(history.return_observations, 0)}개 · 버전 ${num(history.version_count, 0)}개

입출금 조정 수익률 ${pct(summary.twr_pct)} · 비교지수 ${pct(summary.benchmark_return_pct)} · 차이 ${pct(summary.excess_return_pct, "%p")} · 최대 낙폭 ${pct(summary.drawdown_pct)}

종가 결측 ${num(history.coverage?.missing_sessions?.length, 0)}일 · 비교지수 결측 ${num(history.coverage?.benchmark_missing_sessions?.length, 0)}일

${disconnected ? '

전체 기간을 연결할 근거가 부족해 누적 수익률을 보류합니다. 확인된 연속 구간의 성과만 아래에 표시합니다.

' : '

코드가 바뀌어도 연결 근거가 확인된 원장의 성과를 이어서 계산합니다. 버전별 전진 검증 표본 요건은 따로 유지합니다.

'}${segments}${issues(history.issues)}
버전별 성과 · ${num(versions.length, 0)}개
${versionRows || ''}
종료된 버전 포함 · 종가 관측 수 / 수익 구간 수
버전 · 코드관측 기간 · 변경 사유표본입출금 조정 수익률비교지수차이최대 낙폭확인 사항
등록된 버전이 없습니다.
변경 전후 비교 · ${num((history.comparisons || []).length, 0)}건

기간 길이를 맞춘 관측 비교입니다. 시장 상황과 보유 종목도 달라질 수 있으므로 코드 변경의 효과나 우열을 뜻하지 않습니다.

${comparisons || '

아직 비교할 버전 변경 기록이 없습니다.

'}
누적 성과 계산 범위${issues(history.limitations)}
`; + } function render(data) { const ops = data.operations || {}; @@ -54,12 +81,17 @@ } function renderValidation(validation) { + const panel = $("paperValidation"); + const detailNodes = () => [...(panel.querySelectorAll?.("details[data-validation-detail]") || [])]; + const openDetails = new Set(detailNodes().filter((node) => node.open).map((node) => node.dataset.validationDetail)); const cohorts = (validation.cohorts || []).filter((row) => row.active !== false); - $("paperValidation").innerHTML = issues(validation.issues) + (cohorts.length ? cohorts.map((row) => { + const history = (validation.performance_history || []).map(renderPerformanceHistory).join(""); + panel.innerHTML = issues(validation.issues) + history + (history ? '

현재 버전의 전진 검증 · 변경 이후의 새 관측으로 표본 요건을 확인합니다.

' : "") + (cohorts.length ? cohorts.map((row) => { const performance = row.performance || {}; const regime = (key, label) => { const r = row.regimes?.[key] || {}; return `

${label} ${num(r.observations, 0)} / ${num(row.minimum_regime_observations, 0)}일 · 계좌 ${num(r.portfolio_return_pct)}% · 비교지수 ${num(r.benchmark_return_pct)}%

`; }; return `

${esc(row.basket || row.account_key)}

${esc(status(row.status))}

새 관측 ${num(row.return_observations ?? row.observations, 0)} / ${num(row.minimum_observations, 0)}거래일 · 기간 ${num(row.calendar_days, 0)} / ${num(row.minimum_calendar_days, 0)}일 · 시작 ${esc(when(row.started_at))}

입출금 조정 수익률 ${num(performance.twr_pct)}% · ${esc(row.benchmark?.name || "비교지수")} ${num(performance.benchmark_return_pct)}% · 차이 ${num(performance.excess_return_pct)}%p

${regime("up", "비교지수 상승일")}${regime("down", "비교지수 하락일")}

결측 ${num(row.coverage?.missing_sessions?.length, 0)}일 · 비용 ${num(row.costs?.explicit_cost, 0)}원 · 총 매매금액 / 평균 자산 ${num(row.costs?.gross_turnover_ratio, 2)}배

${issues(row.issues)}
검증 식별자와 범위

${esc(row.cohort_id)} · ${esc(row.account_key)}

입출금을 구간 시작으로 간주한 장부 TWR 근사치입니다. 비용은 자산 평가에 이미 반영되어 다시 차감하지 않습니다. 기존 과거 기록은 새 전진 검증 일수에 포함하지 않습니다. 관측 기간 충족이 실계좌 승인이나 미래 수익 보증을 뜻하지 않습니다.

`; }).join("") : '

검증 기준 등록 또는 첫 종가 기록을 기다리고 있습니다. 자동 수집 실행 상태를 확인하세요.

'); + for (const node of detailNodes()) node.open = openDetails.has(node.dataset.validationDetail); } let reading = false; async function load() { diff --git a/tests/js/operations.test.cjs b/tests/js/operations.test.cjs index 6c27df1..ecd9bf8 100644 --- a/tests/js/operations.test.cjs +++ b/tests/js/operations.test.cjs @@ -53,3 +53,125 @@ test("provider shares, authentication failure and broker environments are explic const costs = p.element("executionSummary").innerHTML; assert.match(costs, /증권사 모의 서버/); assert.match(costs, /추정값/); }); + +function history(overrides = {}) { + return { + basket: "장기 보유", account_key: "paper:hold", status: "observed", observations: 8, + return_observations: 7, version_count: 2, + performance: { start_date: "2026-10-13", end_date: "2026-10-22", twr_pct: 4.25, benchmark_return_pct: 3, excess_return_pct: 1.25, drawdown_pct: -0.5 }, + coverage: { missing_sessions: [], benchmark_missing_sessions: [] }, issues: [], + versions: [ + { cohort_id: "old", active: false, started_at: "2026-10-13T00:00:00Z", ended_at: "2026-10-21T00:00:00Z", fingerprint: { code: "abcdef123456789" }, registration_reason: ["first_registration"], observations: 6, return_observations: 5, performance: { start_date: "2026-10-13", end_date: "2026-10-20", twr_pct: 2 }, issues: [] }, + { cohort_id: "new", active: true, started_at: "2026-10-21T00:00:00Z", fingerprint: { code: "1234567890abcd" }, registration_reason: ["code_changed", "config_changed"], observations: 2, return_observations: 1, performance: { start_date: "2026-10-21", end_date: "2026-10-22", twr_pct: 1 }, issues: [] } + ], comparisons: [], segments: [], ...overrides + }; +} + +test("cumulative history retains ended versions without inflating current validation samples", () => { + const p = panel(); + p.render({ validation: { performance_history: [history()], cohorts: [{ active: true, basket: "장기 보유", return_observations: 1, minimum_observations: 120 }] } }); + const html = p.element("paperValidation").innerHTML; + assert.match(html, /전체 누적 성과/); assert.match(html, /종가 8개 \/ 수익 구간 7개/); + assert.match(html, /입출금 조정 수익률 4.25%/); + assert.match(html, /버전 1 · 종료/); assert.match(html, /버전 2 · 현재/); + assert.match(html, /abcdef1234/); assert.doesNotMatch(html, /abcdef123456789/); + assert.match(html, /운용 코드 변경 · 운용 설정 변경/); + assert.match(html, /1 \/ 120거래일/); assert.doesNotMatch(html, /7 \/ 120거래일/); +}); + +test("unverified continuity suppresses aggregate values and shows verified segments", () => { + const p = panel(); + p.render({ validation: { performance_history: [history({ status: "continuity_unverified", performance: { twr_pct: 999 }, coverage: { missing_sessions: ["2026-10-16"], benchmark_missing_sessions: ["2026-10-21"] }, segments: [{ observations: 3, return_observations: 2, performance: { start_date: "2026-10-13", end_date: "2026-10-15", twr_pct: 1.75 } }] })] } }); + const html = p.element("paperValidation").innerHTML; + assert.match(html, /전체 기간 연결 미확인/); assert.doesNotMatch(html, /999%/); + assert.match(html, /누적 수익률을 보류/); assert.match(html, /연속 구간 1/); + assert.match(html, /입출금 조정 수익률 1.75%/); + assert.match(html, /종가 결측 1일 · 비교지수 결측 1일/); +}); + +test("matched comparison displays equal windows and keeps transition separate", () => { + const p = panel(); + p.render({ validation: { performance_history: [history({ comparisons: [{ before_cohort_id: "old", after_cohort_id: "new", changed_at: "2026-10-21T00:00:00Z", change_reasons: ["code_changed"], transition: { status: "linked", start_date: "2026-10-20", end_date: "2026-10-21", twr_pct: 1.2, benchmark_return_pct: 0.7, excess_return_pct: 0.5 }, matched_window: { status: "observed", intervals: 1, before: { start_date: "2026-10-19", end_date: "2026-10-20", twr_pct: 0.5 }, after: { start_date: "2026-10-21", end_date: "2026-10-22", twr_pct: 1 }, twr_change_pp: 0.5, excess_change_pp: -0.3 } }] })] } }); + const html = p.element("paperValidation").innerHTML; + assert.match(html, /버전 1 → 버전 2/); assert.match(html, /변경 경계 구간: 수익률 연결 확인/); + assert.match(html, /동일 길이 비교: 관측 성과 · 각 1개 수익 구간/); + assert.match(html, /2026-10-19 ~ 2026-10-20/); assert.match(html, /2026-10-21 ~ 2026-10-22/); + assert.match(html, /변경 경계 구간 제외/); + assert.match(html, /변경 후 − 변경 전: 수익률 0.5%p · 비교지수 대비 차이 -0.3%p/); + assert.match(html, /코드 변경의 효과나 우열을 뜻하지 않습니다/); +}); + +test("first observation and disconnected comparisons never turn missing returns into zero", () => { + const p = panel(); + p.render({ validation: { performance_history: [history({ status: "insufficient_sample", observations: 1, return_observations: 0, performance: null, versions: [], comparisons: [{ transition: { status: "unlinked", twr_pct: 777 }, matched_window: { status: "insufficient_sample", intervals: 0, before: { twr_pct: 888 } } }] })] } }); + const html = p.element("paperValidation").innerHTML; + assert.match(html, /입출금 조정 수익률 — · 비교지수 —/); + assert.match(html, /변경 경계 구간: 연결 보류/); + assert.match(html, /동일 길이 비교: 관측 부족 · 각 0개 수익 구간/); + assert.doesNotMatch(html, /777%|888%|입출금 조정 수익률 0%/); +}); + +test("historical names, dates, reasons and issue text are escaped", () => { + const p = panel(); + const unsafe = ""; + p.render({ validation: { performance_history: [history({ basket: unsafe, performance: { start_date: unsafe }, issues: [unsafe], versions: [{ cohort_id: "x", fingerprint: { code: unsafe }, registration_reason: [unsafe], performance: { end_date: unsafe }, issues: [unsafe] }], comparisons: [{ change_reasons: [unsafe], transition: { status: unsafe, start_date: unsafe, issues: [unsafe] }, matched_window: { status: unsafe, issues: [unsafe] } }] })] } }); + const html = p.element("paperValidation").innerHTML; + assert.doesNotMatch(html, / { + const p = panel(); + const element = p.element("paperValidation"); + let html = ""; + let details = []; + // Replacing innerHTML creates new closed nodes, as it does in the browser. + Object.defineProperty(element, "innerHTML", { + get: () => html, + set: (value) => { + html = value; + details = [...value.matchAll(/
/g)].map((match) => ({ + open: false, + dataset: { validationDetail: match[1].replace(/&(quot|amp|lt|gt|#39);/g, (_, name) => ({ quot: '"', amp: "&", lt: "<", gt: ">", "#39": "'" })[name]) } + })); + } + }); + element.querySelectorAll = () => details; + const key = (account, section) => JSON.stringify([account, section]); + const node = (account, section) => details.find((item) => item.dataset.validationDetail === key(account, section)); + p.render({ validation: { performance_history: [history(), history({ account_key: "paper:pocket", basket: "적립" })] } }); + node("paper:hold", "versions").open = true; + node("paper:hold", "comparisons").open = true; + node("paper:pocket", "limitations").open = true; + const previous = node("paper:hold", "versions"); + p.render({ validation: { performance_history: [ + history({ account_key: "paper:new", basket: "새 계좌" }), + history({ account_key: "paper:pocket", basket: "적립" }), + history({ basket: "이름 변경", version_count: 3, versions: [...history().versions, { cohort_id: "third", active: true }] }) + ] } }); + assert.notEqual(node("paper:hold", "versions"), previous); + assert.equal(node("paper:hold", "versions").open, true); + assert.equal(node("paper:hold", "comparisons").open, true); + assert.equal(node("paper:pocket", "limitations").open, true); + assert.equal(node("paper:pocket", "versions").open, false); + assert.equal(node("paper:new", "versions").open, false); + node("paper:hold", "versions").open = false; + p.render({ validation: { performance_history: [history()] } }); + assert.equal(node("paper:hold", "versions").open, false); + assert.equal(node("paper:hold", "comparisons").open, true); +}); + +test("history calculation scope exposes saved limitations with escaped text", () => { + const p = panel(); + p.render({ validation: { performance_history: [history({ limitations: [ + "저장 당시 검증된 관측이며 현재 DB를 다시 감사한 결과가 아닙니다.", + "버전 변경 경계의 수익률은 계좌 누적 성과에만 포함합니다.", + "비용은 기존 TWR에 반영되어 다시 차감하지 않습니다.", + "" + ] })] } }); + const html = p.element("paperValidation").innerHTML; + assert.match(html, /누적 성과 계산 범위/); + assert.match(html, /저장 당시 검증된 관측/); + assert.match(html, /버전 변경 경계의 수익률/); + assert.match(html, /비용은 기존 TWR에 반영되어 다시 차감하지 않습니다/); + assert.doesNotMatch(html, /