From 1b4d67f3994eb9809819f356fd603720849292fa Mon Sep 17 00:00:00 2001
From: easygap <103491329+easygap@users.noreply.github.com>
Date: Tue, 22 Sep 2026 14:50:14 +0900
Subject: [PATCH] =?UTF-8?q?=ED=88=AC=EC=9E=90=20=EA=B8=B0=EB=A1=9D=20?=
=?UTF-8?q?=ED=83=90=EC=83=89=20=ED=99=94=EB=A9=B4=20=EA=B0=9C=ED=8E=B8?=
=?UTF-8?q?=EA=B3=BC=20=EC=9C=84=ED=97=98=20=ED=8C=90=EB=8B=A8=20=EC=9E=90?=
=?UTF-8?q?=EB=A3=8C=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EC=99=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 39 +-
core/basket_rebalancer.py | 95 +-
core/risk_overlays.py | 39 +-
docs/DESIGN_20260922.md | 102 +
docs/RISK_REVIEW_20260922.md | 69 +
docs/images/dashboard-account-20260922.png | Bin 0 -> 153269 bytes
docs/images/dashboard-holdings-20260922.png | Bin 0 -> 131030 bytes
docs/images/dashboard-mobile-20260922.png | Bin 0 -> 250619 bytes
docs/images/dashboard-operations-20260922.png | Bin 0 -> 67669 bytes
docs/images/risk-review-20260922.png | Bin 0 -> 194214 bytes
monitoring/static/dashboard.css | 2020 ++++++++++-------
monitoring/static/dashboard.js | 648 ++++--
monitoring/static/nungum-symbol.svg | 4 +-
monitoring/templates/dashboard.html | 379 ++--
monitoring/web_dashboard.py | 6 +-
.../dashboard_performance_20260922.json | 33 +
.../implementation_benchmark_20260922.json | 78 +
reports/research/risk_review_20260922.json | 858 +++++++
tests/test_basket_overlay_integration.py | 78 +-
tests/test_risk_overlays.py | 19 +
tests/test_risk_review_data.py | 55 +
tools/benchmark_risk_review.py | 96 +
tools/risk_review.py | 70 +-
23 files changed, 3443 insertions(+), 1245 deletions(-)
create mode 100644 docs/DESIGN_20260922.md
create mode 100644 docs/RISK_REVIEW_20260922.md
create mode 100644 docs/images/dashboard-account-20260922.png
create mode 100644 docs/images/dashboard-holdings-20260922.png
create mode 100644 docs/images/dashboard-mobile-20260922.png
create mode 100644 docs/images/dashboard-operations-20260922.png
create mode 100644 docs/images/risk-review-20260922.png
create mode 100644 reports/research/dashboard_performance_20260922.json
create mode 100644 reports/research/implementation_benchmark_20260922.json
create mode 100644 reports/research/risk_review_20260922.json
create mode 100644 tests/test_risk_review_data.py
create mode 100644 tools/benchmark_risk_review.py
diff --git a/README.md b/README.md
index f7473060..8579796a 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,15 @@
정해 둔 비중에 맞춰 주식과 ETF를 사고팔고, 원금·수익률·보유 종목을 한 화면에서 확인하는 개인용 투자 도구입니다. 과거 데이터로 운용 규칙을 비교하고, 모의투자로 실제 동작을 점검할 수 있습니다.
-
+
-*2026년 9월 17일 모의투자 기록을 불러온 실제 화면입니다. 아래 백테스트와는 별도의 계좌 기록입니다.*
+*2026년 9월 22일 모의투자 기록을 불러온 실제 화면입니다. 아래 백테스트와는 별도의 계좌 기록입니다.*
## 할 수 있는 일
- **계좌 확인** — 총자산, 투자원금, 현금, 보유 종목과 목표 비중을 확인합니다.
-- **성과 확인** — 적립금을 수익에서 제외한 수익률, 자산 추이, 고점 대비 하락률을 봅니다.
+- **날짜별 성과 탐색** — 날짜 눈금을 움직이면 그날의 수익률·원금·평가금액이 함께 바뀝니다. 적립금은 수익에서 제외합니다.
+- **입체·평면 차트와 CSV** — 같은 투자 기록을 두 방식으로 살펴보고, 조회한 기간의 원본 수치를 내려받습니다.
- **자동 리밸런싱** — 실제 비중이 설정 범위를 벗어나면 조정합니다. 최소 주문금액과 1주 단위를 반영합니다.
- **모의투자 검증** — 운영 기록, 누락된 자산 기록, 거래비용과 미해결 주문을 확인합니다.
- **운영 점검** — 자동매매 실행 여부, 거래 중지 상태, 증권사 연결과 데이터 갱신 상태를 봅니다.
@@ -22,11 +23,17 @@ Python·aiohttp·SQLite·한국투자증권 KIS API를 사용합니다. 화면
## 화면
+### 날짜를 고르면 그날의 계좌가 보입니다
+
+파란 차트의 띠는 실제 누적 수익률입니다. 날짜 눈금은 마우스·터치·방향키로 움직일 수 있습니다. 입체감은 선에 폭을 준 표현이며, 별도의 지표를 뜻하지 않습니다. 정확한 값은 평면 차트와 일별 기록 표에서도 확인할 수 있습니다.
+
+한글 글꼴은 로컬에서 읽고, 입체 표현은 가벼운 Canvas로 그립니다. 흰색·검정·코발트·시안의 역할과 참고한 실제 디자인 사례는 [화면 설계와 검증 기록](docs/DESIGN_20260922.md)에 정리했습니다.
+
### 보유 종목과 비중
매입금액과 목표 비중을 나란히 보여 줍니다. 계좌를 바꾸면 요약, 종목표, 차트, 적립금 입력 대상이 함께 바뀝니다.
-
+
### 모바일과 운영 상태
@@ -35,14 +42,14 @@ Python·aiohttp·SQLite·한국투자증권 KIS API를 사용합니다. 화면
모바일 화면 보기
-
+
자동매매 상태 화면 보기
-
+
@@ -61,21 +68,23 @@ ETF 적립의 기본 목표는 주식 ETF 47.5%·CD금리 ETF 47.5%·현금 5%
적립금이 들어오면 낙폭이 작아 보이던 백테스트 계산을 고쳤습니다. 이어서 추세·낙폭 조건이 겹칠 때 비중을 중복해서 줄이지 않고, 주식 축소분을 기존 CD금리 ETF로 옮기도록 바꿨습니다.
-
+
+
+**2020-07-07~2026-09-17, 실제 ETF 종가·1주 단위·월 10만원 적립 비교**
-**2020-07-07~2026-09-16, 실제 ETF 종가·1주 단위·월 10만원 적립 비교**
+9월 22일 수집 자료에서 지수 종가가 17일까지 제공되어, 모든 자료가 있는 17일까지 비교했습니다. ETF에만 있는 18일·21일 기록을 섞어 기간을 늘리지 않았습니다.
| 방식 | 연환산 수익률 | 최대 낙폭 | 샤프² |
|---|---:|---:|---:|
-| 고정 비중 | 13.54% | -21.72% | 0.80 |
-| 기존 위험 관리¹ | 10.74% | -16.02% | 0.74 |
-| 변경한 위험 관리 | 12.97% | -15.74% | 0.90 |
+| 고정 비중 | 13.53% | -21.72% | 0.79 |
+| 기존 위험 관리¹ | 10.73% | -16.02% | 0.74 |
+| 변경한 위험 관리 | 12.96% | -15.74% | 0.90 |
¹ 기존 방식도 적립금 계산 오류를 수정한 뒤 같은 조건으로 다시 계산했습니다. ² 샤프 계산의 기준금리는 연 3%로 고정했습니다.
변경한 방식은 이 전체 기간에서 기존 방식보다 수익률이 높고 최대 낙폭이 작았습니다. **고정 비중보다 수익률은 낮았고, 2023~2025년에는 기존 방식보다 낙폭이 컸습니다.** 수수료·슬리피지와 CD ETF의 보수적 세금 근사를 반영했지만, ETF 분배금·실시간 호가·미체결은 재현하지 못했습니다. 이미 살펴본 과거 자료로 비교한 결과이며 향후 수익을 보장하지 않습니다.
-기간별 결과, 비용 3배 조건, 2014년부터의 보조 실험, 2026년 9월까지 확인한 자료와 코드 변경 근거는 [위험 관리 검증 보고서](docs/RISK_REVIEW_20260917.md)에 정리했습니다.
+9월 22일에는 오래된 시세로 주식 비중을 다시 늘리던 가능성을 막고, 모의·실전 계좌의 위험 판단 기록을 분리했습니다. 백테스트 계산도 같은 일별 결과를 유지하면서 빨라졌습니다. [최신 검증과 자료 기준일](docs/RISK_REVIEW_20260922.md), [운용 규칙을 변경한 근거](docs/RISK_REVIEW_20260917.md)에서 결과와 한계를 확인할 수 있습니다.
## 시작하기
@@ -101,14 +110,14 @@ Copy-Item .env.example .env
.\.venv\Scripts\python.exe main.py --mode rebalance --basket kr_pocket
# 같은 조건으로 백테스트 재현
-.\.venv\Scripts\python.exe tools/risk_review.py --as-of 2026-09-17
+.\.venv\Scripts\python.exe tools/risk_review.py --as-of 2026-09-22
```
매일 자동 실행하려면 `main.py --mode schedule`을 사용합니다. KIS API 키 등 개인 설정은 `.env`에 넣고 Git에는 올리지 않습니다. 대시보드는 계좌 정보를 표시하므로 기본 설정대로 이 PC에서만 접속해 사용합니다.
## 자세히 보기
-- [위험 관리 검증과 한계](docs/RISK_REVIEW_20260917.md)
-- [화면 설계·한글 글꼴·성능 측정](docs/DASHBOARD_REVIEW_20260917.md)
+- [위험 관리 검증과 한계](docs/RISK_REVIEW_20260922.md)
+- [화면 설계·참고한 디자인·성능 측정](docs/DESIGN_20260922.md)
- [모의투자 평가 기준](docs/BASKET_PAPER_EVALUATION.md) · [실전 전환 절차](docs/PAPER_TO_LIVE_RUNBOOK.md)
- [거래 안전장치](docs/SAFETY_MODEL.md) · [프로젝트 구조](docs/PROJECT_GUIDE.md)
diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py
index 756486af..c7643a34 100644
--- a/core/basket_rebalancer.py
+++ b/core/basket_rebalancer.py
@@ -195,10 +195,14 @@ def overlay_decision(self):
self._overlay_decision = None
if not self._overlay_cfg.any_enabled:
return None
- if getattr(self, "_overlay_decision", None) is not None:
+ evaluation_date = datetime.now(_KST).date()
+ if (getattr(self, "_overlay_decision", None) is not None
+ and getattr(self, "_overlay_evaluation_date", None) == evaluation_date):
return self._overlay_decision
- prev = load_overlay_state(self.basket_name)
+ self._overlay_input_issues = []
+ self._overlay_source_dates = {}
+ prev = load_overlay_state(self.basket_name, mode=self._ledger_mode())
index_closes = None
if self._overlay_cfg.trend.enabled:
index_closes = self._fetch_index_closes(
@@ -215,8 +219,10 @@ def overlay_decision(self):
daily_returns=daily,
prev_state=prev,
)
+ decision.data_issues.extend(self._overlay_input_issues)
+ decision.source_dates = dict(self._overlay_source_dates)
try:
- save_overlay_state(self.basket_name, decision)
+ save_overlay_state(self.basket_name, decision, mode=self._ledger_mode())
except OSError as exc:
logger.warning("바스켓 '{}' 오버레이 상태 저장 실패: {}", self.basket_name, exc)
if decision.data_issues:
@@ -226,8 +232,61 @@ def overlay_decision(self):
else:
logger.info("바스켓 '{}' 리스크 오버레이 계산 완료 (배수 {})", self.basket_name, decision.scale)
self._overlay_decision = decision
+ self._overlay_evaluation_date = evaluation_date
return decision
+ def _overlay_previous_session(self):
+ """한국 거래일 기준 직전 완료 일봉. 주말·설정된 휴장일은 건너뛴다."""
+ from core.trading_hours import TradingHours
+
+ calendar = TradingHours(self.config)
+ day = datetime.now(_KST) - timedelta(days=1)
+ for _ in range(31):
+ if calendar.is_trading_day(day):
+ return day.date()
+ day -= timedelta(days=1)
+ raise ValueError("직전 거래일을 확인할 수 없습니다")
+
+ def _overlay_data_issue(self, message: str):
+ if not hasattr(self, "_overlay_input_issues"):
+ self._overlay_input_issues = []
+ self._overlay_input_issues.append(message)
+
+ def _overlay_dated_frame(self, frame, source: str):
+ """날짜 오류·중복·오래된 자료는 버림 없이 전체 입력을 보류한다."""
+ import pandas as pd
+
+ try:
+ values = frame["date"] if "date" in frame.columns else frame.index
+ if "date" not in frame.columns and not isinstance(frame.index, pd.DatetimeIndex):
+ raise ValueError("날짜 열 없음")
+ dates = pd.to_datetime(values, errors="coerce")
+ parsed = [
+ (value.tz_convert(_KST) if value.tzinfo is not None else value).date()
+ if not pd.isna(value) else None
+ for value in dates
+ ]
+ if any(value is None for value in parsed):
+ raise ValueError("날짜를 읽지 못한 행 있음")
+ today = datetime.now(_KST).date()
+ keep = [value < today for value in parsed]
+ result = frame.loc[keep].copy()
+ result["_overlay_date"] = [value for value in parsed if value < today]
+ result = result.sort_values("_overlay_date")
+ if result["_overlay_date"].duplicated().any():
+ raise ValueError("같은 날짜의 기록이 둘 이상 있음")
+ expected = self._overlay_previous_session()
+ latest = result["_overlay_date"].iloc[-1] if not result.empty else None
+ if not hasattr(self, "_overlay_source_dates"):
+ self._overlay_source_dates = {}
+ self._overlay_source_dates[source] = str(latest) if latest else None
+ if latest != expected:
+ raise ValueError(f"최근 기록 {latest or '없음'}, 필요한 기준일 {expected}")
+ return result
+ except (ValueError, TypeError, OverflowError) as exc:
+ self._overlay_data_issue(f"{source}: {exc}. 자료 확인 전 비중 확대 보류")
+ return None
+
def _fetch_index_closes(self, symbol: str, ma_days: int) -> list[float] | None:
"""추세 판단용 지수 종가(오래된 순). 오늘 날짜 봉은 제외 — 전일까지의 정보만."""
start, end = self._recent_range(int(ma_days * 1.7) + 30)
@@ -238,26 +297,8 @@ def _fetch_index_closes(self, symbol: str, ma_days: int) -> list[float] | None:
return None
if df is None or df.empty or "close" not in df.columns:
return None
- frame = df.copy()
- today = datetime.now(_KST).date()
- dates = None
- if "date" in frame.columns:
- dates = frame["date"]
- elif hasattr(frame.index, "date"):
- dates = frame.index.to_series()
- if dates is not None:
- try:
- import pandas as pd
- parsed = pd.to_datetime(dates).dt.date if hasattr(pd.to_datetime(dates), "dt") else pd.to_datetime(dates).date
- mask = [d < today for d in parsed]
- frame = frame[mask].copy()
- frame["_overlay_date"] = [d for d, keep in zip(parsed, mask) if keep]
- frame = frame.sort_values("_overlay_date")
- if frame["_overlay_date"].duplicated().any():
- return None
- except Exception:
- return None
- else:
+ frame = self._overlay_dated_frame(df, "지수 종가")
+ if frame is None:
return None
# 누락 봉을 삭제하면 이동평균 창이 과거로 밀려 잘못 복귀할 수 있다.
try:
@@ -278,11 +319,9 @@ def _nav_series_for_overlay(self) -> tuple[list[float] | None, list[float] | Non
return None, None
if snaps is None or getattr(snaps, "empty", True) or "cumulative_return" not in snaps.columns:
return None, None
- if "date" in snaps.columns:
- import pandas as pd
- snaps = snaps.copy()
- snaps["date"] = pd.to_datetime(snaps["date"], errors="coerce")
- snaps = snaps[snaps["date"].dt.date < datetime.now(_KST).date()].sort_values("date")
+ snaps = self._overlay_dated_frame(snaps, "계좌 기록")
+ if snaps is None:
+ return None, None
try:
cumulative = [float(v) if v is not None else float("nan") for v in snaps["cumulative_return"].tolist()]
except (TypeError, ValueError):
diff --git a/core/risk_overlays.py b/core/risk_overlays.py
index 99a5e5ea..d0b02ad8 100644
--- a/core/risk_overlays.py
+++ b/core/risk_overlays.py
@@ -71,6 +71,7 @@ class OverlayDecision:
vol_scale: float | None = None
realized_vol: float | None = None
evaluated_at: str = ""
+ source_dates: dict[str, str | None] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@@ -229,7 +230,7 @@ def compute_decision(
prev_state: dict[str, Any] | None = None,
now: datetime | None = None,
) -> OverlayDecision:
- """설정된 오버레이를 모두 평가해 배수 하나로 합친다(곱)."""
+ """설정한 결합 방식에 따라 하나의 위험 배수를 계산한다."""
prev = prev_state or {}
decision = OverlayDecision(evaluated_at=(now or datetime.now()).isoformat(timespec="seconds"))
scale = 1.0
@@ -358,21 +359,49 @@ def overlay_state_path(basket_name: str, state_dir: str | os.PathLike | None = N
return overlay_state_dir(state_dir) / f"{safe}.json"
-def load_overlay_state(basket_name: str, state_dir: str | os.PathLike | None = None) -> dict[str, Any] | None:
- path = overlay_state_path(basket_name, state_dir)
+def load_overlay_state(
+ basket_name: str, state_dir: str | os.PathLike | None = None, *, mode: str | None = None,
+) -> dict[str, Any] | None:
+ directory = overlay_state_dir(state_dir)
+ if mode is not None and mode not in {"paper", "live"}:
+ raise ValueError("위험 관리 기록의 모드는 paper 또는 live여야 합니다")
+ path = overlay_state_path(basket_name, directory / mode if mode else directory)
+ # 이전 버전의 공용 기록은 모의투자만 이어받는다. 실전 계좌로 전파하지 않는다.
+ if mode == "paper" and not path.exists():
+ path = overlay_state_path(basket_name, directory)
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
- return data if isinstance(data, dict) else None
+ if not isinstance(data, dict):
+ return None
+ try:
+ scale = float(data["scale"])
+ if not math.isfinite(scale) or not 0 <= scale <= 1:
+ return None
+ except (KeyError, TypeError, ValueError):
+ return None
+ for key in ("trend_below", "drawdown_active"):
+ if data.get(key) is not None and not isinstance(data[key], bool):
+ return None
+ for key in ("reasons", "data_issues"):
+ if key in data and (not isinstance(data[key], list)
+ or any(not isinstance(item, str) for item in data[key])):
+ return None
+ data["scale"] = scale
+ return data
def save_overlay_state(
basket_name: str, decision: OverlayDecision, state_dir: str | os.PathLike | None = None,
+ *, mode: str | None = None,
) -> Path:
- path = overlay_state_path(basket_name, state_dir)
+ directory = overlay_state_dir(state_dir)
+ if mode is not None and mode not in {"paper", "live"}:
+ raise ValueError("위험 관리 기록의 모드는 paper 또는 live여야 합니다")
+ path = overlay_state_path(basket_name, directory / mode if mode else directory)
path.parent.mkdir(parents=True, exist_ok=True)
payload = decision.to_dict()
payload["basket"] = str(basket_name)
diff --git a/docs/DESIGN_20260922.md b/docs/DESIGN_20260922.md
new file mode 100644
index 00000000..937367b7
--- /dev/null
+++ b/docs/DESIGN_20260922.md
@@ -0,0 +1,102 @@
+# 눈금: 투자 기록을 재는 도구
+
+2026년 9월 22일 개편의 설계 기준입니다. 계좌를 읽는 도구라는 목적과 기존 HTML/CSS/JavaScript·aiohttp 구조를 유지합니다.
+
+## 방향
+
+흰 카드가 반복되던 화면을 하나의 계측 화면과 기록 영역으로 나눕니다. 눈금, 기준선, 선택일 표시가 같은 조작을 설명하게 합니다. 대표 시각 요소는 **실제 시간가중수익률로 그리는 입체 띠**입니다. 입금 때문에 자산이 갑자기 늘어난 것을 투자 성과처럼 연출하지 않습니다. 입체감은 같은 수익률 선에 폭을 준 표현이며, 숨겨진 제3의 지표가 아닙니다. 평면 보기와 원본 표를 함께 제공합니다.
+
+```
+눈금 계좌 / 보유 종목 / 검증 / 운영 연결 · 적립
+나의 투자 기록 마지막 자산 기준일
+계좌 선택
+┌ 선택일 수익률 ───────── 실제 수익률의 입체 궤적 ────────────┐
+│ 평가금액 / 원금 기준선, 날짜, 입금 표시 평면 전환 │
+│ ← 날짜 탐색 눈금 → │
+└ 월별 수익률 ─────────────────────────────────────────────┘
+최신 총자산 원금 수익률 최대 낙폭
+보유 종목·원본 기록 자산 구성·비중 판단 근거
+모의투자 검증 확인할 일
+운영 기록
+```
+
+## 색과 글자
+
+색상값은 IBM Carbon의 공개 색상표를 사용하고 역할을 분리합니다. 색상표를 쓰는 것과 컴포넌트를 복사하는 것은 구분합니다. 화면 구조와 입체 날짜 탐색은 눈금의 계좌 기록에 맞춰 직접 구현했습니다.
+
+| 역할 | 색상 | 참고 규격 |
+|---|---|---|
+| 페이지 | `#FFFFFF` | White |
+| 보조 면 | `#F4F4F4` | Gray 10 |
+| 본문·주요 버튼 | `#161616` | Gray 100 |
+| 선택·링크·로고 | `#0F62FE` | Blue 60 |
+| 주요 차트 바탕 | `#002D9C` | Blue 80 |
+| 차트 선·선택한 기록 | `#82CFFF`, `#FFFFFF` | Cyan 30, White |
+| 상승·하락 수치 | `#DA1E28`, `#0072C3` | Red 60, Cyan 60 |
+
+어두운 차트의 손익 수치는 더 밝은 Red 30·Cyan 20으로 바꿉니다. 입체 면의 명암은 데이터의 추가 변수가 아닌 조명 표현입니다. 양수·음수 기호를 함께 표시합니다. 안내는 회색·파랑, 연결 실패나 거래 중지 알림은 회색 면과 빨간 선으로 구분합니다. 중요한 장애 알림은 모바일에서도 페이지 위쪽에 표시합니다.
+
+한글은 기존 로컬 Nungum UI 글꼴을 유지합니다. 제목 30~40px, 주요 성과 숫자 37~66px, 본문 14~15px, 표와 좁은 화면의 보조 표기는 11~13px입니다. 숫자는 같은 폭으로 정렬합니다. 글꼴과 연출용 외부 요청은 없습니다.
+
+## 조작과 연출
+
+- 날짜 슬라이더·차트 포인터·키보드로 동일한 날짜를 선택합니다. 수익률, 평가금액, 원금, 낙폭이 함께 바뀝니다.
+- 입체·평면 전환은 좌표계를 바꾸는 짧은 전환 한 번으로 보여줍니다. 상시 재생하는 영상이나 회전 물체를 두지 않습니다.
+- 계좌 변경 중에는 이전 계좌의 차트와 새 계좌의 이름이 섞이지 않도록 합니다.
+- 최신 계좌 요약과 선택한 과거 날짜의 수치를 구분합니다.
+- 작은 화면에서는 선택일 숫자, 차트, 날짜 조작 순서로 배치합니다. 동작 줄이기 설정에서는 전환을 생략합니다.
+
+## 화면의 차별성을 두는 곳
+
+색의 역할은 공개된 전문 디자인 규격을 따릅니다. 눈금의 차별성은 실제 날짜를 고르는 눈금, 같은 기록을 입체와 평면으로 바꾸는 조작, 원금과 수익을 분리하는 데이터 구성에 둡니다. 꾸밈용 영문이나 수익을 과장하는 문구는 넣지 않습니다.
+
+## 참고 자료
+
+- [IBM Carbon 색상 가이드, 2026-08-12 갱신](https://preview.carbondesignsystem.com/building-blocks/foundations/color/overview): 중성 회색의 단계, 배경별 글자 대비, 색의 역할.
+- [Carbon 데이터 시각화 색상표, 2026-09-09 갱신](https://carbondesignsystem.com/data-visualization/color-palettes/): Blue·Cyan·Red의 실제 HEX 값. 신규 유행 자료가 아닌 현재 공개된 색상 규격입니다.
+- [Pentagram — PayPal](https://www.pentagram.com/work/paypal): 흑백 중심의 구조와 파랑 강조, 실제 지불 동작을 출발점으로 한 모션. 기존 디자인 사례이며 2026년 신작이라고 소개하지 않습니다.
+- [2026 Webby 시각 디자인 수상작](https://winners.webbyawards.com/winners/websites-and-mobile-sites/features-design/best-visual-design-aesthetic): Igloo Inc 등의 수상 여부 확인. 수상작 하나를 전체 웹 디자인의 유행이라고 단정하지 않습니다.
+
+
+- [Webby 2026 데이터 시각화 수상작](https://winners.webbyawards.com/winners/websites-and-mobile-sites/features-design/best-data-visualization): 실제 데이터를 탐색하게 만드는 화면 구성 사례.
+- [Teenage Engineering OP–XY 조작 체계](https://teenage.engineering/guides/op-xy/layout): 표시와 조작의 대응 관계를 참고했습니다. 제품 외형을 복제하지 않습니다.
+- [web.dev 애니메이션 성능](https://web.dev/articles/animations-and-performance): 합성 가능한 짧은 전환과 불필요한 재계산 억제.
+- [web.dev Canvas 성능](https://web.dev/articles/canvas-performance): 반복 도형의 그리기 횟수와 해상도를 제한하는 구현 원칙. 오래된 원리 문서이며 2026년 새 기능으로 소개하지 않습니다.
+
+## 실제 화면
+
+
+
+
+
+## 화면·조작 검증
+
+2026-09-22 로컬 모의투자 기록으로 확인했습니다. 운영 계좌에 주문이나 적립을 추가하지 않았습니다.
+
+- 320·390·768·1440px에서 페이지 가로 넘침 없음. 입체·평면 버튼 높이는 36px 이상.
+- 날짜 슬라이더 Home·방향키·최근 날짜 복귀, 기간 변경, 두 계좌 전환 확인.
+- ETF 계좌 51개 일별 기록을 CSV로 내려받아 행 수·첫 날짜·원금·수익률 확인.
+- 기록이 없으면 빈 상태, 잘못된 금액이나 적립금 조회 실패 시 오류 표시 및 내려받기 제한.
+- 운영 상태 조회 실패 시 상단 알림과 적립 기록 제한 확인. 정상 상태에 경고 색을 사용하지 않음.
+- `prefers-reduced-motion`에서 입체·평면 전환 생략. 일별 표와 낙폭 차트로 같은 기록 확인.
+- 실제 화면의 브라우저 경고·오류 0건. 오류 상황은 별도 탭에서 응답을 바꿔 확인.
+
+초기 검토에서 작은 화면을 바꾼 직후의 캡처가 Canvas 크기 갱신보다 빨랐습니다. 크기 변경 후 다시 그려지는 것을 확인하고 최종 캡처를 교체했습니다. 일반 안내까지 빨갛게 표시되던 부분과 버튼 높이도 함께 수정했습니다.
+
+## 성능 측정
+
+Playwright에서 Chrome DevTools Protocol과 PerformanceObserver로 측정했습니다. 별도 DevTools 프로필이 사용 중이라 기존 Playwright 브라우저로 진행했습니다. Chromium 153.0.8010.53, 390×844px, CPU 4배 감속, 지연 150ms, 다운로드 200,000B/s, 캐시 비활성화 조건의 로컬 서버 단일 측정입니다.
+
+| 항목 | 결과 |
+|---|---:|
+| LCP | 1.16초 |
+| CLS | 0.0133 |
+| 입체·평면 및 키보드 조작의 최대 Event Timing | 56ms |
+| 조작 없는 1초 동안 새 rAF 요청 | 0회 |
+| CSS 전송량 / 원본 | 약 7.4 / 34.2KiB |
+| JS 전송량 / 원본 | 약 24.0 / 85.9KiB |
+| 로컬 한글 글꼴 | 약 431KiB |
+
+새 렌더링 라이브러리·외부 글꼴·영상·3D 모델 요청은 없습니다. 차트는 최대 약 420개 선분으로 그리고, 날짜 변경은 프레임당 한 번, 보기 전환은 360ms 동안만 그립니다. 숨은 탭의 주기 조회는 기존 정책대로 쉬게 했습니다. 전체 이력은 조회 주기에 한 번 받아 계좌·기간 변경에 재사용합니다.
+
+이 값은 실제 사용자 집계인 INP나 모든 기기의 무지연 보장이 아닙니다. 글꼴이 초기 전송량의 대부분이며, 감속 조건에서 초기 로딩 중 125·99·346ms의 긴 작업이 관찰됐습니다. 연출을 쓰지 않을 때 계속 CPU를 점유하지 않는지 별도로 확인했습니다.
diff --git a/docs/RISK_REVIEW_20260922.md b/docs/RISK_REVIEW_20260922.md
new file mode 100644
index 00000000..6ee7c569
--- /dev/null
+++ b/docs/RISK_REVIEW_20260922.md
@@ -0,0 +1,69 @@
+# 위험 관리 보완과 재검증
+
+검증일 **2026-09-22** · 비교에 사용한 마지막 공통 종가 **2026-09-17**
+
+이번에는 위험 비중을 결정하는 자료와 계좌 구분을 보완하고 계산 시간을 줄였습니다. 9월 17일 정한 추세·낙폭 임계값과 자산 구성은 유지했습니다. 수익률을 높이려고 새 매매 조건을 반복 탐색한 결과가 아닙니다.
+
+## 고친 문제
+
+- **오래된 자료로 비중을 늘릴 수 있었습니다.** 지수와 계좌 기록의 마지막 날짜가 한국 거래일 기준 직전 거래일인지 확인합니다. 주말과 설정된 휴장일은 제외합니다. 오래되거나 날짜가 잘못된 자료는 위험 비중 확대의 근거로 쓰지 않고, 화면에 판단 보류 사유를 표시합니다.
+- **손실 행을 삭제한 채 계산할 가능성을 막았습니다.** 날짜 오류·중복이 있으면 해당 입력 전체를 보류합니다. 당일·미래 기록은 신호 계산에서 제외합니다. 사용한 자료 날짜도 판단 기록에 남깁니다.
+- **모의투자와 실전의 판단 기록을 분리했습니다.** 위험 관리 상태 파일을 `paper`·`live`별로 저장합니다. 이전 공용 파일은 모의투자만 이어받고, 잘못된 배수·상태는 읽지 않습니다.
+- **날짜가 바뀌면 다시 판단합니다.** 동일 프로세스에서 전날 계산 결과가 다음 날까지 남지 않게 했습니다. 같은 날의 주문 계획과 실행은 같은 판단을 사용합니다.
+- **연구 자료의 마지막 날짜를 맞춥니다.** 세 자료가 모두 있는 날까지만 비교합니다. 기간 중 누락은 이전 값으로 채우지 않고 검증을 중단합니다. 기준일을 바꾸면 결과 파일 이름도 해당 날짜로 바뀝니다.
+
+거래일 판단은 프로젝트의 휴장일 설정에 의존합니다. 발표된 임시 휴장일이 설정에 빠지면 자료 확인을 보류할 수 있습니다. 손실 자체를 없애는 장치는 아니며, ETF 적립은 계속 모의투자 전용입니다.
+
+## 9월 22일 자료 상태
+
+| 자료 | 실제 제공된 마지막 날짜 | 비교에 사용한 마지막 날짜 | 뒤쪽에서 제외한 거래일 |
+|---|---|---|---:|
+| KODEX 200 `069500` | 9월 21일 | 9월 17일 | 2일 |
+| TIGER CD금리 ETF `357870` | 9월 21일 | 9월 17일 | 2일 |
+| 코스피200 `KS200` | 9월 17일 | 9월 17일 | 0일 |
+
+조회한 날짜가 22일이라는 이유로 21일까지 검증됐다고 표시하지 않습니다. 자료별 날짜·사용 행 수·SHA-256은 [원본 결과](../reports/research/risk_review_20260922.json)에 있습니다.
+
+## 같은 조건으로 다시 비교
+
+2020-07-07~2026-09-17, 시작 30만원, 월 10만원 적립, 1주 단위, 최소 주문 5만원. 수수료·슬리피지 및 CD ETF 양의 매매차익에 대한 보수적 세금 근사를 포함합니다.
+
+| 방식 | 연환산 수익률 | 최대 낙폭 | 샤프 | 비용 3배 연환산 수익률 |
+|---|---:|---:|---:|---:|
+| 고정 비중 | 13.53% | −21.72% | 0.79 | 13.41% |
+| 기존 위험 관리 | 10.73% | −16.02% | 0.74 | 10.27% |
+| 9월 17일 변경한 위험 관리 | 12.96% | −15.74% | 0.90 | 12.67% |
+
+
+
+기존 방식도 적립금 계산 오류를 고친 상태로 비교했습니다. 변경한 규칙의 전체 기간 낙폭은 작았지만 고정 비중보다 수익률이 낮았습니다. 2023~2025년에는 기존 방식보다 최대 낙폭이 컸습니다(−8.80%, 기존 −7.86%). 이 비교는 이미 살펴본 자료로 하는 사후 검증이며 앞으로의 수익을 보장하지 않습니다. 분배금·실제 호가·괴리율·미체결은 재현하지 못했습니다. 자세한 가정은 [9월 17일 보고서](RISK_REVIEW_20260917.md)에 있습니다.
+
+## 계산 속도
+
+매일 pandas 행과 전체 과거 이력을 새로 만들던 부분을 바꿨습니다. 종가는 한 번 배열로 읽고, 추세 판단에는 필요한 이동평균 기간만 전달합니다.
+
+Windows Python 3.12.10에서 기준 커밋 `9a7c145`와 비교했습니다. 같은 2026-09-17 가격 캐시를 사용한 **1,520거래일 결과 전체**를 매회 정확히 대조했고, 각 정책을 세 번씩 실행했습니다. 단위는 실행 시간의 중앙값입니다.
+
+| 방식 | 수정 전 | 수정 후 | 속도 비율 |
+|---|---:|---:|---:|
+| 고정 비중 | 234ms | 54ms | 4.30배 |
+| 기존 위험 관리 | 278ms | 90ms | 3.09배 |
+| 변경한 위험 관리 | 271ms | 78ms | 3.45배 |
+
+세 방식 모두 모든 날짜의 자산·현금·수량·수익률·낙폭·비용·위험 배수가 정확히 같았습니다. 이 속도 개선을 수익률 개선으로 표시하지 않습니다. [측정 원본](../reports/research/implementation_benchmark_20260922.json)과 [재현 스크립트](../tools/benchmark_risk_review.py)를 제공합니다.
+
+## 확인한 자료와 적용 범위
+
+- [AQR, Inflation Redux?, 2026-09-15](https://www.aqr.com/Insights/Research/Alternative-Thinking/Inflation-Redux): 인플레이션 불확실성, 주식·채권의 동반 손실과 구현 방식에 따른 차이를 다룹니다. 이를 국내 ETF의 특정 매수 시점이나 수익 보장 근거로 옮기지 않았습니다.
+- [scikit-learn TimeSeriesSplit](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html): 시간 순서를 지켜 미래 자료가 과거 판단에 들어가지 않도록 하는 평가 원칙을 재확인했습니다. 새 모델이나 의존성을 추가하지 않았습니다.
+- 자산 배분·비용·TWR 관련 기존 근거와 그 한계는 [9월 17일 조사 기록](RISK_REVIEW_20260917.md#2026년-자료를-어떻게-반영했나)에 정리했습니다.
+
+## 재현
+
+```powershell
+.\.venv\Scripts\python.exe tools/risk_review.py --as-of 2026-09-22
+.\.venv\Scripts\python.exe tools/benchmark_risk_review.py --baseline 9a7c145 --as-of 2026-09-17
+.\.venv\Scripts\python.exe -m pytest tests/test_risk_review_data.py tests/test_basket_overlay_integration.py tests/test_risk_overlays.py tests/test_risk_overlay_backtest.py -q
+```
+
+데이터 공급자의 정정과 캐시 유무에 따라 재수집 값이 달라질 수 있으므로 결과 파일의 해시를 함께 확인합니다. 이 검증은 주문을 실행하지 않습니다.
diff --git a/docs/images/dashboard-account-20260922.png b/docs/images/dashboard-account-20260922.png
new file mode 100644
index 0000000000000000000000000000000000000000..e953519e7fa56291830222bfd95b4c2e64e13649
GIT binary patch
literal 153269
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