diff --git a/README.md b/README.md index f747306..8579796 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,15 @@ 정해 둔 비중에 맞춰 주식과 ETF를 사고팔고, 원금·수익률·보유 종목을 한 화면에서 확인하는 개인용 투자 도구입니다. 과거 데이터로 운용 규칙을 비교하고, 모의투자로 실제 동작을 점검할 수 있습니다. -![눈금의 계좌 현황 화면 — 총자산, 투자원금, 수익률과 자산 추이](docs/images/dashboard-account-20260917.png) +![날짜 눈금으로 살펴보는 눈금의 실제 투자 기록](docs/images/dashboard-account-20260922.png) -*2026년 9월 17일 모의투자 기록을 불러온 실제 화면입니다. 아래 백테스트와는 별도의 계좌 기록입니다.* +*2026년 9월 22일 모의투자 기록을 불러온 실제 화면입니다. 아래 백테스트와는 별도의 계좌 기록입니다.* ## 할 수 있는 일 - **계좌 확인** — 총자산, 투자원금, 현금, 보유 종목과 목표 비중을 확인합니다. -- **성과 확인** — 적립금을 수익에서 제외한 수익률, 자산 추이, 고점 대비 하락률을 봅니다. +- **날짜별 성과 탐색** — 날짜 눈금을 움직이면 그날의 수익률·원금·평가금액이 함께 바뀝니다. 적립금은 수익에서 제외합니다. +- **입체·평면 차트와 CSV** — 같은 투자 기록을 두 방식으로 살펴보고, 조회한 기간의 원본 수치를 내려받습니다. - **자동 리밸런싱** — 실제 비중이 설정 범위를 벗어나면 조정합니다. 최소 주문금액과 1주 단위를 반영합니다. - **모의투자 검증** — 운영 기록, 누락된 자산 기록, 거래비용과 미해결 주문을 확인합니다. - **운영 점검** — 자동매매 실행 여부, 거래 중지 상태, 증권사 연결과 데이터 갱신 상태를 봅니다. @@ -22,11 +23,17 @@ Python·aiohttp·SQLite·한국투자증권 KIS API를 사용합니다. 화면 ## 화면 +### 날짜를 고르면 그날의 계좌가 보입니다 + +파란 차트의 띠는 실제 누적 수익률입니다. 날짜 눈금은 마우스·터치·방향키로 움직일 수 있습니다. 입체감은 선에 폭을 준 표현이며, 별도의 지표를 뜻하지 않습니다. 정확한 값은 평면 차트와 일별 기록 표에서도 확인할 수 있습니다. + +한글 글꼴은 로컬에서 읽고, 입체 표현은 가벼운 Canvas로 그립니다. 흰색·검정·코발트·시안의 역할과 참고한 실제 디자인 사례는 [화면 설계와 검증 기록](docs/DESIGN_20260922.md)에 정리했습니다. + ### 보유 종목과 비중 매입금액과 목표 비중을 나란히 보여 줍니다. 계좌를 바꾸면 요약, 종목표, 차트, 적립금 입력 대상이 함께 바뀝니다. -![대형주 분산 투자 계좌의 보유 종목](docs/images/dashboard-holdings-20260917.png) +![대형주 분산 투자 계좌의 보유 종목과 투자 비중의 기준](docs/images/dashboard-holdings-20260922.png) ### 모바일과 운영 상태 @@ -35,14 +42,14 @@ Python·aiohttp·SQLite·한국투자증권 KIS API를 사용합니다. 화면
모바일 화면 보기 -모바일 계좌 현황 — 잔액, 현금, 원금, 수익률 +모바일 투자 기록 — 날짜 탐색, 원금, 수익률, 보유 종목과 운영 상태
자동매매 상태 화면 보기 -![거래 안전, 장 상태, 자동매매와 데이터 갱신 확인](docs/images/dashboard-operations-20260917.png) +![거래 안전, 장 상태, 자동매매와 데이터 갱신 확인](docs/images/dashboard-operations-20260922.png)
@@ -61,21 +68,23 @@ ETF 적립의 기본 목표는 주식 ETF 47.5%·CD금리 ETF 47.5%·현금 5% 적립금이 들어오면 낙폭이 작아 보이던 백테스트 계산을 고쳤습니다. 이어서 추세·낙폭 조건이 겹칠 때 비중을 중복해서 줄이지 않고, 주식 축소분을 기존 CD금리 ETF로 옮기도록 바꿨습니다. -![동일 기간·비용으로 비교한 세 가지 운용 방식의 수익과 낙폭](docs/images/risk-review-20260917.png) +![동일 기간·비용으로 비교한 세 가지 운용 방식의 수익과 낙폭](docs/images/risk-review-20260922.png) + +**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 756486a..c7643a3 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 99a5e5e..d0b02ad 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 0000000..937367b --- /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년 새 기능으로 소개하지 않습니다. + +## 실제 화면 + +![실제 ETF 적립 계좌와 날짜별 수익률](images/dashboard-account-20260922.png) + +![보유 종목과 목표 비중](images/dashboard-holdings-20260922.png) + +## 화면·조작 검증 + +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 0000000..6ee7c56 --- /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% | + +![세 운용 방식의 수익률과 낙폭](images/risk-review-20260922.png) + +기존 방식도 적립금 계산 오류를 고친 상태로 비교했습니다. 변경한 규칙의 전체 기간 낙폭은 작았지만 고정 비중보다 수익률이 낮았습니다. 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 0000000..e953519 Binary files /dev/null and b/docs/images/dashboard-account-20260922.png differ diff --git a/docs/images/dashboard-holdings-20260922.png b/docs/images/dashboard-holdings-20260922.png new file mode 100644 index 0000000..e15294f Binary files /dev/null and b/docs/images/dashboard-holdings-20260922.png differ diff --git a/docs/images/dashboard-mobile-20260922.png b/docs/images/dashboard-mobile-20260922.png new file mode 100644 index 0000000..4f0ace2 Binary files /dev/null and b/docs/images/dashboard-mobile-20260922.png differ diff --git a/docs/images/dashboard-operations-20260922.png b/docs/images/dashboard-operations-20260922.png new file mode 100644 index 0000000..5b6c4ee Binary files /dev/null and b/docs/images/dashboard-operations-20260922.png differ diff --git a/docs/images/risk-review-20260922.png b/docs/images/risk-review-20260922.png new file mode 100644 index 0000000..dd0de05 Binary files /dev/null and b/docs/images/risk-review-20260922.png differ diff --git a/monitoring/static/dashboard.css b/monitoring/static/dashboard.css index 81d2162..09c1422 100644 --- a/monitoring/static/dashboard.css +++ b/monitoring/static/dashboard.css @@ -1,4 +1,4 @@ -/* 한글·숫자는 같은 로컬 글꼴로 표시한다. 차트는 입력과 데이터 변경 때만 그린다. */ +/* 눈금: 실제 기록을 탐색하는 계측 화면. 외부 글꼴이나 애니메이션 라이브러리 없음. */ @font-face { font-family: "Nungum UI"; src: url("/static/fonts/nungum-ui.woff2") format("woff2"); @@ -6,37 +6,39 @@ font-display: swap; } :root { - --paper: #f5f6f8; - --surface: #fff; - --ink: #202b3d; - --ink-soft: #596579; - --ink-faint: #667185; - --rule: #e8ebf0; - --rule-strong: #cbd1dd; - --accent: #3157a4; - --accent-bg: #edf2fb; - --up: #c4383c; - --down: #2563c7; - --warn: #916018; - --stop: #b83232; - --pill-bg: #263d64; + --paper: #ffffff; + --surface: #f4f4f4; + --ink: #161616; + --ink-soft: #525252; + --ink-faint: #6f6f6f; + --rule: #e0e0e0; + --rule-strong: #c6c6c6; + --accent: #0f62fe; + --accent-bg: #edf5ff; + --up: #da1e28; + --down: #0072c3; + --warn: #8a3800; + --stop: #da1e28; + --pill-bg: #161616; --pill-fg: #fff; --font: "Nungum UI", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; - --rail: 204px; + --panel: #002d9c; + --signal: #82cfff; + --cursor: #ffffff; } * { box-sizing: border-box; } html { -webkit-text-size-adjust: 100%; - scroll-padding-top: 88px; + scroll-padding-top: 94px; } body { margin: 0; background: var(--paper); color: var(--ink); - font: 400 14px/1.55 var(--font); - letter-spacing: -0.015em; + font: 400 14px/1.6 var(--font); + letter-spacing: -0.018em; word-break: keep-all; overflow-wrap: anywhere; -webkit-font-smoothing: antialiased; @@ -102,7 +104,6 @@ button:disabled { color: white; background: var(--pill-bg); transform: translateY(-200%); - border-radius: 8px; } .skip-link:focus { transform: none; @@ -127,583 +128,920 @@ button:disabled { text-align: right; font-variant-numeric: tabular-nums; } - -/* 주 메뉴 */ -.rail { - position: fixed; - inset: 0 auto 0 0; - width: var(--rail); +.chrome { + position: sticky; + top: 0; z-index: 40; - background: var(--surface); - border-right: 1px solid var(--rule); - padding: 31px 18px 22px; + min-height: 78px; display: flex; - flex-direction: column; + align-items: center; + gap: 48px; + padding: 12px max(28px, calc((100vw - 1376px) / 2)); + background: var(--paper); + border-bottom: 1px solid var(--rule-strong); } .brand { - display: inline-flex; + display: flex; align-items: center; - gap: 10px; - padding-left: 14px; - font-size: 25px; + gap: 12px; + font-size: 27px; font-weight: 700; letter-spacing: -0.065em; text-decoration: none; + flex-shrink: 0; } .brand img { display: block; } .nav { display: flex; - flex-direction: column; - gap: 7px; - margin-top: 51px; -} -.nav > a { - display: flex; + gap: 30px; + align-self: stretch; align-items: center; - min-height: 46px; - gap: 12px; - padding: 10px 14px; - text-decoration: none; - border-radius: 8px; - color: var(--ink-soft); - font-weight: 500; -} -.nav > a > svg { - width: 19px; - height: 19px; - flex-shrink: 0; - fill: none; - stroke: currentColor; - stroke-width: 1.5; - stroke-linecap: round; - stroke-linejoin: round; -} -.nav > a:hover { - background: #f6f8fb; -} -.nav > a[aria-current] { - color: var(--accent); - background: var(--accent-bg); - font-weight: 650; } -.rail-bottom { - margin-top: auto; - padding: 20px 14px 0; - border-top: 1px solid var(--rule); - font-size: 12px; +.nav a { + position: relative; + padding: 11px 0; + font-size: 14px; color: var(--ink-soft); -} -.rail-caption { - display: block; - color: var(--ink-faint); - margin-bottom: 7px; -} -.rail-bottom a { text-decoration: none; - display: flex; - justify-content: space-between; - min-height: 32px; - align-items: center; -} -.chrome { - position: sticky; - top: 0; - margin-left: var(--rail); - z-index: 30; - min-height: 72px; - background: var(--surface); - border-bottom: 1px solid var(--rule); - padding: max(16px, env(safe-area-inset-top)) 36px 16px; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} -.location { - color: var(--ink-faint); - font-size: 12px; white-space: nowrap; } -.location strong { - color: var(--ink-soft); - font-weight: 500; - margin-left: 6px; +.nav a[aria-current] { + color: var(--ink); + font-weight: 700; +} +.nav a[aria-current]:after { + content: ""; + position: absolute; + width: 5px; + height: 5px; + border-radius: 50%; + background: var(--accent); + left: calc(50% - 2px); + bottom: 2px; } .header-actions { + margin-left: auto; display: flex; - gap: 18px; align-items: center; + gap: 16px; } .state { - display: flex; + display: inline-flex; align-items: center; - gap: 6px; + gap: 7px; + font-size: 12px; color: var(--ink-soft); - font-size: 11px; } .state time { - margin-left: 3px; - color: var(--ink-faint); + display: none; } .dot { - display: inline-block; width: 6px; height: 6px; + background: #8d8d8d; border-radius: 50%; - background: #718097; - flex-shrink: 0; + display: inline-block; } -.dot[data-state="ok"], -.dot[data-state="ready"] { - background: #32705d; +.dot[data-state="ready"], +.dot[data-state="ok"] { + background: #198038; } -.dot[data-state="warning"], -.dot[data-state="partial"] { - background: #b67c29; +.dot[data-state="partial"], +.dot[data-state="warning"] { + background: #b28600; } .dot[data-state="error"] { - background: var(--stop); + background: #da1e28; } .mode { - font-size: 11px; - font-weight: 600; - color: #506075; - background: #f0f3f7; - padding: 4px 8px; - border-radius: 5px; + border: 1px solid var(--rule-strong); + padding: 3px 8px; + border-radius: 3px; + font-size: 12px; white-space: nowrap; } .mode[data-mode="live"] { color: var(--stop); - background: #fff0ef; + border-color: var(--stop); } .pill { + min-height: 40px; display: inline-flex; align-items: center; justify-content: center; - min-height: 38px; - padding: 0 17px; - background: var(--pill-bg); + border: 1px solid var(--pill-bg); + border-radius: 6px; + padding: 0 18px; color: var(--pill-fg); - -webkit-text-fill-color: var(--pill-fg); - border: 1px solid transparent; - border-radius: 7px; + background: var(--pill-bg); font-weight: 600; - font-size: 13px; text-decoration: none; + transition: background 0.18s; white-space: nowrap; - transition: background 0.16s; } .pill:hover:not(:disabled) { - background: #182d4e; + background: #393939; } .pill-line { - background: white; + background: transparent; color: var(--ink); - -webkit-text-fill-color: var(--ink); border-color: var(--rule-strong); } .pill-line:hover:not(:disabled) { - background: var(--paper); + background: var(--accent-bg); } .link { - display: inline-flex; - align-items: center; - min-height: 36px; + background: none; border: 0; - background: transparent; - padding: 0; + padding: 8px 0; color: var(--accent); - text-decoration: none; - font-size: 12px; - font-weight: 600; -} -.link:hover { + font-size: 13px; text-decoration: underline; text-underline-offset: 4px; + min-height: 36px; } - -/* 계좌 요약 */ .page { - max-width: 1640px; - margin-left: var(--rail); - padding: 32px 36px 0; + max-width: 1432px; + margin: 0 auto; + padding: 40px 28px 0; } .page-heading { display: flex; - align-items: center; + align-items: flex-end; justify-content: space-between; - gap: 14px; - margin-bottom: 26px; + gap: 20px; + margin-bottom: 28px; +} +.page-kicker { + color: var(--ink-soft); + font-size: 13px; + margin-bottom: 7px; } h1 { - font-size: 26px; - line-height: 1.3; - font-weight: 700; - letter-spacing: -0.04em; + font-size: 40px; + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.06em; } -.page-heading p { - margin-top: 7px; - color: var(--ink-soft); - font-size: 14px; +.record-date { + text-align: right; + font-size: 13px; } -.page-heading time { +.record-date span { + display: block; color: var(--ink-faint); font-size: 12px; + margin-bottom: 4px; +} +.record-date time { + font-weight: 500; } .account-tabs { display: flex; - gap: 10px; - margin-bottom: 19px; + gap: 6px; + min-height: 60px; + margin-bottom: 15px; } .account-tabs > button { - flex: 1; - max-width: 330px; - min-width: 0; display: flex; - gap: 12px; - text-align: left; align-items: center; - background: #fff; - border: 1px solid var(--rule); - border-radius: 10px; - padding: 13px 16px; - font-weight: 600; - font-size: 13px; + gap: 13px; + text-align: left; + padding: 11px 20px; + background: transparent; + border: 1px solid transparent; + border-radius: 8px 8px 0 0; + min-width: 230px; + font-size: 15px; } .account-tabs > button[aria-pressed="true"] { - border-color: #718fca; - box-shadow: 0 0 0 1px #718fca; + background: var(--panel); + color: var(--surface); } .account-tabs small { display: block; - font-size: 11px; + font-size: 12px; + opacity: 0.78; margin-top: 2px; font-weight: 400; - color: var(--ink-faint); } .account-icon { - flex: 0 0 34px; - height: 34px; + width: 28px; + height: 28px; display: grid; place-items: center; + border: 1px solid currentColor; font-size: 11px; - border-radius: 10px; - color: #53627b; - background: #f0f3f7; -} -.account-tabs button[aria-pressed="true"] .account-icon { - background: var(--accent-bg); - color: var(--accent); + border-radius: 50%; + flex-shrink: 0; } .account-check { margin-left: auto; - color: var(--accent); - visibility: hidden; + opacity: 0; } .account-tabs button[aria-pressed="true"] .account-check { - visibility: visible; + opacity: 1; + color: var(--signal); +} +.instrument { + background: var(--panel); + color: #ffffff; + border-radius: 0 12px 12px 12px; + margin-top: -15px; + padding: 24px 30px 0; + position: relative; + box-shadow: none; + overflow: hidden; } -.overview { +.instrument:focus-within { + --focus: #ffffff; +} +.instrument :focus-visible { + outline-color: var(--cursor); +} +.instrument-head { display: flex; align-items: center; - background: var(--surface); - border: 1px solid var(--rule); - border-radius: 14px; - padding: 26px 30px; - gap: 30px; - margin-bottom: 22px; - min-height: 160px; + justify-content: space-between; + gap: 16px; } -.overview-total { - flex: 1.25; - min-width: 210px; +.instrument h2 { + font-size: 18px; + font-weight: 500; + letter-spacing: -0.035em; } -.overview-total > p { - color: var(--ink-soft); - font-size: 13px; +.instrument-head p { + font-size: 12px; + color: #d0e2ff; + margin-top: 3px; } -.overview-cash { - display: none; - margin-top: 8px; +.view-switch { + display: flex; + border: 1px solid #78a9ff; + border-radius: 5px; + padding: 3px; } -.overview-total > strong { - display: block; - font-size: clamp(29px, 2.7vw, 40px); +.view-switch button { + min-height: 36px; + min-width: 53px; + border: 0; + background: none; + color: #d0e2ff; + border-radius: 3px; + font-size: 12px; +} +.view-switch button[aria-pressed="true"] { + background: #ffffff; + color: #161616; font-weight: 600; - line-height: 1.45; - letter-spacing: -0.045em; - font-variant-numeric: tabular-nums; } -.overview-total > span { +.chart-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-top: 20px; +} +.seg { + display: flex; + gap: 4px; +} +.seg button { + min-height: 36px; + padding: 0 14px; + color: #d0e2ff; + border: 0; + border-bottom: 1px solid transparent; + background: none; + font-size: 13px; +} +.seg button[aria-pressed="true"] { + color: var(--cursor); + border-bottom-color: var(--cursor); +} +.chart-maturity { font-size: 12px; - display: block; - font-variant-numeric: tabular-nums; + color: #d0e2ff; } -.overview-stats { - flex: 2.5; +.instrument-body { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 26px; - padding-left: 32px; - border-left: 1px solid var(--rule); + grid-template-columns: 250px minmax(0, 1fr); + gap: 14px; + align-items: center; + min-height: 300px; } -.overview-stats dt { - color: var(--ink-soft); +.readout { + position: relative; + z-index: 1; + padding: 16px 0; +} +.readout-date { + display: flex; + align-items: center; + gap: 7px; font-size: 13px; } -.overview-stats abbr { +.readout-date i { + height: 5px; + width: 5px; + border-radius: 50%; + background: var(--cursor); +} +.readout-date span { + margin-left: 5px; font-size: 11px; - background: var(--paper); - color: var(--ink-soft); - padding: 2px 4px; - text-decoration: none; - border-radius: 3px; - cursor: help; + color: #d0e2ff; + border: 1px solid #78a9ff; + border-radius: 2px; + padding: 0 4px; } -.overview-stats dd { - font-size: clamp(20px, 1.85vw, 27px); - font-weight: 600; - margin-top: 6px; - line-height: 1.4; - letter-spacing: -0.035em; - font-variant-numeric: tabular-nums; - white-space: nowrap; +.readout-label { + margin-top: 18px; + font-size: 13px; + color: #d0e2ff; } -.overview-stats small { +.readout-return { display: block; - font-size: 11px; - color: var(--ink-faint); - margin-top: 6px; + font-size: clamp(40px, 4.4vw, 66px); + line-height: 1.14; + font-weight: 400; + letter-spacing: -0.065em; + font-variant-numeric: tabular-nums; + margin-top: 5px; } -.workspace { - display: grid; - grid-template-columns: minmax(0, 1fr) 256px; - gap: 22px; - align-items: start; +.readout .down { + color: #bae6ff; } -.main-column, -.side-column { - min-width: 0; +.readout .up { + color: #ffb3b8; } -.sec { - background: white; - border: 1px solid var(--rule); - border-radius: 14px; - padding: 23px 25px; - margin-bottom: 22px; - scroll-margin-top: 20px; - min-width: 0; +.readout .flat { + color: #ffffff; } -.sec-head { - display: flex; - align-items: baseline; +.readout dl { + margin-top: 19px; + max-width: 230px; +} +.readout dl > div { + display: flex; justify-content: space-between; + gap: 15px; + margin-top: 8px; + font-size: 13px; +} +.readout dt { + color: #d0e2ff; +} +.readout dd { + font-variant-numeric: tabular-nums; +} +.readout-event { + min-height: 24px; + max-width: 240px; + color: var(--cursor); + font-size: 12px; + margin-top: 17px; +} +.chart-main { + min-width: 0; +} +.chart-box { + position: relative; + width: 100%; + height: 290px; +} +.chart-box canvas { + display: block; + width: 100%; + height: 100%; +} +.chart-main canvas { + cursor: crosshair; + touch-action: pan-y; +} +.chart-legend { + display: flex; + justify-content: flex-end; flex-wrap: wrap; - gap: 5px 16px; - margin-bottom: 18px; + gap: 18px; + font-size: 12px; + color: #d0e2ff; + margin-top: 0; + padding-bottom: 9px; +} +.chart-legend span { + display: flex; + align-items: center; + gap: 7px; +} +.line-key { + width: 18px; + border-top: 2px solid var(--signal); +} +.zero-key { + width: 18px; + border-top: 1px dashed #a6c8ff; +} +.point-key { + width: 5px; + height: 5px; + border: 1px solid var(--cursor); + transform: rotate(45deg); +} +.timeline { + border-top: 1px solid #4589ff; + padding: 18px 0 19px; +} +.timeline-labels { + display: flex; + align-items: center; + font-size: 12px; + gap: 14px; +} +.timeline-labels output { + color: #d0e2ff; +} +.latest-button { + margin-left: auto; + border: 1px solid #78a9ff; + background: none; + border-radius: 4px; + color: #ffffff; + min-height: 36px; + padding: 0 11px; + font-size: 12px; +} +.latest-button:hover { + background: #0043ce; +} +.timeline-track { + position: relative; + height: 38px; + margin-top: 6px; + background: + repeating-linear-gradient(90deg, #a6c8ff40 0 1px, transparent 1px 10px) left + center/100% 10px no-repeat, + repeating-linear-gradient(90deg, #a6c8ff90 0 1px, transparent 1px 50px) left + center/100% 19px no-repeat; +} +.timeline input { + appearance: none; + -webkit-appearance: none; + width: 100%; + height: 38px; + margin: 0; + background: none; + cursor: ew-resize; +} +.timeline input::-webkit-slider-runnable-track { + background: #a6c8ff55; + height: 1px; +} +.timeline input::-webkit-slider-thumb { + -webkit-appearance: none; + height: 29px; + width: 10px; + border: 2px solid #002d9c; + border-radius: 3px; + background: var(--cursor); + margin-top: -14px; + box-shadow: 0 0 0 1px var(--cursor); +} +.timeline input::-moz-range-track { + background: #a6c8ff55; + height: 1px; +} +.timeline input::-moz-range-thumb { + height: 26px; + width: 8px; + border: 2px solid #002d9c; + border-radius: 3px; + background: var(--cursor); +} +.timeline-dates { + display: flex; + justify-content: space-between; + color: #d0e2ff; + font-size: 12px; +} +.instrument-foot { + display: flex; + align-items: center; + gap: 24px; + padding: 14px 0; + border-top: 1px solid #4589ff; +} +.instrument-foot > span { + font-size: 12px; + color: #d0e2ff; + white-space: nowrap; +} +.months { + display: flex; + gap: 28px; + overflow-x: auto; +} +.months > div { + display: flex; + align-items: center; + gap: 12px; + white-space: nowrap; + font-size: 13px; +} +.months b { + font-size: 12px; + font-weight: 400; + color: #d0e2ff; +} +.months .down { + color: #bae6ff; +} +.months .up { + color: #ffb3b8; +} +.months .flat { + color: #ffffff; +} +.instrument-status { + margin: 0 -30px; + padding: 12px 30px; + background: #001d6c; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + color: #d0e2ff; + font-size: 12px; +} +.instrument-export { + background: none; + border: 0; + min-height: 36px; + color: #ffffff; + font-size: 12px; + white-space: nowrap; +} +.instrument-export span { + margin-left: 12px; +} +.instrument-empty { + min-height: 300px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 12px; + text-align: center; +} +.instrument[data-loading="true"] .instrument-body { + opacity: 0.35; +} +.instrument[data-loading="true"] .timeline { + opacity: 0.35; + pointer-events: none; +} +.overview { + display: grid; + grid-template-columns: 1.2fr 2.6fr; + gap: 35px; + padding: 30px 0; + border-bottom: 1px solid var(--rule-strong); +} +.overview-total > p { + font-size: 13px; + color: var(--ink-soft); +} +.overview-total > strong { + display: block; + font-size: 31px; + font-weight: 500; + letter-spacing: -0.04em; + font-variant-numeric: tabular-nums; + margin: 4px 0; +} +.overview-total > span { + display: block; + font-size: 12px; +} +.overview-total .overview-cash { + margin-top: 8px; + font-size: 12px; +} +.overview-stats { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 26px; + align-content: start; +} +.overview-stats dt { + color: var(--ink-soft); + font-size: 13px; +} +.overview-stats abbr { + font-size: 10px; + text-decoration: none; + border-bottom: 1px dotted var(--rule-strong); + margin-left: 4px; +} +.overview-stats dd { + font-size: 27px; + font-variant-numeric: tabular-nums; + letter-spacing: -0.04em; + line-height: 1.4; + margin: 8px 0; +} +.overview-stats small { + font-size: 12px; + color: var(--ink-soft); +} +.history-details { + margin-bottom: 36px; +} +.history-details > summary { + color: var(--ink-soft); +} +.chart-title { + display: flex; + justify-content: space-between; + gap: 12px; + color: var(--ink-soft); + font-size: 13px; + padding: 20px 0 12px; +} +.chart-title > span { + font-size: 12px; +} +.chart-box-small { + height: 130px; +} +.workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 36px; +} +.main-column, +.side-column { + min-width: 0; +} +.sec { + min-width: 0; + padding: 25px 0; + margin-bottom: 24px; + border-top: 1px solid var(--rule-strong); +} +.main-column > .sec:first-child { + border-top: 0; + padding-top: 0; +} +.sec-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 22px; } .sec h2 { - font-size: 16px; - font-weight: 650; - letter-spacing: -0.025em; + font-size: 21px; + font-weight: 600; + letter-spacing: -0.04em; } .sec-meta { - color: var(--ink-faint); - font-size: 11px; + color: var(--ink-soft); + font-size: 12px; } .loading, .empty { color: var(--ink-soft); - font-size: 13px; - padding: 18px 0; + padding: 20px 0; + font-size: 14px; } .empty > strong, .empty > span { display: block; } .empty > span { - margin-top: 5px; + font-size: 13px; + margin-top: 8px; } .fine { - font-size: 11px; - color: var(--ink-faint); - line-height: 1.65; - margin-top: 13px; -} - -/* 차트 */ -#performance .sec-head { - margin-bottom: 13px; + color: var(--ink-soft); + font-size: 12px; + line-height: 1.7; + margin-top: 15px; } -#chartSummary { - width: 100%; - font-size: 11px; +.disclosure { + border-bottom: 1px solid var(--rule); } -.chart-controls { +.disclosure > summary { + cursor: pointer; display: flex; align-items: center; - justify-content: space-between; - gap: 8px; - margin-bottom: 15px; + min-height: 48px; + gap: 12px; + font-size: 13px; + list-style: none; } -.seg { - display: flex; - gap: 3px; - padding: 3px; - background: #f4f6f9; - border-radius: 6px; +.disclosure > summary::-webkit-details-marker { + display: none; } -.seg button { - border: 0; - background: transparent; - min-height: 36px; - min-width: 45px; - padding: 0 11px; +.disclosure > summary::after { + content: "+"; + font-size: 18px; + margin-left: auto; +} +.disclosure[open] > summary::after { + content: "−"; +} +.count { + font-size: 12px; color: var(--ink-soft); - border-radius: 4px; - font-size: 11px; } -.seg button[aria-pressed="true"] { - background: white; - color: var(--ink); - font-weight: 650; - box-shadow: 0 1px 3px #25344c14; +.side-column { + border-left: 1px solid var(--rule-strong); + padding-left: 28px; } -.chart-maturity { - font-size: 11px; - color: var(--ink-faint); - max-width: 28ch; +.side-column .sec:first-child { + border-top: 0; + padding-top: 0; } -.chart-title { +.side-column h2 { + font-size: 16px; +} +.risk-explainer h2 { + margin-bottom: 20px; +} +.risk-scale { display: flex; - align-items: center; - gap: 15px; - font-size: 11px; + align-items: baseline; + gap: 10px; + margin-bottom: 16px; +} +.risk-scale strong { + font-size: 36px; + font-weight: 500; + letter-spacing: -0.05em; +} +.risk-scale span { + font-size: 12px; color: var(--ink-soft); - margin-bottom: 8px; } -.chart-title > span { - font-size: 11px; - color: var(--ink-faint); +.risk-facts { + margin: 0; } -.legend-value::before, -.legend-principal::before { - content: ""; - display: inline-block; - width: 14px; - border-top: 2px solid var(--accent); - margin-right: 6px; - vertical-align: middle; +.risk-facts > div { + padding: 12px 0; + border-bottom: 1px solid var(--rule); } -.legend-principal::before { - border-top: 1px dashed #8290a4; +.risk-facts dt { + font-size: 12px; + color: var(--ink-soft); } -.chart-box { - position: relative; - height: 238px; - width: 100%; +.risk-facts dd { + font-size: 14px; + margin-top: 3px; } -.chart-box canvas { +.risk-explainer time { display: block; - width: 100%; - height: 100%; + font-size: 12px; + color: var(--ink-soft); + margin-top: 14px; } -.chart-box-small { - height: 76px; +.attention { + background: #f4f4f4; + border: 0; + border-left: 3px solid var(--rule-strong); + padding: 22px !important; + border-radius: 4px; } -.chart + .chart { - margin-top: 24px; - padding-top: 16px; - border-top: 1px solid var(--rule); +.attention .sec-head { + margin-bottom: 15px; } -.tip { - position: absolute; - top: 0; - left: 0; - padding: 10px 12px; - background: #fff; +.attention h3 { + font-size: 16px; + line-height: 1.65; + font-weight: 600; +} +.attention #decisionDescription { + margin-top: 12px; + font-size: 13px; + color: #525252; +} +.attention .sec-meta { + margin-top: 14px; + font-size: 12px; + color: #525252; +} +.notice-symbol { + display: grid; + place-items: center; + width: 19px; + height: 19px; + border: 1px solid currentColor; + border-radius: 50%; + font-size: 12px; +} +.attention[data-state="info"] { + border-left-color: var(--accent); +} +.attention[data-state="alert"] { + border-left-color: var(--stop); +} +.attention[data-state="alert"] .notice-symbol { + color: var(--stop); +} +.priority-notice { + display: block; + width: 100%; + min-height: 44px; + padding: 12px 16px; + margin-bottom: 24px; + background: var(--surface); border: 1px solid var(--rule); - border-radius: 8px; - box-shadow: 0 5px 20px #16294514; - max-width: 230px; - width: max-content; - pointer-events: none; - z-index: 4; - font-size: 11px; + border-left: 3px solid var(--stop); + color: var(--ink); + text-align: left; + font-size: 13px; +} +.instrument .empty { + color: #d0e2ff; +} +.instrument .empty strong { + color: #ffffff; +} +.allocation-ring { + position: relative; + margin: 20px 0 16px; + padding-top: 25px; +} +.allocation-ring::before { + content: ""; + position: absolute; + top: 0; + width: 100%; + height: 12px; + background: linear-gradient( + 90deg, + var(--accent) var(--invested, 0%), + #c6c6c6 var(--invested, 0%) + ); + mask-image: repeating-linear-gradient(90deg, #000 0 5px, transparent 5px 7px); } -.tip time, -.tip strong, -.tip span { - display: block; +.allocation-ring > span { + display: flex; + justify-content: space-between; + align-items: center; } -.tip strong { - margin: 3px 0; - font-size: 16px; +.allocation-ring small { + color: var(--ink-soft); + font-size: 12px; } -.tip span { - max-width: 26ch; - font-size: 11px; +.allocation-ring strong { + font-size: 21px; + font-weight: 500; } -.months { +.allocation dl > div { display: flex; - flex-wrap: wrap; - gap: 9px 22px; - margin-top: 17px; - padding-top: 15px; - border-top: 1px solid var(--rule); + justify-content: space-between; + gap: 12px; + margin-top: 10px; + font-size: 13px; } -.months > div { +.allocation dt { display: flex; align-items: center; - gap: 8px; - font-size: 11px; + gap: 7px; + color: var(--ink-soft); } -.months b { - font-weight: 400; - color: var(--ink-faint); +.asset-dot, +.cash-dot { + width: 6px; + height: 6px; + background: var(--accent); + display: inline-block; } -.months span { - font-weight: 600; - font-variant-numeric: tabular-nums; +.cash-dot { + background: #8d8d8d; } -.disclosure { - margin-top: 18px; - border-top: 1px solid var(--rule); +.status-row { + margin: 15px 0 5px; + padding: 0; + list-style: none; } -.disclosure > summary { - cursor: pointer; - min-height: 44px; +.status-row li { display: flex; - align-items: center; justify-content: space-between; - font-size: 12px; - font-weight: 500; - gap: 12px; - list-style: none; -} -.disclosure > summary::-webkit-details-marker { - display: none; -} -.disclosure > summary::after { - content: "+"; - color: var(--ink-faint); - font-size: 18px; -} -.disclosure[open] > summary::after { - content: "−"; + gap: 8px; + margin-bottom: 10px; + font-size: 13px; } -.count { - font-size: 11px; - color: var(--ink-faint); - margin-left: auto; +.status-row .k { + color: var(--ink-soft); } -.disclosure > .table-scroll { - max-height: 390px; +.status-row .v { + font-weight: 500; } - /* 보유 종목 */ .track-head { display: flex; @@ -733,7 +1071,7 @@ h1 { .alloc { padding: 14px 16px; border-radius: 8px; - background: #f7f9fc; + background: #f4f4f4; margin-bottom: 20px; } .alloc-line { @@ -753,7 +1091,7 @@ h1 { .bar { height: 5px; border-radius: 3px; - background: #e6ebf3; + background: #e0e0e0; position: relative; margin: 9px 0; } @@ -762,7 +1100,7 @@ h1 { height: 100%; max-width: 100%; border-radius: 3px; - background: #5475ae; + background: #0f62fe; } .bar > em { position: absolute; @@ -851,11 +1189,11 @@ tfoot td { .wbar > i { display: block; height: 3px; - background: #6a88ba; + background: #0f62fe; border-radius: 2px; } .wbar > i.t { - background: #c6cedb; + background: #c6c6c6; } .flow { font-size: 11px; @@ -863,134 +1201,6 @@ tfoot td { padding-top: 15px; } -/* 확인할 일 */ -.side-column .sec { - padding: 20px; -} -.side-column h2 { - font-size: 13px; -} -.attention { - background: #eef2f9; - border-color: #e6ebf4; -} -.attention .sec-head { - align-items: center; - margin-bottom: 17px; -} -.notice-symbol { - height: 20px; - width: 20px; - border: 1px solid #a8b7ce; - border-radius: 50%; - display: grid; - place-items: center; - font-size: 11px; - color: var(--accent); -} -.attention h3 { - font-size: 16px; - line-height: 1.65; - font-weight: 600; - letter-spacing: -0.025em; -} -.attention #decisionDescription { - color: var(--ink-soft); - font-size: 13px; - line-height: 1.75; - margin-top: 9px; -} -.attention .link { - margin-top: 10px; -} -.attention .sec-meta { - margin-top: 13px; - padding-top: 12px; - border-top: 1px solid #dce3ef; - font-size: 11px; -} -.allocation-ring { - --invested: 0%; - margin: 22px auto; - width: 144px; - height: 144px; - border-radius: 50%; - background: conic-gradient( - #5777ae 0 var(--invested), - #e7ebf2 var(--invested) 100% - ); - padding: 15px; -} -.allocation-ring > span { - display: flex; - align-items: center; - justify-content: center; - flex-direction: column; - height: 100%; - background: white; - border-radius: 50%; -} -.allocation-ring small { - font-size: 11px; - color: var(--ink-soft); -} -.allocation-ring strong { - font-size: 24px; - font-weight: 600; - letter-spacing: -0.025em; -} -.allocation dl > div { - display: flex; - justify-content: space-between; - gap: 8px; - padding: 7px 0; - font-size: 12px; -} -.allocation dt { - color: var(--ink-soft); - display: flex; - align-items: center; - gap: 7px; -} -.allocation dd { - font-variant-numeric: tabular-nums; - font-weight: 500; -} -.asset-dot, -.cash-dot { - display: inline-block; - width: 6px; - height: 6px; - border-radius: 2px; - background: #5777ae; -} -.cash-dot { - background: #d2d9e5; -} -.allocation .fine { - font-size: 11px; -} -.status-row { - list-style: none; - margin: 14px 0 8px; - padding: 0; -} -.status-row li { - display: flex; - align-items: center; - gap: 7px; - font-size: 11px; - padding: 8px 0; -} -.status-row .k { - color: var(--ink-soft); -} -.status-row .v { - margin-left: auto; - font-weight: 500; -} - -/* 검증 및 자동매매 */ .review-item { display: grid; grid-template-columns: minmax(100px, 1fr) minmax(130px, 1.2fr) auto; @@ -1051,7 +1261,7 @@ tfoot td { margin-top: 3px; } .halt { - background: #fff5f3; + background: #fff1f1; padding: 18px; border-radius: 8px; margin-top: 20px; @@ -1103,10 +1313,10 @@ tfoot td { background: white; max-height: calc(100dvh - 32px); overflow: auto; - box-shadow: 0 20px 80px #0d203333; + box-shadow: 0 20px 80px #16161633; } .deposit-dialog::backdrop { - background: #15263e66; + background: #16161666; } .deposit-dialog form { padding: 25px; @@ -1195,7 +1405,7 @@ tfoot td { .quick button[aria-pressed="true"] { background: var(--accent-bg); color: var(--accent); - border-color: #718fca; + border-color: #0f62fe; } .confirm { padding: 16px 0; @@ -1235,7 +1445,7 @@ tfoot td { left: 50%; transform: translate(-50%, 12px); opacity: 0; - background: #263d64; + background: var(--accent); color: white; border-radius: 9px; padding: 12px 20px; @@ -1252,403 +1462,583 @@ tfoot td { transform: translate(-50%, 0); } -@media (min-width: 1021px) and (min-height: 960px) { - .side-column { - position: sticky; - top: 92px; - } +/* 기록과 폼은 밝은 바탕에서 읽고, 계측 화면만 어두운 면을 쓴다. */ +table { + background: transparent; +} +.track-name { + font-size: 20px; +} +.note, +.overlay { + font-size: 13px; +} +.track-kind { + font-size: 12px; +} +.review-item { + grid-template-columns: 1.1fr 1.3fr auto; +} +.foot { + padding: 25px 0; + border-top: 1px solid var(--rule-strong); +} +.tip { + display: none; } -@media (max-width: 1240px) { - :root { - --rail: 178px; +@media (max-width: 1120px) { + .chrome { + gap: 30px; } - .page { - padding: 27px 24px 0; + .header-actions { + gap: 10px; } - .chrome { - padding-inline: 24px; + .state { + display: none; } .workspace { - grid-template-columns: minmax(0, 1fr) 232px; - gap: 18px; + grid-template-columns: minmax(0, 1fr) 260px; + gap: 24px; + } + .side-column { + padding-left: 22px; + } + .instrument-body { + grid-template-columns: 220px minmax(0, 1fr); } .overview { - gap: 18px; - padding: 24px; + gap: 25px; } .overview-stats { - padding-left: 22px; - gap: 18px; + gap: 15px; } - .overview-total { - min-width: 180px; + .overview-stats dd { + font-size: 24px; } - .overview-stats small { - max-width: 15ch; +} +@media (max-width: 860px) { + .chrome { + gap: 24px; + padding: 12px 20px; + } + .nav { + gap: 19px; + } + .brand { + font-size: 23px; + gap: 8px; + } + .brand img { + width: 26px; + height: 26px; + } + .page { + padding: 30px 20px 0; + } + h1 { + font-size: 34px; + } + .instrument { + padding: 22px 22px 0; } - .rail { - padding-inline: 12px; + .instrument-status { + margin: 0 -22px; + padding: 10px 22px; } - .nav > a { + .instrument-body { + grid-template-columns: 190px minmax(0, 1fr); + gap: 0; + } + .readout-return { + font-size: 43px; + } + .readout dl { + max-width: 180px; + } + .chart-box { + height: 300px; + } + .chart-legend { gap: 10px; - font-size: 13px; + font-size: 11px; } - .sec { - padding: 21px; + .overview { + grid-template-columns: 1fr; + gap: 25px; + } + .overview-total { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 12px; + } + .overview-total > p { + grid-column: 1; + } + .overview-total > strong { + grid-column: 1; + grid-row: 2; + } + .overview-total > span { + grid-column: 2; + grid-row: 2; + text-align: right; + align-self: center; + } + .overview-total .overview-cash { + grid-column: 2; + grid-row: 1; + text-align: right; + margin: 0; } - .chart-maturity { - display: none; + .workspace { + grid-template-columns: 1fr; + gap: 18px; } - .state time { - display: none; + .side-column { + padding-left: 0; + border: 0; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + align-items: start; } -} -@media (max-width: 1020px) { - :root { - --rail: 72px; + .side-column .sec { + margin: 0; } - .rail { - padding: 27px 11px; + .side-column .sec:nth-child(2) { + order: -1; + grid-column: 1 / -1; } - .brand { - padding: 0; - justify-content: center; - } - .brand > span, - .nav > a > span, - .rail-bottom { - position: absolute; - width: 1px; - height: 1px; - clip-path: inset(50%); - overflow: hidden; - } - .nav > a { - padding: 13px; - justify-content: center; - } - .nav > a > svg { - width: 21px; - height: 21px; + .system-summary { + display: none; } - .workspace { - grid-template-columns: minmax(0, 1fr) 218px; + .risk-explainer { + border-top: 1px solid var(--rule-strong) !important; + padding-top: 22px !important; } - .overview-stats { - gap: 15px; - padding-left: 20px; + .record-date { + font-size: 12px; } - .overview { - gap: 20px; +} +@media (max-width: 600px) { + html { + scroll-padding-top: 125px; } - .overview-total { - min-width: 165px; + .chrome { + display: grid; + grid-template-columns: 1fr auto; + gap: 6px 12px; + padding: 12px 16px 0; + min-height: 105px; } - .overview-stats dd { - font-size: 21px; + .nav { + grid-row: 2; + grid-column: 1/-1; + gap: 26px; } - .sec { - padding: 20px; + .nav a { + padding: 9px 0 12px; + font-size: 13px; } .header-actions { - gap: 13px; + grid-column: 2; + grid-row: 1; } - .side-column .sec { - padding: 18px; + .header-actions .pill { + min-height: 36px; + padding: 0 12px; + font-size: 12px; } - .page-heading time { + .mode { font-size: 11px; + padding: 2px 5px; } -} -@media (max-width: 800px) { - .overview-cash { - display: block; - } - .workspace { - grid-template-columns: minmax(0, 1fr); + .page { + padding: 25px 16px 0; } - .side-column { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 18px; - grid-row: 1; + .page-heading { + align-items: flex-start; + margin-bottom: 23px; + gap: 12px; } - .side-column .sec { - margin-bottom: 0; + h1 { + font-size: 30px; } - .side-column .attention { - grid-column: 1/-1; + .page-kicker { + font-size: 12px; + margin-bottom: 6px; } - .attention #decisionDescription { - margin-top: 4px; + .record-date { + padding-top: 4px; + max-width: 125px; + font-size: 11px; } - .attention .sec-head { - margin-bottom: 6px; + .record-date span { + font-size: 11px; } - .attention .sec-meta, - .side-column .allocation, - .side-column .system-summary { - display: none; + .account-tabs { + gap: 2px; + min-height: 57px; } - .main-column { - margin-top: 0; + .account-tabs > button { + min-width: 0; + flex: 1; + padding: 11px 11px; + gap: 7px; + font-size: 13px; } - .overview { - flex-direction: column; - align-items: stretch; - gap: 21px; + .account-tabs small { + font-size: 11px; } - .overview-stats { - padding: 20px 0 0; - border-left: 0; - border-top: 1px solid var(--rule); + .account-icon, + .account-check { + display: none; } - .overview-total > strong { - font-size: 36px; + .instrument { + padding: 19px 15px 0; + border-radius: 0 8px 8px 8px; } - .overview-stats small { - max-width: none; + .instrument h2 { + font-size: 16px; } - .overview-stats dd { - font-size: 23px; + .instrument-head p { + font-size: 11px; } - .state { - display: none; + .view-switch button { + min-height: 36px; + min-width: 42px; } - .page-heading { + .chart-controls { align-items: flex-start; + gap: 6px; + margin-top: 14px; + flex-wrap: wrap; } - .page-heading time { - margin-top: 5px; + .seg { + gap: 2px; } - .attention .link { - margin-top: 4px; + .seg button { + font-size: 12px; + padding: 0 12px; } -} -@media (max-width: 560px) { - :root { - --rail: 0px; + .chart-maturity { + width: 100%; + font-size: 11px; + margin-top: 8px; } - html { - scroll-padding-top: calc(112px + env(safe-area-inset-top, 0px)); + .instrument-body { + display: flex; + flex-direction: column; + min-height: 0; } - .rail { - top: calc(57px + env(safe-area-inset-top, 0px)); - bottom: auto; + .readout { width: 100%; - height: 50px; - padding: 0 12px; - border-right: 0; - border-bottom: 1px solid var(--rule); - } - .brand { - display: none; + padding: 20px 0 5px; + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: 18px; } - .nav { - flex-direction: row; - margin: 0; - gap: 0; - height: 100%; - justify-content: space-between; + .readout-date { + grid-column: 1/-1; + font-size: 12px; } - .nav > a { - flex: 1; - min-width: 0; - min-height: 44px; - padding: 0 6px; - gap: 0; - border-radius: 0; + .readout-label { + grid-column: 1; + margin-top: 19px; font-size: 11px; - border-bottom: 2px solid transparent; - white-space: nowrap; } - .nav > a > svg { - display: none; + .readout-return { + grid-column: 1; + font-size: 43px; + margin-top: 0; + } + .readout dl { + grid-column: 2; + grid-row: 2/4; + align-self: center; + width: 100%; + max-width: none; + margin: 8px 0 0; } - .nav > a > span { - display: inline; - position: static; - width: auto; - height: auto; - clip-path: none; - overflow: visible; + .readout dl > div { + font-size: 11px; + gap: 8px; + margin-top: 6px; } - .nav > a[aria-current] { - background: transparent; - border-bottom-color: var(--accent); + .readout-event { + grid-column: 1/-1; + margin-top: 12px; + min-height: 20px; + max-width: none; + font-size: 11px; } - .chrome { - height: calc(57px + env(safe-area-inset-top, 0px)); - min-height: 57px; - padding: calc(10px + env(safe-area-inset-top, 0px)) - max(16px, env(safe-area-inset-right)) 10px - max(16px, env(safe-area-inset-left)); + .chart-main { + width: 100%; } - .location { - font-size: 0; + .chart-box { + height: 242px; } - .location::before { - content: "눈금"; - font-size: 22px; - font-weight: 700; - letter-spacing: -0.065em; - color: var(--ink); + .chart-legend { + justify-content: flex-start; + gap: 15px; + font-size: 11px; + padding-bottom: 14px; } - .location strong { - display: none; + .timeline { + padding: 13px 0; } - .header-actions { + .timeline-labels { + font-size: 11px; gap: 10px; } - .pill { - min-height: 36px; - font-size: 12px; - padding-inline: 12px; + .timeline-dates { + font-size: 11px; } - .page { - padding: 73px 16px 0; + .latest-button { + font-size: 11px; } - .page-heading { - margin-bottom: 23px; - flex-wrap: wrap; - gap: 9px; + .instrument-foot { + gap: 16px; + padding: 12px 0; } - .page-heading time { + .instrument-foot > span { font-size: 11px; - width: 100%; - } - h1 { - font-size: 23px; } - .page-heading p { - font-size: 12px; + .months { + gap: 18px; } - .account-tabs { - gap: 8px; + .months > div { + flex-direction: column; + gap: 2px; + font-size: 13px; } - .account-tabs > button { - padding: 12px 10px; - gap: 7px; + .months b { font-size: 11px; } - .account-tabs small { + .instrument-status { + margin: 0 -15px; + padding: 10px 15px; + display: block; font-size: 11px; } - .account-icon, - .account-check { - display: none; + .instrument-status p { + line-height: 1.6; + } + .instrument-export { + display: block; + margin: 5px 0 0 auto; } .overview { - padding: 20px; - margin-bottom: 16px; + padding: 23px 0; + gap: 20px; } .overview-total > strong { - font-size: 34px; + font-size: 28px; } - .overview-stats { - gap: 11px; + .overview-total > span { + font-size: 11px; } - .overview-stats dd { - font-size: 19px; + .overview-total > p, + .overview-total .overview-cash { + font-size: 12px; } - .overview-stats dt { - font-size: 11px; + .overview-stats { + gap: 10px; } - .overview-stats small { + .overview-stats dt { font-size: 11px; } .overview-stats abbr { display: none; } - .overview-total > span { + .overview-stats dd { + font-size: 20px; + margin: 6px 0; + } + .overview-stats small { font-size: 11px; + line-height: 1.6; + display: block; } - .workspace { - gap: 16px; + .history-details { + margin-bottom: 23px; } .sec { - padding: 20px 16px; - margin-bottom: 16px; - border-radius: 12px; - } - .sec-head { - gap: 5px; + padding: 22px 0; + margin-bottom: 15px; } .sec h2 { - font-size: 15px; + font-size: 20px; + } + .sec-head { + gap: 10px; + margin-bottom: 18px; } .sec-meta { font-size: 11px; } - .chart-box { - height: 215px; + .track-name { + font-size: 19px; } - .chart-box-small { - height: 66px; + .track-head { + align-items: flex-start; } - .chart-title { - gap: 12px; + .spark { + width: 85px; + } + .note, + .overlay { + font-size: 12px; + } + .table-scroll { + margin: 0 -16px; + padding: 0 16px; + } + table { + min-width: 580px; + } + .table-scroll:focus-visible { + outline-offset: -3px; } .review-item { - grid-template-columns: minmax(0, 1fr) auto; + display: flex; + flex-wrap: wrap; + gap: 12px; + } + .review-name { + flex: 1 1 100%; } .review-progress { - grid-row: 2; - grid-column: 1/-1; + flex: 1; + } + .review-issues { + flex-basis: 100%; + } + .review-progress p { + font-size: 11px; } .status-item { - grid-template-columns: 88px minmax(0, 1fr); - gap: 10px; + grid-template-columns: 95px 1fr; + } + .side-column { + gap: 20px; + } + .side-column h2 { + font-size: 15px; + } + .risk-scale { + display: block; + } + .risk-scale strong { + font-size: 30px; + display: block; + } + .risk-facts dd { + font-size: 12px; + } + .risk-facts dt, + .risk-explainer time { + font-size: 11px; + } + .allocation dl > div { + flex-wrap: wrap; + font-size: 12px; + } + .allocation .fine { + font-size: 11px; + } + .attention { + padding: 20px !important; + } + .side-column .attention h2 { + font-size: 16px; } .foot { flex-direction: column; align-items: flex-start; - gap: 6px; + gap: 8px; + padding: 25px 0 max(25px, env(safe-area-inset-bottom)); font-size: 11px; } - .track-head .spark { - display: none; + .deposit-dialog { + max-width: calc(100% - 32px); + width: 400px; + padding: 22px; } - .track-name { - font-size: 13px; + .dialog-actions { + flex-wrap: wrap; } - .fine { - font-size: 11px; + .dialog-actions .pill { + flex: 1; + min-width: 85px; + } + .toast { + max-width: calc(100vw - 32px); } } @media (max-width: 360px) { - .overview { - padding: 17px; + .page { + padding-left: 12px; + padding-right: 12px; } - .overview-stats { - gap: 8px; + .chrome { + padding-left: 12px; + padding-right: 12px; } - .overview-stats dd { - font-size: 17px; + .nav { + gap: 23px; } - .overview-stats small { - font-size: 11px; + h1 { + font-size: 26px; } - .page { - padding-inline: 12px; + .record-date { + max-width: 110px; } - .nav > a { - font-size: 11px; - padding-inline: 4px; + .instrument { + padding-left: 12px; + padding-right: 12px; + } + .instrument-status { + margin-left: -12px; + margin-right: -12px; + padding-left: 12px; + padding-right: 12px; + } + .readout { + gap: 0 10px; + } + .readout-return { + font-size: 37px; + } + .overview-stats dd { + font-size: 18px; + } + .overview-total > strong { + font-size: 25px; + } + .overview-total > span { + max-width: 125px; + justify-self: end; } .chart-box { - height: 200px; + height: 225px; + } + .side-column { + grid-template-columns: 1fr; + } + .side-column .sec:nth-child(2) { + grid-column: 1; } - .attention h3 { - font-size: 14px; + .allocation dl > div { + flex-wrap: nowrap; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; scroll-behavior: auto !important; - transition: none !important; - animation: none !important; } } diff --git a/monitoring/static/dashboard.js b/monitoring/static/dashboard.js index 1f12b18..f112139 100644 --- a/monitoring/static/dashboard.js +++ b/monitoring/static/dashboard.js @@ -110,12 +110,12 @@ const FONT = '"Nungum UI", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif'; const COLORS = { - ink: "#3157a4", - up: "#c4383c", - down: "#2563c7", - warn: "#976019", + ink: "#0f62fe", + up: "#da1e28", + down: "#0072c3", + warn: "#a2191f", }; - const inkAlpha = (a) => `rgba(48, 60, 78, ${a})`; + const inkAlpha = (a) => `rgba(22, 22, 22, ${a})`; /* ---------- 상태 ---------- */ @@ -142,6 +142,8 @@ coreStatus: "loading", runtimeStatus: "loading", activeRequests: new Map(), + chartRequest: 0, + chartDataAccount: null, depositConfirming: false, depositRequestId: null, }; @@ -233,7 +235,7 @@ setSync("partial", "계좌 기록 정상 · 운영 상태 확인 불가"); else if (state.coreStatus === "partial") setSync("partial", "일부 데이터 지연"); - else setSync("ok", "연결 정상"); + else setSync("ok", "계좌 기록 연결 정상"); updateDepositAvailability(); } function markCoreSuccess(timestamp) { @@ -246,9 +248,17 @@ } function setMode(mode) { const normalized = String(mode || "unknown").toLowerCase(); - state.mode = ["paper", "live"].includes(normalized) + const nextMode = ["paper", "live"].includes(normalized) ? normalized : "unknown"; + if (state.mode !== nextMode) { + state.flows.clear(); + state.flowStatus.clear(); + state.series.clear(); + state.seriesStatus.clear(); + state.chartDataAccount = null; + } + state.mode = nextMode; el.modeBadge.dataset.mode = state.mode; el.modeBadge.textContent = state.mode === "paper" @@ -314,12 +324,27 @@ /** 스냅샷·입금 기록으로 평가금액, 투자원금(계단), 시간가중 자산, 낙폭 시계열을 만든다. */ function buildSeries(rows, flows, initialCapital) { + if ( + rows.some( + (r) => + !parseDate(r.date) || + !Number.isFinite(num(r.total_value)) || + num(r.total_value) < 0 || + !Number.isFinite(num(r.cumulative_return)) || + num(r.cumulative_return) < -100, + ) + ) + throw new Error("날짜 또는 계좌 금액이 올바르지 않습니다."); + if ( + new Set(rows.map((r) => String(r.date).slice(0, 10))).size !== rows.length + ) + throw new Error("같은 날짜의 계좌 기록이 중복됐습니다."); const sorted = [...rows] .filter((r) => parseDate(r.date)) .sort((a, b) => parseDate(a.date) - parseDate(b.date)); const dates = sorted.map((r) => parseDate(r.date)); - const value = sorted.map((r) => Number(r.total_value || 0)); - const cr = sorted.map((r) => Number(r.cumulative_return || 0)); + const value = sorted.map((r) => Number(r.total_value)); + const cr = sorted.map((r) => Number(r.cumulative_return)); const twr = cr.map((c) => initialCapital * (1 + c / 100)); const sortedFlows = [...(flows || [])] .map((f) => ({ @@ -330,13 +355,18 @@ .filter((f) => f.at) .sort((a, b) => a.at - b.at); const instants = sorted.map(rowInstant); - const principal = instants.map( - (at) => - initialCapital + - sortedFlows - .filter((f) => at && f.at <= at) - .reduce((s, f) => s + f.amount, 0), - ); + let flowIndex = 0; + let principalAt = initialCapital; + const principal = instants.map((at) => { + while ( + flowIndex < sortedFlows.length && + at && + sortedFlows[flowIndex].at <= at + ) { + principalAt += sortedFlows[flowIndex++].amount; + } + return principalAt; + }); // 계좌 기록과 같은 규칙: 시작 자본을 첫 고점으로 두고 시간가중 자산으로 낙폭을 잰다. let peak = initialCapital > 0 ? initialCapital : -Infinity; let peakIdx = -1; @@ -531,10 +561,42 @@ ? "자산 구성 기록 없음" : `보유 자산 ${(100 - cashPct).toFixed(1)}%, 현금 ${cashPct.toFixed(1)}%`, ); + renderRiskExplanation(b); $("allocationList").innerHTML = `
보유 자산
${won(snap ? snap.total_value - snap.cash : null)}
현금
${won(snap?.cash)}
`; } + function renderRiskExplanation(basket) { + const target = $("riskExplanation"); + const overlay = basket?.overlay; + if (!basket) { + target.innerHTML = + '

계좌 기록을 불러오는 중입니다.

'; + return; + } + if (!overlay?.enabled) { + target.innerHTML = `
${Math.round((basket.design_fraction || 0) * 100)}%보유 자산 목표 비중

설정한 비중을 유지합니다. 추세와 낙폭에 따른 자동 축소는 사용하지 않습니다.

`; + return; + } + const riskWeights = Object.entries(basket.target_weights || {}) + .filter(([symbol]) => symbol !== overlay.defensive_symbol) + .reduce((sum, [, weight]) => sum + weight, 0); + const issues = overlay.data_issues || []; + const trend = + overlay.trend_below == null + ? "판단 기록 확인 필요" + : overlay.trend_below + ? "장기 추세 아래 · 비중 축소" + : "장기 추세 기준 유지"; + const dd = + overlay.drawdown_active == null + ? "판단 기록 확인 필요" + : overlay.drawdown_active + ? "낙폭 기준 도달 · 비중 축소" + : "낙폭 기준 이내"; + target.innerHTML = `
${(riskWeights * 100).toFixed(1)}%주식 목표 비중
추세 판단
${escapeHtml(trend)}
손실 관리
${escapeHtml(dd)}
비중을 줄이면
${overlay.defensive_symbol ? "줄인 금액은 CD금리 ETF에 배분" : "현금으로 보유"}
${issues.length ? `

${escapeHtml(issues.join(" / "))}

` : ""}${overlay.evaluated_at ? `` : '

첫 판단 기록을 기다리고 있습니다.

'}`; + } + function latestSnapshotDate() { const b = selectedBasket(); return b && b.snapshot ? b.snapshot.date : null; @@ -572,6 +634,11 @@ el.decisionAction.hidden = !action; el.decisionAction.dataset.action = action || ""; el.decisionAction.textContent = actionLabel; + const urgent = ["operations", "retry"].includes(action); + $("today").dataset.state = urgent ? "alert" : action ? "info" : "quiet"; + $("today").querySelector(".notice-symbol").textContent = urgent ? "!" : "·"; + $("priorityNotice").hidden = !urgent; + $("priorityNotice").textContent = `${title} · ${actionLabel} →`; } function renderDecision() { const baskets = state.baskets; @@ -932,6 +999,10 @@ hover: null, geometry: null, series: null, + view: "ribbon", + perspective: 1, + selected: null, + paintFrame: null, }; function ensureChartAccountOptions() { @@ -1033,156 +1104,287 @@ .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) { + // 같은 기록을 입체와 평면으로 표현한다. 깊이는 별도 지표가 아니다. + // 최대 420개 꼭짓점으로 표현하되 원본 기록·날짜 선택·다운로드는 생략하지 않는다. + function drawChart() { if (!chart.series || el.chartWrap.hidden) return; const series = chart.series; + const n = series.rows.length; + if (!n) return; const width = Math.max(1, 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.7); - context.fillText(compactWon(v), padding.left - 8, y); + const height = Math.max(180, el.chartBox.clientHeight); + const ctx = setupCanvas(el.chartEquity, width, height); + const p = chart.perspective; + const small = width < 480; + const dx = (small ? 23 : 45) * p; + const dy = (small ? 42 : 65) * p; + const left = small ? 37 : 57; + const right = small ? 26 : 42; + const plotWidth = Math.max(1, width - left - right - dx); + const max = Math.max(0, ...series.cr); + const min = Math.min(0, ...series.cr); + const spread = Math.max(2, max - min); + const top = 36 + dy; + const floor = height - 44; + const span = Math.max(50, floor - top - 25 * p); + const x = (i) => left + (plotWidth * i) / Math.max(1, n - 1); + const y = (v, i = 0) => + top + + ((max + spread * 0.15 - v) / (spread * 1.3)) * span + + (22 * p * i) / Math.max(1, n - 1); + chart.geometry = { + width, + height, + n, + x, + padding: { left, right: right + dx }, + scale: { y }, + dx, + dy, + }; + ctx.font = `400 ${small ? 11 : 12}px ${FONT}`; + ctx.lineWidth = 1; + ctx.textBaseline = "middle"; + // 그리드와 기준선도 같은 투영을 사용한다. 0%가 손익의 기준이다. + const grid = [min, (min + max) / 2, max]; + grid.forEach((v, index) => { + if (index > 0 && Math.abs(v - grid[index - 1]) < 0.01) return; + ctx.beginPath(); + ctx.moveTo(left, y(v)); + ctx.lineTo(x(n - 1), y(v, n - 1)); + ctx.lineTo(x(n - 1) + dx, y(v, n - 1) - dy); + ctx.strokeStyle = "#a6c8ff44"; + ctx.stroke(); + ctx.fillStyle = "#d0e2ff"; + ctx.textAlign = "right"; + ctx.fillText(`${v.toFixed(0)}%`, left - 10, y(v)); }); - const step = Math.max(1, Math.ceil((n - 1) / (width < 480 ? 3 : 6))); - context.fillStyle = inkAlpha(0.7); - 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, + for (let t = 0; t <= 6; t++) { + const i = ((n - 1) * t) / 6; + ctx.beginPath(); + ctx.moveTo(x(i), floor); + ctx.lineTo(x(i) + dx, floor - dy); + ctx.strokeStyle = "#a6c8ff30"; + ctx.stroke(); + } + ctx.setLineDash([4, 5]); + ctx.beginPath(); + ctx.moveTo(x(0), y(0)); + ctx.lineTo(x(n - 1), y(0, n - 1)); + ctx.strokeStyle = "#a6c8ff99"; + ctx.stroke(); + ctx.setLineDash([]); + const step = Math.max(1, Math.ceil((n - 1) / 420)); + const indices = []; + for (let i = 0; i < n; i += step) indices.push(i); + if (indices.at(-1) !== n - 1) indices.push(n - 1); + const polygon = (points, fill) => { + ctx.beginPath(); + points.forEach(([a, b], i) => (i ? ctx.lineTo(a, b) : ctx.moveTo(a, b))); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + }; + if (p > 0.001) { + // 2D Canvas에 투영한 띠: WebGL 컨텍스트, 텍스처, 3D 모델 다운로드가 없다. + for (let k = 0; k < indices.length - 1; k++) { + const a = indices[k], + b = indices[k + 1]; + const ax = x(a), + ay = y(series.cr[a], a), + bx = x(b), + by = y(series.cr[b], b); + polygon( + [ + [ax, ay], + [bx, by], + [bx, by + 5 * p], + [ax, ay + 5 * p], + ], + `rgba(15,98,254,${p})`, + ); + const light = clamp(67 + (ay - by) * 0.6, 55, 81); + polygon( + [ + [ax, ay], + [bx, by], + [bx + dx, by - dy], + [ax + dx, ay - dy], + ], + `hsla(215,90%,${light}%,${p * 0.96})`, + ); + } + ctx.beginPath(); + indices.forEach((i, k) => + k + ? ctx.lineTo(x(i) + dx, y(series.cr[i], i) - dy) + : ctx.moveTo(x(i) + dx, y(series.cr[i], i) - dy), ); + ctx.strokeStyle = `rgba(186,230,255,${p * 0.95})`; + ctx.stroke(); } - context.textAlign = n > 1 ? "right" : "left"; - context.fillText( - fmtMD(series.rows[n - 1].date), - x(n - 1), - height - padding.bottom + 14, + ctx.beginPath(); + indices.forEach((i, k) => + k + ? ctx.lineTo(x(i), y(series.cr[i], i)) + : ctx.moveTo(x(i), y(series.cr[i], i)), ); - - 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(); - 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.restore(); - + ctx.strokeStyle = "#bae6ff"; + ctx.lineJoin = "round"; + ctx.lineWidth = 1.8; + ctx.stroke(); + if (n === 1) { + ctx.beginPath(); + ctx.arc(x(0), y(series.cr[0]), 4, 0, Math.PI * 2); + ctx.fillStyle = "#bae6ff"; + ctx.fill(); + } series.markers.forEach((m) => { - if (m.index >= visible) return; - const mx = x(m.index); - context.strokeStyle = inkAlpha(0.28); - context.beginPath(); - 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}`; + const mx = x(m.index), + my = y(series.cr[m.index], m.index); + ctx.beginPath(); + ctx.moveTo(mx, my - 5); + ctx.lineTo(mx + 5, my); + ctx.lineTo(mx, my + 5); + ctx.lineTo(mx - 5, my); + ctx.closePath(); + ctx.fillStyle = "#ffffff"; + ctx.fill(); }); - - 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); + const chosen = chart.selected ?? n - 1; + const cx = x(chosen), + cy = y(series.cr[chosen], chosen); + polygon( + [ + [cx, cy], + [cx + dx, cy - dy], + [cx + dx, floor - dy], + [cx, floor], + ], + "rgba(255,255,255,.08)", + ); + ctx.beginPath(); + ctx.moveTo(cx, floor); + ctx.lineTo(cx, cy); + ctx.lineTo(cx + dx, cy - dy); + ctx.strokeStyle = "#ffffff"; + ctx.lineWidth = 1; + ctx.stroke(); + ctx.beginPath(); + ctx.arc(cx, cy, 4, 0, Math.PI * 2); + ctx.fillStyle = "#ffffff"; + ctx.fill(); + ctx.strokeStyle = "#002d9c"; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.fillStyle = "#ffffff"; + ctx.textAlign = chosen > n * 0.75 ? "right" : "left"; + ctx.fillText( + pct(series.cr[chosen]), + cx + (chosen > n * 0.75 ? -9 : 9), + cy - 15, + ); + ctx.fillStyle = "#d0e2ff"; + ctx.textAlign = "left"; + ctx.fillText(fmtMD(series.rows[0].date), left, floor + 22); + ctx.textAlign = "right"; + ctx.fillText(fmtMD(series.rows[n - 1].date), x(n - 1) + dx, floor + 22); + if ($("historyDetails").open) drawDrawdown(series); + } + + function scheduleChartPaint() { + if (chart.paintFrame != null) return; + chart.paintFrame = requestAnimationFrame(() => { + chart.paintFrame = null; + drawChart(); }); - 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 = "#ffffff"; - context.lineWidth = 2; - context.stroke(); + function setChartView(view) { + chart.view = view; + $("performance").dataset.view = view; + $("chartView") + .querySelectorAll("button") + .forEach((b) => + b.setAttribute("aria-pressed", String(b.dataset.view === view)), + ); + if (chart.frame != null) cancelAnimationFrame(chart.frame); + const from = chart.perspective, + to = view === "ribbon" ? 1 : 0; + if (reducedMotion.matches || document.hidden) { + chart.frame = null; + chart.perspective = to; + drawChart(); + return; } - drawDrawdown(series); + const began = performance.now(); + const step = (now) => { + const t = clamp((now - began) / 360, 0, 1); + chart.perspective = from + (to - from) * (1 - Math.pow(1 - t, 3)); + drawChart(); + chart.frame = t < 1 ? requestAnimationFrame(step) : null; + }; + chart.frame = requestAnimationFrame(step); + } + + function selectHistory(index) { + const s = chart.series; + if (!s?.rows.length) return; + const i = clamp(index, 0, s.rows.length - 1); + chart.selected = i; + chart.hover = i; + const row = s.rows[i]; + $("historyDate").textContent = fmtLong(row.date); + $("historyDate").dateTime = String(row.date).slice(0, 10); + $("historyTag").textContent = + i === s.rows.length - 1 ? "최근 기록" : "선택한 날짜"; + $("historyReturn").textContent = pct(s.cr[i]); + $("historyReturn").className = `readout-return ${tone(s.cr[i])}`; + $("historyValue").textContent = won(s.value[i]); + $("historyPrincipal").textContent = won(s.principal[i]); + $("historyDrawdown").textContent = pct(s.dd[i] * 100); + const amount = s.markers + .filter((m) => m.index === i) + .reduce((sum, m) => sum + m.amount, 0); + $("historyEvent").textContent = amount + ? `적립금 ${signedWon(amount)} 기록` + : i === s.rows.length - 1 + ? "날짜 눈금을 움직여 기록을 살펴보세요." + : "이 날짜의 계좌 기록을 보고 있습니다."; + const cursor = $("historyCursor"); + cursor.value = String(i); + cursor.setAttribute( + "aria-valuetext", + `${fmtLong(row.date)}, 수익률 ${pct(s.cr[i])}, 평가금액 ${won(s.value[i])}`, + ); + $("historyPosition").textContent = `${i + 1} / ${s.rows.length}개 기록`; + scheduleChartPaint(); + } + + function exportHistory() { + const s = chart.series; + if (!s?.rows.length || state.chartDataAccount !== state.chartAccount) + return; + const csv = [ + "날짜,평가금액(원),투자원금(원),누적시간가중수익률(%),고점대비하락률(%)", + ...s.rows.map((r, i) => + [ + String(r.date).slice(0, 10), + s.value[i], + s.principal[i], + s.cr[i], + (s.dd[i] * 100).toFixed(6), + ].join(","), + ), + ].join("\r\n"); + const url = URL.createObjectURL( + new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" }), + ); + const a = document.createElement("a"); + a.href = url; + a.download = `nungum-${state.chartAccount.replace(/[^a-z0-9_-]/gi, "-")}-${String(s.rows.at(-1).date).slice(0, 10)}.csv`; + a.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); } function drawDrawdown(series) { @@ -1248,37 +1450,20 @@ } } - 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)), + if (!chart.series || !g || $("performance").dataset.loading === "true") + return; + const px = event.clientX - event.currentTarget.getBoundingClientRect().left; + const i = clamp( + Math.round( + ((px - g.padding.left) / (g.width - g.padding.left - g.padding.right)) * + Math.max(1, 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(); + if (chart.selected !== i) selectHistory(i); } function updateChart(snapshots) { @@ -1299,15 +1484,30 @@ series.dd = series.rows.map( (row, i) => historicalDD.get(row.date) ?? series.dd[i], ); + const wasLatest = + chart.selected == null || + chart.selected === chart.series?.rows.length - 1; + const previousDate = chart.series?.rows[chart.selected]?.date; + const sameAccount = state.chartDataAccount === state.chartAccount; chart.series = series; + state.chartDataAccount = state.chartAccount; state.chartRows = series.rows; chart.hover = null; el.chartTip.hidden = true; renderChartMaturity(); + const cursor = $("historyCursor"); + cursor.max = String(Math.max(0, series.rows.length - 1)); + cursor.disabled = !series.rows.length; + $("exportHistory").disabled = !series.rows.length; + $("historyLatest").disabled = !series.rows.length; + $("historyStart").textContent = fmtMD(series.rows[0]?.date); + $("historyEnd").textContent = fmtMD(series.rows.at(-1)?.date); if (!series.rows.length) { el.chartWrap.hidden = true; el.chartEmpty.hidden = false; + el.chartEmpty.innerHTML = + "선택한 기간에 기록이 없습니다.조회 기간을 넓히거나 모의투자 기록을 확인해 주세요."; el.chartSummary.textContent = "선택한 기간에 자산 기록이 없습니다."; el.chartRows.innerHTML = ""; el.monthStrip.innerHTML = ""; @@ -1323,6 +1523,11 @@ el.chartEmpty.hidden = true; renderChartTable(series); renderMonthStrip(series, history); + const previousIndex = + sameAccount && !wasLatest + ? series.rows.findIndex((r) => r.date === previousDate) + : -1; + selectHistory(previousIndex >= 0 ? previousIndex : series.rows.length - 1); drawChart(); } @@ -1331,19 +1536,61 @@ state.chartAccount = el.chartAccount.value; updateOverview(); syncChartQuery(); + const request = ++state.chartRequest; + const account = state.chartAccount, + days = state.chartDays; + $("performance").dataset.loading = "true"; + $("performance").setAttribute("aria-busy", "true"); + $("exportHistory").disabled = true; + $("historyCursor").disabled = true; + // 계좌가 바뀌면 이전 숫자를 남기지 않는다. 오래 걸린 이전 응답도 폐기한다. + if (state.chartDataAccount !== account) { + el.chartWrap.hidden = true; + el.chartEmpty.hidden = false; + el.chartEmpty.innerHTML = + "선택한 계좌를 불러오는 중입니다.날짜별 성과 기록을 확인하고 있습니다."; + } try { - const data = await fetchJson( - `/api/snapshots?days=${state.chartDays}&account_key=${encodeURIComponent(state.chartAccount || "")}`, - { timeout: 15_000, key: "chart" }, - ); - updateChart((data && data.snapshots) || []); + const basket = chartBasket(); + if ( + basket && + (state.seriesStatus.get(account) !== "ready" || + state.flowStatus.get(basket.basket) !== "ready") + ) + throw new Error( + "수익률 계산에 필요한 전체 기록을 확인하지 못했습니다.", + ); + const cached = state.series.get(account); + // 전체 기록은 갱신 주기에 한 번 읽는다. 기간·계좌 변경 때 같은 데이터를 다시 받지 않는다. + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + cutoff.setHours(0, 0, 0, 0); + const data = cached + ? { snapshots: cached.filter((row) => parseDate(row.date) >= cutoff) } + : await fetchJson( + `/api/snapshots?days=${days}&account_key=${encodeURIComponent(account || "")}`, + { timeout: 15_000, key: "chart" }, + ); + if ( + request !== state.chartRequest || + account !== state.chartAccount || + days !== state.chartDays + ) + return; + updateChart(data?.snapshots || []); } catch { + if (request !== state.chartRequest) return; el.chartSummary.textContent = - "성과 기록을 불러오지 못했습니다. 연결을 확인한 뒤 다시 시도하세요."; + "성과 기록을 읽지 못했습니다. 운영 영역의 다시 확인을 눌러주세요."; el.chartWrap.hidden = true; el.chartEmpty.hidden = false; el.chartEmpty.innerHTML = - "성과 기록을 읽지 못했습니다.계좌 기록은 바뀌지 않았습니다. 잠시 후 다시 확인하세요."; + "성과 기록을 읽지 못했습니다.계좌 기록은 바뀌지 않았습니다. 연결 상태를 확인해 주세요."; + } finally { + if (request === state.chartRequest) { + $("performance").dataset.loading = "false"; + $("performance").setAttribute("aria-busy", "false"); + } } } @@ -1864,7 +2111,8 @@ '
포트폴리오를 불러오지 못했습니다.이전 화면의 숫자는 최신이 아닐 수 있습니다.
'; } else if ( results.some((r) => r.status === "rejected") || - state.flowError + state.flowError || + [...state.seriesStatus.values()].some((status) => status === "error") ) { state.coreStatus = "partial"; } else { @@ -1968,6 +2216,9 @@ $("depositCancelButton").addEventListener("click", closeDeposit); el.depositBack.addEventListener("click", showDepositFields); $("retryButton").addEventListener("click", () => refreshAll(true)); + $("priorityNotice").addEventListener("click", () => + el.decisionAction.click(), + ); el.decisionAction.addEventListener("click", () => { const action = el.decisionAction.dataset.action; @@ -1995,7 +2246,23 @@ }); el.chartEquity.addEventListener("pointermove", handleChartPointer); el.chartEquity.addEventListener("pointerdown", handleChartPointer); - el.chartEquity.addEventListener("pointerleave", hideChartTip); + $("historyCursor").addEventListener("input", (event) => + selectHistory(Number(event.target.value)), + ); + $("historyLatest").addEventListener("click", () => + selectHistory((chart.series?.rows.length || 1) - 1), + ); + $("exportHistory").addEventListener("click", exportHistory); + $("historyDetails").addEventListener("toggle", () => { + if ($("historyDetails").open && chart.series) drawDrawdown(chart.series); + }); + $("chartView").addEventListener("click", (event) => { + const button = event.target.closest("button[data-view]"); + if (button) setChartView(button.dataset.view); + }); + reducedMotion.addEventListener("change", () => { + if (reducedMotion.matches) setChartView(chart.view); + }); document.querySelectorAll("[data-amount]").forEach((button) => { button.addEventListener("click", () => { @@ -2059,7 +2326,7 @@ wireEvents(); initScrollSpy(); if ("ResizeObserver" in window) { - new ResizeObserver(() => drawChart()).observe(el.chartBox); + new ResizeObserver(scheduleChartPaint).observe(el.chartBox); } document .querySelectorAll("#chartRange button") @@ -2069,6 +2336,7 @@ String(Number(b.dataset.days) === state.chartDays), ), ); + document.fonts?.ready.then(scheduleChartPaint); await refreshAll(true); window.setInterval(() => refreshCore(), 30_000); window.setInterval(() => refreshSlow(), 60_000); diff --git a/monitoring/static/nungum-symbol.svg b/monitoring/static/nungum-symbol.svg index a1fb743..bd42653 100644 --- a/monitoring/static/nungum-symbol.svg +++ b/monitoring/static/nungum-symbol.svg @@ -1,6 +1,6 @@ 눈금 서로 마주 보는 니은과 기역으로 기준과 기록의 분리를 나타낸 심볼 - - + + diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html index 5d4714e..cb13e4f 100644 --- a/monitoring/templates/dashboard.html +++ b/monitoring/templates/dashboard.html @@ -6,7 +6,7 @@ name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> - + 눈금 | 투자 현황 - - -
- 내 투자 / 계좌 현황
+
-

계좌 현황

-

투자한 금액과 수익, 보유 종목을 확인하세요.

+

국내 주식과 ETF

+

나의 투자 기록

+
+
+ 마지막 자산 기록
-
+
+
+
+

수익의 흐름

+

입출금을 제외한 시간가중수익률

+
+
+ +
+
+
+ +
+ +
+

+
+
+
+
+

+ 최근 기록 +

+

이날까지의 수익률

+ — +
+
+
평가금액
+
—
+
+
+
투자원금
+
—
+
+
+
고점 대비
+
—
+
+
+

+ 날짜 눈금을 움직여 기록을 살펴보세요. +

+
+
+
+ 날짜별 누적 시간가중수익률. 아래 날짜 슬라이더나 표로도 확인할 + 수 있습니다. 띠의 폭은 지표를 나타내지 않습니다. +
+
+ + +
+
+ 누적 수익률수익률 0%적립 기록 +
+
+
+
+
+ — +
+
+ +
+
+ —— +
+
+
+ 월별 수익률 +
+
+
+ +
+

성과 기록을 불러오는 중입니다.

+ +
+
계좌 현황 id="overview" >
-

총자산

+

최근 총자산

—계좌를 불러오는 중입니다.

현금 —

@@ -152,124 +265,46 @@

계좌 현황

+
+ 낙폭과 일별 기록 자세히 보기 +
+
+ 고점 대비 하락률 입출금을 제외한 수익률 기준 +
+
+ +
+
+
+ + + + + + + + + + + +
+ 선택한 기간의 평가금액과 시간가중수익률 +
날짜평가금액투자원금시간가중수익률
+
+
-
-
-

자산 추이

-

- 성과 기록을 불러오는 중입니다. -

-
- -
- -
- - - - -
-

-
- -
-
-
- 평가금액투자원금 -
-
- - -
-
-
-
- 고점 대비 하락률 입출금을 제외한 수익률 기준 -
-
- -
-
-
-
- -

- 점선은 투자원금입니다. 차트에 마우스를 올리거나 터치하면 해당 - 날짜의 금액을 볼 수 있습니다. -

- -
- 표로 보기 -
- - - - - - - - - - - -
- 선택한 기간의 평가금액과 시간가중수익률 -
날짜평가금액투자원금시간가중수익률
-
-
-
-

보유 종목

@@ -446,6 +481,12 @@

이전 기본 계좌

diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py index 4728dd6..395fa71 100644 --- a/monitoring/web_dashboard.py +++ b/monitoring/web_dashboard.py @@ -200,7 +200,7 @@ def get_baskets_json() -> dict: # 리스크 오버레이(추세 필터·낙폭 제어)가 켜진 바스켓은 마지막 실행이 남긴 배수를 # 곱한 '적용 비중'이 그날의 목표다. 화면의 목표 비중·목표 범위 판정은 적용 비중을 쓴다. overlay_cfg = parse_overlay_config(basket_config) - overlay_state = load_overlay_state(name) if overlay_cfg.any_enabled else None + overlay_state = load_overlay_state(name, mode=ledger_mode) if overlay_cfg.any_enabled else None scale = float((overlay_state or {}).get("scale", 1.0)) target_weights = overlay_target_weights( basket_config.get("holdings") or {}, base_fraction, scale, @@ -219,6 +219,10 @@ def get_baskets_json() -> dict: "trend_filter": overlay_cfg.trend.enabled, "drawdown_guard": overlay_cfg.drawdown.enabled, "volatility_target": overlay_cfg.volatility.enabled, + "trend_below": (overlay_state or {}).get("trend_below"), + "drawdown_active": (overlay_state or {}).get("drawdown_active"), + "defensive_symbol": (basket_config.get("overlays") or {}).get("defensive_symbol"), + "source_dates": (overlay_state or {}).get("source_dates") or {}, } # 종목별 목표 비중(총자산 대비) = 바스켓 내 비중 정규화 × 적용 투자 비중. # 현재가는 장부에 저장하지 않으므로 화면은 매입금액 기준 비중과 나란히 보여준다. diff --git a/reports/research/dashboard_performance_20260922.json b/reports/research/dashboard_performance_20260922.json new file mode 100644 index 0000000..ebfa778 --- /dev/null +++ b/reports/research/dashboard_performance_20260922.json @@ -0,0 +1,33 @@ +{ + "measured_on": "2026-09-22", + "tool": "Playwright, Chrome DevTools Protocol, PerformanceObserver", + "browser": "Chromium 153.0.8010.53", + "server": "localhost aiohttp dashboard", + "profile": { + "viewport": [390, 844], + "cpu_slowdown": 4, + "latency_ms": 150, + "download_bytes_per_second": 200000, + "upload_bytes_per_second": 93750, + "cache_disabled": true + }, + "measurements": { + "lcp_ms": 1156, + "cls": 0.01331540084314144, + "load_ms": 3206.699999999255, + "initial_long_tasks_ms": [125, 99, 346], + "largest_sampled_event_duration_ms": 56, + "idle_raf_requests_in_one_second": 0, + "css_transfer_bytes": 7620, + "css_decoded_bytes": 34991, + "js_transfer_bytes": 24596, + "js_decoded_bytes": 87940, + "font_decoded_bytes": 440912 + }, + "limits": [ + "로컬 서버 단일 측정, 실제 사용자 성능 집계 아님", + "Event Timing 표본은 입체·평면 전환과 날짜 슬라이더 키보드 조작이며 INP로 보고하지 않음", + "이후 오류 알림 배경색과 조회 실패 상태의 소규모 수정으로 최종 파일 크기는 다를 수 있음", + "모든 기기에서 지연이 없다는 보장이 아님" + ] +} diff --git a/reports/research/implementation_benchmark_20260922.json b/reports/research/implementation_benchmark_20260922.json new file mode 100644 index 0000000..1ea37d2 --- /dev/null +++ b/reports/research/implementation_benchmark_20260922.json @@ -0,0 +1,78 @@ +{ + "date": "2026-09-22", + "baseline_commit": "9a7c145", + "input_as_of": "2026-09-17", + "input_sha256": { + "069500": "1ef75c687434ead55cc128f9f933ca589bb4eff2a3390ad3dd104b44822ad933", + "357870": "fbc29674729fc175cf2f1ff78fee361a96a8cfed0279b9fe7cb3b444562cf810", + "KS200": "3834a4d6f22a7278ae4327cd5aa88337681ba0db6e877ff9ede325ebfded70f0" + }, + "python": "3.12.10 (tags/v3.12.10:0cc8128, Apr 8 2025, 12:21:36) [MSC v.1943 64 bit (AMD64)]", + "rows": 3119, + "repetitions": 3, + "policies": { + "static": { + "seconds": { + "before": [ + 0.23429969999779132, + 0.23586300000170013, + 0.21607280000171158 + ], + "after": [ + 0.0977578000020003, + 0.05446730000039679, + 0.050483499999245396 + ] + }, + "median_seconds": { + "before": 0.23429969999779132, + "after": 0.05446730000039679 + }, + "speedup": 4.301658058983729, + "all_daily_values_exactly_equal": true, + "output_rows": 1520 + }, + "old_product": { + "seconds": { + "before": [ + 0.3308458999999857, + 0.27771930000017164, + 0.26015770000230987 + ], + "after": [ + 0.09260569999969448, + 0.08979030000045896, + 0.0883538000016415 + ] + }, + "median_seconds": { + "before": 0.27771930000017164, + "after": 0.08979030000045896 + }, + "speedup": 3.092976635546959, + "all_daily_values_exactly_equal": true, + "output_rows": 1520 + }, + "minimum": { + "seconds": { + "before": [ + 0.27083330000095884, + 0.30301369999870076, + 0.2586730000002717 + ], + "after": [ + 0.07846160000190139, + 0.07897890000094776, + 0.07652740000048652 + ] + }, + "median_seconds": { + "before": 0.27083330000095884, + "after": 0.07846160000190139 + }, + "speedup": 3.4517942534232753, + "all_daily_values_exactly_equal": true, + "output_rows": 1520 + } + } +} \ No newline at end of file diff --git a/reports/research/risk_review_20260922.json b/reports/research/risk_review_20260922.json new file mode 100644 index 0000000..0e5507f --- /dev/null +++ b/reports/research/risk_review_20260922.json @@ -0,0 +1,858 @@ +{ + "as_of": "2026-09-22", + "last_complete_bar": "2026-09-17", + "integer_start": "2020-07-07", + "source": "FinanceDataReader, close prices", + "data_audit": { + "common_last_bar": "2026-09-17", + "latest_available": { + "069500": "2026-09-21", + "357870": "2026-09-21", + "KS200": "2026-09-17" + }, + "excluded_after_common_bar": { + "069500": 2, + "357870": 2, + "KS200": 0 + } + }, + "data": { + "069500": { + "first": "2014-07-03", + "last": "2026-09-17", + "rows": 2997, + "sha256": "582287f5881d931b2b969b219acce9e745d8ee35a782941779e9ed584d3f53ad" + }, + "357870": { + "first": "2020-07-07", + "last": "2026-09-17", + "rows": 1521, + "sha256": "719280069d47c59ef5aaf41dd9e77b37b1b6162d567971f0198554a0ee91f26d" + }, + "KS200": { + "first": "2014-01-02", + "last": "2026-09-17", + "rows": 3120, + "sha256": "b8f9f5ad01620955d09ec54f3d2a564a11fb510f2ed234faf1078dc505cf7314" + } + }, + "integer_etf": { + "static": { + "label": "고정 비중", + "all": { + "years": 6.2, + "cagr_pct": 13.53, + "period_return_pct": 119.47, + "vol_pct": 13.81, + "sharpe": 0.79, + "mdd_pct": -21.72, + "calmar": 0.62, + "worst_year_pct": -10.48, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 41.3, + "avg_exposure_pct": 46.8, + "turnover_per_year_pct": 39.5, + "final_value": 13873421, + "contributed": 7700000, + "profit": 6173421, + "yearly": { + "2020": 15.54, + "2021": 1.67, + "2022": -10.48, + "2023": 13.15, + "2024": -2.83, + "2025": 40.61, + "2026": 35.0 + } + }, + "periods": { + "2020_2022": { + "years": 2.48, + "cagr_pct": 2.05, + "period_return_pct": 5.16, + "vol_pct": 8.26, + "sharpe": -0.07, + "mdd_pct": -16.25, + "calmar": 0.13, + "worst_year_pct": -10.48, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 40.0, + "avg_exposure_pct": 45.7, + "turnover_per_year_pct": 67.7, + "final_value": 3036402, + "contributed": 3200000, + "profit": -163598, + "yearly": { + "2020": 15.54, + "2021": 1.67, + "2022": -10.48 + } + }, + "2023_2025": { + "years": 2.99, + "cagr_pct": 15.67, + "period_return_pct": 54.6, + "vol_pct": 9.35, + "sharpe": 1.33, + "mdd_pct": -9.51, + "calmar": 1.65, + "worst_year_pct": -2.83, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 41.7, + "avg_exposure_pct": 47.3, + "turnover_per_year_pct": 28.0, + "final_value": 9563195, + "contributed": 6800000, + "profit": 2763195, + "yearly": { + "2023": 13.15, + "2024": -2.83, + "2025": 40.61 + } + }, + "2026": { + "years": 0.71, + "cagr_pct": 52.93, + "period_return_pct": 35.0, + "vol_pct": 32.45, + "sharpe": 1.4, + "mdd_pct": -21.72, + "calmar": 2.44, + "worst_year_pct": 35.0, + "losing_years": 0, + "total_years": 1, + "negative_months_pct": 44.4, + "avg_exposure_pct": 48.4, + "turnover_per_year_pct": 46.9, + "final_value": 13873421, + "contributed": 7700000, + "profit": 6173421, + "yearly": { + "2026": 35.0 + } + } + }, + "triple_cost": { + "years": 6.2, + "cagr_pct": 13.41, + "period_return_pct": 118.06, + "vol_pct": 13.88, + "sharpe": 0.78, + "mdd_pct": -21.75, + "calmar": 0.62, + "worst_year_pct": -10.75, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 42.7, + "avg_exposure_pct": 46.9, + "turnover_per_year_pct": 39.6, + "final_value": 13846782, + "contributed": 7700000, + "profit": 6146782, + "yearly": { + "2020": 15.33, + "2021": 1.58, + "2022": -10.75, + "2023": 13.15, + "2024": -2.84, + "2025": 40.35, + "2026": 35.17 + } + } + }, + "old_product": { + "label": "기존 방식", + "all": { + "years": 6.2, + "cagr_pct": 10.73, + "period_return_pct": 88.09, + "vol_pct": 10.85, + "sharpe": 0.74, + "mdd_pct": -16.02, + "calmar": 0.67, + "worst_year_pct": -5.67, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 42.7, + "avg_exposure_pct": 29.4, + "turnover_per_year_pct": 184.5, + "final_value": 12254639, + "contributed": 7700000, + "profit": 4554639, + "yearly": { + "2020": 15.54, + "2021": 1.75, + "2022": -5.67, + "2023": 3.63, + "2024": -4.68, + "2025": 28.73, + "2026": 33.38 + } + }, + "periods": { + "2020_2022": { + "years": 2.48, + "cagr_pct": 4.26, + "period_return_pct": 10.9, + "vol_pct": 6.15, + "sharpe": 0.23, + "mdd_pct": -10.05, + "calmar": 0.42, + "worst_year_pct": -5.67, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 43.3, + "avg_exposure_pct": 31.9, + "turnover_per_year_pct": 74.5, + "final_value": 3160711, + "contributed": 3200000, + "profit": -39289, + "yearly": { + "2020": 15.54, + "2021": 1.75, + "2022": -5.67 + } + }, + "2023_2025": { + "years": 2.99, + "cagr_pct": 8.36, + "period_return_pct": 27.16, + "vol_pct": 6.4, + "sharpe": 0.86, + "mdd_pct": -7.86, + "calmar": 1.06, + "worst_year_pct": -4.68, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 41.7, + "avg_exposure_pct": 24.8, + "turnover_per_year_pct": 132.4, + "final_value": 8461683, + "contributed": 6800000, + "profit": 1661683, + "yearly": { + "2023": 3.63, + "2024": -4.68, + "2025": 28.73 + } + }, + "2026": { + "years": 0.71, + "cagr_pct": 50.34, + "period_return_pct": 33.38, + "vol_pct": 26.78, + "sharpe": 1.57, + "mdd_pct": -16.02, + "calmar": 3.14, + "worst_year_pct": 33.38, + "losing_years": 0, + "total_years": 1, + "negative_months_pct": 44.4, + "avg_exposure_pct": 39.6, + "turnover_per_year_pct": 349.9, + "final_value": 12254639, + "contributed": 7700000, + "profit": 4554639, + "yearly": { + "2026": 33.38 + } + } + }, + "triple_cost": { + "years": 6.2, + "cagr_pct": 10.27, + "period_return_pct": 83.21, + "vol_pct": 10.8, + "sharpe": 0.71, + "mdd_pct": -16.11, + "calmar": 0.64, + "worst_year_pct": -5.91, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 42.7, + "avg_exposure_pct": 28.8, + "turnover_per_year_pct": 185.7, + "final_value": 12051796, + "contributed": 7700000, + "profit": 4351796, + "yearly": { + "2020": 15.33, + "2021": 1.63, + "2022": -5.91, + "2023": 3.26, + "2024": -5.4, + "2025": 27.59, + "2026": 33.29 + } + } + }, + "minimum": { + "label": "주식만 조절·중복 축소 방지", + "all": { + "years": 6.2, + "cagr_pct": 12.96, + "period_return_pct": 112.82, + "vol_pct": 11.25, + "sharpe": 0.9, + "mdd_pct": -15.74, + "calmar": 0.82, + "worst_year_pct": -4.12, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 40.0, + "avg_exposure_pct": 36.7, + "turnover_per_year_pct": 197.6, + "final_value": 13322783, + "contributed": 7700000, + "profit": 5622783, + "yearly": { + "2020": 15.54, + "2021": 1.9, + "2022": -4.12, + "2023": 7.47, + "2024": -3.96, + "2025": 36.66, + "2026": 33.66 + } + }, + "periods": { + "2020_2022": { + "years": 2.48, + "cagr_pct": 5.01, + "period_return_pct": 12.88, + "vol_pct": 6.26, + "sharpe": 0.35, + "mdd_pct": -9.21, + "calmar": 0.54, + "worst_year_pct": -4.12, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 36.7, + "avg_exposure_pct": 33.0, + "turnover_per_year_pct": 82.8, + "final_value": 3208413, + "contributed": 3200000, + "profit": 8413, + "yearly": { + "2020": 15.54, + "2021": 1.9, + "2022": -4.12 + } + }, + "2023_2025": { + "years": 2.99, + "cagr_pct": 12.18, + "period_return_pct": 41.05, + "vol_pct": 8.09, + "sharpe": 1.14, + "mdd_pct": -8.8, + "calmar": 1.38, + "worst_year_pct": -3.96, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 41.7, + "avg_exposure_pct": 39.3, + "turnover_per_year_pct": 157.9, + "final_value": 9242724, + "contributed": 6800000, + "profit": 2442724, + "yearly": { + "2023": 7.47, + "2024": -3.96, + "2025": 36.66 + } + }, + "2026": { + "years": 0.71, + "cagr_pct": 50.79, + "period_return_pct": 33.66, + "vol_pct": 26.23, + "sharpe": 1.61, + "mdd_pct": -15.74, + "calmar": 3.23, + "worst_year_pct": 33.66, + "losing_years": 0, + "total_years": 1, + "negative_months_pct": 44.4, + "avg_exposure_pct": 38.9, + "turnover_per_year_pct": 333.7, + "final_value": 13322783, + "contributed": 7700000, + "profit": 5622783, + "yearly": { + "2026": 33.66 + } + } + }, + "triple_cost": { + "years": 6.2, + "cagr_pct": 12.67, + "period_return_pct": 109.44, + "vol_pct": 11.28, + "sharpe": 0.88, + "mdd_pct": -15.73, + "calmar": 0.81, + "worst_year_pct": -4.18, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 40.0, + "avg_exposure_pct": 36.7, + "turnover_per_year_pct": 196.7, + "final_value": 13189337, + "contributed": 7700000, + "profit": 5489337, + "yearly": { + "2020": 15.33, + "2021": 1.75, + "2022": -4.18, + "2023": 6.99, + "2024": -4.06, + "2025": 36.66, + "2026": 32.8 + } + } + } + }, + "fractional_research": { + "static": { + "all": { + "years": 12.21, + "cagr_pct": 9.59, + "period_return_pct": 205.78, + "vol_pct": 12.06, + "sharpe": 0.59, + "mdd_pct": -21.73, + "calmar": 0.44, + "worst_year_pct": -11.21, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 41.5, + "avg_exposure_pct": 50.8, + "turnover_per_year_pct": 16.8, + "final_value": 35111316, + "contributed": 14900000, + "profit": 20211316, + "yearly": { + "2014": -1.76, + "2015": 1.53, + "2016": 6.54, + "2017": 14.43, + "2018": -7.33, + "2019": 8.81, + "2020": 21.35, + "2021": 3.06, + "2022": -11.21, + "2023": 13.89, + "2024": -3.34, + "2025": 44.43, + "2026": 41.27 + } + }, + "zero_cash_yield": { + "years": 12.21, + "cagr_pct": 8.04, + "period_return_pct": 157.12, + "vol_pct": 12.08, + "sharpe": 0.47, + "mdd_pct": -21.87, + "calmar": 0.37, + "worst_year_pct": -12.5, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 43.5, + "avg_exposure_pct": 50.9, + "turnover_per_year_pct": 16.5, + "final_value": 31877710, + "contributed": 14900000, + "profit": 16977710, + "yearly": { + "2014": -2.48, + "2015": 0.06, + "2016": 5.02, + "2017": 12.82, + "2018": -8.67, + "2019": 7.27, + "2020": 19.57, + "2021": 1.62, + "2022": -12.5, + "2023": 12.3, + "2024": -4.7, + "2025": 42.46, + "2026": 40.03 + } + }, + "periods": { + "2014_2018": { + "years": 4.49, + "cagr_pct": 2.7, + "period_return_pct": 12.68, + "vol_pct": 6.06, + "sharpe": -0.01, + "mdd_pct": -10.33, + "calmar": 0.26, + "worst_year_pct": -7.33, + "losing_years": 2, + "total_years": 5, + "negative_months_pct": 40.7, + "avg_exposure_pct": 49.9, + "turnover_per_year_pct": 19.6, + "final_value": 5897972, + "contributed": 5600000, + "profit": 297972, + "yearly": { + "2014": -1.76, + "2015": 1.53, + "2016": 6.54, + "2017": 14.43, + "2018": -7.33 + } + }, + "2019_2022": { + "years": 3.99, + "cagr_pct": 4.86, + "period_return_pct": 20.83, + "vol_pct": 10.25, + "sharpe": 0.23, + "mdd_pct": -18.04, + "calmar": 0.27, + "worst_year_pct": -11.21, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 41.7, + "avg_exposure_pct": 50.8, + "turnover_per_year_pct": 10.5, + "final_value": 12063338, + "contributed": 10400000, + "profit": 1663338, + "yearly": { + "2019": 8.81, + "2020": 21.35, + "2021": 3.06, + "2022": -11.21 + } + }, + "2023_2026": { + "years": 3.71, + "cagr_pct": 24.39, + "period_return_pct": 124.6, + "vol_pct": 17.91, + "sharpe": 1.18, + "mdd_pct": -21.73, + "calmar": 1.12, + "worst_year_pct": -3.34, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 42.2, + "avg_exposure_pct": 51.9, + "turnover_per_year_pct": 19.7, + "final_value": 35111316, + "contributed": 14900000, + "profit": 20211316, + "yearly": { + "2023": 13.89, + "2024": -3.34, + "2025": 44.43, + "2026": 41.27 + } + } + } + }, + "old_product": { + "all": { + "years": 12.21, + "cagr_pct": 8.63, + "period_return_pct": 174.77, + "vol_pct": 9.27, + "sharpe": 0.64, + "mdd_pct": -16.42, + "calmar": 0.53, + "worst_year_pct": -4.57, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 39.5, + "avg_exposure_pct": 38.2, + "turnover_per_year_pct": 72.2, + "final_value": 32522853, + "contributed": 14900000, + "profit": 17622853, + "yearly": { + "2014": -2.01, + "2015": 0.69, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49, + "2019": 5.33, + "2020": 12.72, + "2021": 3.92, + "2022": -4.27, + "2023": 7.87, + "2024": -4.57, + "2025": 40.75, + "2026": 38.94 + } + }, + "zero_cash_yield": { + "years": 12.21, + "cagr_pct": 5.89, + "period_return_pct": 101.04, + "vol_pct": 8.68, + "sharpe": 0.37, + "mdd_pct": -16.44, + "calmar": 0.36, + "worst_year_pct": -5.92, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 43.5, + "avg_exposure_pct": 32.9, + "turnover_per_year_pct": 68.8, + "final_value": 26696411, + "contributed": 14900000, + "profit": 11796411, + "yearly": { + "2014": -2.85, + "2015": -1.36, + "2016": 4.35, + "2017": 12.82, + "2018": -5.47, + "2019": 3.26, + "2020": 7.86, + "2021": 2.13, + "2022": -5.92, + "2023": 3.04, + "2024": -3.09, + "2025": 30.3, + "2026": 35.37 + } + }, + "periods": { + "2014_2018": { + "years": 4.49, + "cagr_pct": 3.28, + "period_return_pct": 15.56, + "vol_pct": 4.59, + "sharpe": 0.09, + "mdd_pct": -6.38, + "calmar": 0.51, + "worst_year_pct": -3.49, + "losing_years": 2, + "total_years": 5, + "negative_months_pct": 37.0, + "avg_exposure_pct": 38.1, + "turnover_per_year_pct": 31.7, + "final_value": 6112778, + "contributed": 5600000, + "profit": 512778, + "yearly": { + "2014": -2.01, + "2015": 0.69, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49 + } + }, + "2019_2022": { + "years": 3.99, + "cagr_pct": 4.26, + "period_return_pct": 18.12, + "vol_pct": 6.98, + "sharpe": 0.21, + "mdd_pct": -11.84, + "calmar": 0.36, + "worst_year_pct": -4.27, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 41.7, + "avg_exposure_pct": 34.7, + "turnover_per_year_pct": 38.3, + "final_value": 12335096, + "contributed": 10400000, + "profit": 1935096, + "yearly": { + "2019": 5.33, + "2020": 12.72, + "2021": 3.92, + "2022": -4.27 + } + }, + "2023_2026": { + "years": 3.71, + "cagr_pct": 20.77, + "period_return_pct": 101.29, + "vol_pct": 14.31, + "sharpe": 1.22, + "mdd_pct": -16.42, + "calmar": 1.27, + "worst_year_pct": -4.57, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 40.0, + "avg_exposure_pct": 42.2, + "turnover_per_year_pct": 99.8, + "final_value": 32522853, + "contributed": 14900000, + "profit": 17622853, + "yearly": { + "2023": 7.87, + "2024": -4.57, + "2025": 40.75, + "2026": 38.94 + } + } + } + }, + "minimum": { + "all": { + "years": 12.21, + "cagr_pct": 9.07, + "period_return_pct": 188.57, + "vol_pct": 9.41, + "sharpe": 0.68, + "mdd_pct": -16.42, + "calmar": 0.55, + "worst_year_pct": -4.58, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 39.5, + "avg_exposure_pct": 38.7, + "turnover_per_year_pct": 71.1, + "final_value": 33450972, + "contributed": 14900000, + "profit": 18550972, + "yearly": { + "2014": -2.01, + "2015": 0.69, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49, + "2019": 5.33, + "2020": 18.42, + "2021": 3.91, + "2022": -4.28, + "2023": 7.85, + "2024": -4.58, + "2025": 40.76, + "2026": 38.94 + } + }, + "zero_cash_yield": { + "years": 12.21, + "cagr_pct": 6.37, + "period_return_pct": 112.63, + "vol_pct": 9.12, + "sharpe": 0.41, + "mdd_pct": -16.4, + "calmar": 0.39, + "worst_year_pct": -6.35, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 43.5, + "avg_exposure_pct": 36.5, + "turnover_per_year_pct": 68.1, + "final_value": 27296390, + "contributed": 14900000, + "profit": 12396390, + "yearly": { + "2014": -2.85, + "2015": -1.36, + "2016": 4.35, + "2017": 12.82, + "2018": -5.47, + "2019": 3.26, + "2020": 15.22, + "2021": 2.67, + "2022": -6.35, + "2023": 6.07, + "2024": -6.21, + "2025": 33.89, + "2026": 30.83 + } + }, + "periods": { + "2014_2018": { + "years": 4.49, + "cagr_pct": 3.28, + "period_return_pct": 15.56, + "vol_pct": 4.59, + "sharpe": 0.09, + "mdd_pct": -6.38, + "calmar": 0.51, + "worst_year_pct": -3.49, + "losing_years": 2, + "total_years": 5, + "negative_months_pct": 37.0, + "avg_exposure_pct": 38.1, + "turnover_per_year_pct": 31.7, + "final_value": 6112778, + "contributed": 5600000, + "profit": 512778, + "yearly": { + "2014": -2.01, + "2015": 0.69, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49 + } + }, + "2019_2022": { + "years": 3.99, + "cagr_pct": 5.55, + "period_return_pct": 24.07, + "vol_pct": 7.54, + "sharpe": 0.37, + "mdd_pct": -11.83, + "calmar": 0.47, + "worst_year_pct": -4.28, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 41.7, + "avg_exposure_pct": 36.3, + "turnover_per_year_pct": 34.2, + "final_value": 12797358, + "contributed": 10400000, + "profit": 2397358, + "yearly": { + "2019": 5.33, + "2020": 18.42, + "2021": 3.91, + "2022": -4.28 + } + }, + "2023_2026": { + "years": 3.71, + "cagr_pct": 20.77, + "period_return_pct": 101.27, + "vol_pct": 14.31, + "sharpe": 1.22, + "mdd_pct": -16.42, + "calmar": 1.26, + "worst_year_pct": -4.58, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 40.0, + "avg_exposure_pct": 42.1, + "turnover_per_year_pct": 99.9, + "final_value": 33450972, + "contributed": 14900000, + "profit": 18550972, + "yearly": { + "2023": 7.85, + "2024": -4.58, + "2025": 40.76, + "2026": 38.94 + } + } + } + } + }, + "limitations": [ + "동일 기간을 이미 살펴본 사후 검증이며 독립적인 미사용 표본이 아님", + "ETF 분배금 미포함; 현금 이자 0%, CD ETF 양의 매매차익 15.4% 상한 과세", + "다음 거래일 종가에 비용을 더한 근사 체결; 실시간 호가·괴리율·미체결 미재현", + "소수 주 연구의 현금금리는 연 3% 고정 가정; 금리 0% 민감도도 공개", + "부분 연도는 연도 전체 수익률이 아님; 실전 자동 전환 없음", + "자료별 마지막 날짜가 다르면 공통 확정일까지 비교; 뒤 날짜는 제외" + ] +} \ No newline at end of file diff --git a/tests/test_basket_overlay_integration.py b/tests/test_basket_overlay_integration.py index 759dca3..59d7bd7 100644 --- a/tests/test_basket_overlay_integration.py +++ b/tests/test_basket_overlay_integration.py @@ -14,7 +14,7 @@ import pandas as pd import pytest -from core.basket_rebalancer import BasketRebalancer +from core.basket_rebalancer import BasketRebalancer, _KST from core.risk_overlays import load_overlay_state, save_overlay_state, OverlayDecision @@ -29,10 +29,21 @@ def fetch_korean_stock(self, symbol, start_date=None, end_date=None): self.calls.append((symbol, start_date, end_date)) if self.closes is None: return pd.DataFrame() - dates = pd.date_range(end="2020-01-01", periods=len(self.closes), freq="B") + dates = pd.date_range(end=_previous_session(), periods=len(self.closes), freq="B") return pd.DataFrame({"date": dates, "close": self.closes}) +def _previous_session(): + from datetime import datetime, timedelta + from zoneinfo import ZoneInfo + from core.trading_hours import TradingHours + day = datetime.now(ZoneInfo("Asia/Seoul")) - timedelta(days=1) + calendar = TradingHours() + while not calendar.is_trading_day(day): + day -= timedelta(days=1) + return day.date() + + def _basket(overlays=None, target=0.6): cfg = { "name": "t", "enabled": True, "initial_capital": 1_000_000, @@ -49,7 +60,8 @@ def _make(basket_cfg, closes, cumulative=None): with patch.object(BasketRebalancer, "_load_baskets_config", return_value={"t": basket_cfg}): rb = BasketRebalancer(basket_name="t") rb.data_collector = _Collector(closes) - frame = pd.DataFrame({"cumulative_return": cumulative}) if cumulative is not None else pd.DataFrame() + frame = pd.DataFrame({"date": pd.bdate_range(end=_previous_session(), periods=len(cumulative)), + "cumulative_return": cumulative}) if cumulative is not None else pd.DataFrame() rb._nav_frame = frame return rb @@ -83,7 +95,7 @@ def test_below_ma_halves_target_and_saves_state(self, _isolated_state): assert decision.trend_below is True and decision.scale == pytest.approx(0.5) assert rb._stock_fraction() == pytest.approx(0.3) assert rb.data_collector.calls[0][0] == "KS200" - state = load_overlay_state("t") + state = load_overlay_state("t", mode="paper") assert state["scale"] == pytest.approx(0.5) and state["trend_below"] is True # 같은 인스턴스에서는 한 번만 계산한다(plan→execute 사이클 내 동일 값) assert rb.overlay_decision() is decision and len(rb.data_collector.calls) == 1 @@ -125,7 +137,7 @@ def test_today_bar_is_excluded(self): closes = [100.0] * 200 + [50.0] rb = _make(_basket(self.overlays), closes) today = datetime.now().strftime("%Y-%m-%d") - dates = list(pd.date_range(end=today, periods=len(closes), freq="D")) + dates = list(pd.bdate_range(end=_previous_session(), periods=200)) + [pd.Timestamp(today)] rb.data_collector.fetch_korean_stock = lambda symbol, start_date=None, end_date=None: pd.DataFrame({"date": dates, "close": closes}) got = rb._fetch_index_closes("KS200", 200) assert got[-1] == 100.0 and len(got) == 200 @@ -171,7 +183,7 @@ def test_future_index_bar_and_unsorted_rows_cannot_change_signal(): rb = _make(cfg, closes=[]) from datetime import datetime, timedelta future = datetime.now() + timedelta(days=2) - dates = list(pd.date_range(end="2020-01-01", periods=200, freq="B")) + [future] + dates = list(pd.date_range(end=_previous_session(), periods=200, freq="B")) + [future] frame = pd.DataFrame({"date":dates,"close":[100.]*200 + [10000.]}) rb.data_collector.fetch_korean_stock = lambda *a: frame.iloc[::-1] assert rb._fetch_index_closes("KS200", 200) == [100.]*200 @@ -181,7 +193,57 @@ def test_nav_ignores_today_and_future_snapshots(): cfg = _basket({"drawdown_guard": {"enabled": True}}) rb = _make(cfg, closes=[], cumulative=[0., -12., 100.]) from datetime import datetime, timedelta - today = datetime.now().date() - rb._nav_frame["date"] = [today-timedelta(days=2), today-timedelta(days=1), today] + today = datetime.now(_KST).date() + previous = _previous_session() + rb._nav_frame["date"] = [previous-timedelta(days=1), previous, today] with _patch_snapshots(rb): assert rb.overlay_decision().drawdown_active is True + + +def test_stale_index_cannot_release_defensive_allocation(): + save_overlay_state("t", OverlayDecision(scale=.5, trend_below=True), mode="paper") + rb = _make(_basket(TestTrendFilter.overlays), [100.] * 199 + [130.]) + stale = pd.DataFrame({"date": pd.bdate_range(end="2020-01-01", periods=200), + "close": [100.] * 199 + [130.]}) + rb.data_collector.fetch_korean_stock = lambda *args: stale + decision = rb.overlay_decision() + assert decision.scale == .5 and decision.trend_below is True + assert any("필요한 기준일" in issue for issue in decision.data_issues) + assert decision.source_dates["지수 종가"] == "2020-01-01" + + +@pytest.mark.parametrize("fault", ["missing_date", "invalid_date", "duplicate_date", "stale_date"]) +def test_bad_nav_dates_cannot_hide_a_loss_or_release_guard(fault): + save_overlay_state("t", OverlayDecision(scale=.5, drawdown_active=True), mode="paper") + rb = _make(_basket(TestDrawdownGuard.overlays), None, [0., -12., 0.]) + if fault == "missing_date": + rb._nav_frame = rb._nav_frame.drop(columns=["date"]) + elif fault == "invalid_date": + rb._nav_frame["date"] = rb._nav_frame["date"].astype(object) + rb._nav_frame.loc[1, "date"] = "잘못된 날짜" + elif fault == "duplicate_date": + rb._nav_frame.loc[1, "date"] = rb._nav_frame.loc[2, "date"] + else: + rb._nav_frame["date"] -= pd.Timedelta(days=30) + with _patch_snapshots(rb): + decision = rb.overlay_decision() + assert decision.scale == .5 and decision.drawdown_active is True + assert decision.data_issues + + +def test_previous_session_skips_weekend_and_configured_holiday(): + from datetime import datetime + rb = _make(_basket(TestTrendFilter.overlays), []) + # 월요일 8월 17일이 휴장일이라고 가정하면 화요일 판단의 종가는 금요일이다. + with patch("core.basket_rebalancer.datetime") as clock, patch("core.trading_hours.TradingHours") as calendar: + clock.now.return_value = datetime(2026, 8, 18, 9) + calendar.return_value.is_trading_day.side_effect = lambda day: day.weekday() < 5 and day.day != 17 + assert str(rb._overlay_previous_session()) == "2026-08-14" + + +def test_cached_decision_is_recomputed_on_new_day(): + rb = _make(_basket(TestTrendFilter.overlays), [100.] * 199 + [130.]) + first = rb.overlay_decision() + rb._overlay_evaluation_date = None + assert rb.overlay_decision() is not first + assert len(rb.data_collector.calls) == 2 diff --git a/tests/test_risk_overlays.py b/tests/test_risk_overlays.py index 655c3a4..c9dc03a 100644 --- a/tests/test_risk_overlays.py +++ b/tests/test_risk_overlays.py @@ -13,6 +13,7 @@ from core.risk_overlays import ( DrawdownGuardConfig, OverlayConfig, + OverlayDecision, TrendFilterConfig, VolatilityTargetConfig, applied_stock_fraction, @@ -176,6 +177,24 @@ def test_unknown_defensive_asset_is_not_silently_accepted(self): class TestState: + def test_paper_and_live_states_are_isolated(self, tmp_path): + save_overlay_state("basket", OverlayDecision(scale=.5, trend_below=True), tmp_path, mode="paper") + assert load_overlay_state("basket", tmp_path, mode="live") is None + save_overlay_state("basket", OverlayDecision(scale=1., trend_below=False), tmp_path, mode="live") + assert load_overlay_state("basket", tmp_path, mode="paper")["scale"] == .5 + assert load_overlay_state("basket", tmp_path, mode="live")["scale"] == 1. + + def test_legacy_state_is_only_used_for_paper_migration(self, tmp_path): + save_overlay_state("basket", OverlayDecision(scale=.5), tmp_path) + assert load_overlay_state("basket", tmp_path, mode="paper")["scale"] == .5 + assert load_overlay_state("basket", tmp_path, mode="live") is None + + @pytest.mark.parametrize("scale", [float("nan"), float("inf"), -1., 2., "broken"]) + def test_invalid_saved_scale_is_not_used(self, tmp_path, scale): + import json + (tmp_path / "basket.json").write_text(json.dumps({"scale": scale}), encoding="utf-8") + assert load_overlay_state("basket", tmp_path) is None + def test_roundtrip_and_applied_fraction(self, tmp_path): cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True}}}) d = compute_decision(cfg, index_closes=_closes(90.0), prev_state=None) diff --git a/tests/test_risk_review_data.py b/tests/test_risk_review_data.py new file mode 100644 index 0000000..c596878 --- /dev/null +++ b/tests/test_risk_review_data.py @@ -0,0 +1,55 @@ +"""서로 다른 자료의 마지막 날짜와 중간 누락을 숨기지 않는다.""" + +import pandas as pd +import pytest + +from tools.risk_review import align_report_inputs + + +def sources(): + dates = pd.to_datetime(["2026-09-16", "2026-09-17", "2026-09-18", "2026-09-21"]) + return { + symbol: pd.Series([100.0, 101.0, 102.0, 103.0], index=dates) + for symbol in ("069500", "357870", "KS200") + } + + +def test_lagging_index_limits_all_comparisons_and_records_exclusions(): + inputs = sources() + inputs["KS200"] = inputs["KS200"].iloc[:2] + aligned, panel, audit = align_report_inputs(inputs, "2026-09-22") + assert all( + values.index[-1] == pd.Timestamp("2026-09-17") for values in aligned.values() + ) + assert not panel.isna().any().any() + assert audit["latest_available"]["069500"] == "2026-09-21" + assert audit["excluded_after_common_bar"] == {"069500": 2, "357870": 2, "KS200": 0} + assert len(inputs["069500"]) == 4 + + +def test_incomplete_today_and_future_bars_are_never_used(): + aligned, _, audit = align_report_inputs(sources(), "2026-09-18") + assert all(len(values) == 2 for values in aligned.values()) + assert audit["common_last_bar"] == "2026-09-17" + + +def test_missing_internal_bar_stops_report_instead_of_forward_filling(): + inputs = sources() + inputs["KS200"] = inputs["KS200"].drop(pd.Timestamp("2026-09-17")) + with pytest.raises(ValueError, match="종가 누락: 2026-09-17"): + align_report_inputs(inputs, "2026-09-22") + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), 0.0, -1.0]) +def test_invalid_prices_stop_report(bad): + inputs = sources() + inputs["069500"].iloc[1] = bad + with pytest.raises(ValueError, match="종가와 날짜"): + align_report_inputs(inputs, "2026-09-22") + + +def test_nonoverlapping_sources_stop_with_explanation(): + inputs = sources() + inputs["KS200"].index = inputs["KS200"].index - pd.Timedelta(days=30) + with pytest.raises(ValueError, match="겹치는 비교 기간"): + align_report_inputs(inputs, "2026-09-22") diff --git a/tools/benchmark_risk_review.py b/tools/benchmark_risk_review.py new file mode 100644 index 0000000..4a00e96 --- /dev/null +++ b/tools/benchmark_risk_review.py @@ -0,0 +1,96 @@ +"""수정 전후의 모든 일별 결과를 대조하고 정수 주 백테스트 실행 시간을 잰다. + +로컬 가격 캐시를 사용하며 캐시가 없으면 공개 종가를 받는다. 주문은 실행하지 않는다. +기준 커밋을 읽을 수 있도록 해당 Git 이력이 필요하다. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import statistics +import subprocess +import sys +import time +import types +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +import pandas as pd + +from tools import risk_overlay_backtest as research +from tools import risk_review as current + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--baseline", default="9a7c145") + parser.add_argument("--as-of", default="2026-09-17") + parser.add_argument("--repetitions", type=int, default=3) + parser.add_argument( + "--output", default="reports/research/implementation_benchmark_20260922.json" + ) + args = parser.parse_args() + if args.repetitions < 1: + parser.error("반복 횟수는 1 이상이어야 합니다") + old = types.ModuleType("risk_review_before") + old.__file__ = str(ROOT / "tools/risk_review.py") + source = subprocess.check_output( + ["git", "show", f"{args.baseline}:tools/risk_review.py"], cwd=ROOT + ).decode("utf-8") + # 사용자가 지정한 로컬 저장소의 기준 코드를 같은 프로세스에서 비교한다. + exec(compile(source, old.__file__, "exec"), old.__dict__) # noqa: S102 + research.AS_OF = args.as_of + series = {s: research._fdr(s, "2014-01-01") for s in ("069500", "357870", "KS200")} + panel = pd.DataFrame(series) + policies = [ + research.Policy("static", "고정"), + research.Policy("old_product", "기존", trend=True, dd=True), + research.Policy("minimum", "변경", trend=True, dd=True, combination="minimum"), + ] + report = { + "date": pd.Timestamp.now().date().isoformat(), + "baseline_commit": args.baseline, + "input_as_of": args.as_of, + "input_sha256": { + s: hashlib.sha256(v.to_csv().encode()).hexdigest() + for s, v in series.items() + }, + "python": sys.version, + "rows": len(panel), + "repetitions": args.repetitions, + "policies": {}, + } + for policy in policies: + timings = {"before": [], "after": []} + for repeat in range(args.repetitions): + results = {} + for name, module in (("before", old), ("after", current)): + began = time.perf_counter() + results[name] = module.integer_etf_simulation( + panel, policy, redirect=policy.name != "old_product" + ) + timings[name].append(time.perf_counter() - began) + pd.testing.assert_frame_equal( + results["before"][0], results["after"][0], check_exact=True + ) + assert results["before"][1] == results["after"][1] + median = {name: statistics.median(values) for name, values in timings.items()} + report["policies"][policy.name] = { + "seconds": timings, + "median_seconds": median, + "speedup": median["before"] / median["after"], + "all_daily_values_exactly_equal": True, + "output_rows": len(results["after"][0]), + } + print(policy.name, report["policies"][policy.name], flush=True) + path = Path(args.output) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tools/risk_review.py b/tools/risk_review.py index f4b1dd5..a38c137 100644 --- a/tools/risk_review.py +++ b/tools/risk_review.py @@ -27,6 +27,44 @@ from tools import risk_overlay_backtest as research +def align_report_inputs(series, as_of): + """공통 확정일까지만 비교하고, 기간 중 누락된 종가는 채우지 않는다.""" + cutoff = pd.Timestamp(as_of) + clean = {} + for symbol in ("069500", "357870", "KS200"): + values = series[symbol].loc[series[symbol].index < cutoff] + if ( + values.empty + or not values.index.is_unique + or not values.index.is_monotonic_increasing + or not all(math.isfinite(v) and v > 0 for v in values) + ): + raise ValueError(f"{symbol}: 종가와 날짜를 확인하세요") + clean[symbol] = values + end = min(values.index[-1] for values in clean.values()) + aligned = {symbol: values.loc[:end] for symbol, values in clean.items()} + if any(values.empty for values in aligned.values()): + raise ValueError("세 자료가 겹치는 비교 기간이 없습니다") + panel = pd.DataFrame(aligned) + start = max(values.index[0] for values in aligned.values()) + incomplete = panel.loc[start:].isna().any(axis=1) + if incomplete.any(): + dates = ", ".join(str(day.date()) for day in incomplete[incomplete].index[:5]) + raise ValueError( + f"비교 기간 중 종가 누락: {dates}. 누락을 채우지 않고 검증을 중단합니다" + ) + audit = { + "common_last_bar": str(end.date()), + "latest_available": { + symbol: str(values.index[-1].date()) for symbol, values in clean.items() + }, + "excluded_after_common_bar": { + symbol: int((values.index > end).sum()) for symbol, values in clean.items() + }, + } + return aligned, panel, audit + + def integer_etf_simulation( panel, policy, @@ -70,13 +108,17 @@ def integer_etf_simulation( previous_state = None previous_month = None rows = [] - # 200日 선 계산 기간은 성과 집계 전에 따로 확보한다. + # 200일선 계산 기간은 성과 집계 전에 따로 확보한다. start = max(200, panel.index.get_indexer([panel[symbols].dropna().index[0]])[0]) commission = research.COMMISSION * cost_multiple slippage = research.SLIPPAGE * cost_multiple + # 일마다 전체 과거를 복사하지 않고 필요한 이동평균 창만 전달한다. + # 다음 날짜와 당일 종가는 신호 입력에 포함하지 않는다. + index_prices = panel["KS200"].to_numpy() + execution_prices = panel[symbols].to_numpy() for i in range(start, len(panel)): day = panel.index[i] - prices = {s: float(panel[s].iloc[i]) for s in symbols} + prices = {s: float(execution_prices[i, j]) for j, s in enumerate(symbols)} if any(not math.isfinite(p) or p <= 0 for p in prices.values()): raise ValueError("ETF 종가가 누락됐습니다") month = (day.year, day.month) @@ -88,7 +130,7 @@ def integer_etf_simulation( previous_month = month decision = compute_decision( cfg, - index_closes=panel["KS200"].iloc[:i].tolist(), + index_closes=index_prices[max(0, i - cfg.trend.ma_days) : i].tolist(), cumulative_returns_pct=[(peak - 1) * 100, (twr - 1) * 100], prev_state=previous_state, now=day.to_pydatetime(), @@ -216,7 +258,7 @@ def plot_comparison(frames, path): "old_product": "기존 방식 (TWR 오류 수정)", "minimum": "주식만 조절·중복 축소 방지", } - colors = {"static": "#9aa5b5", "old_product": "#8e6853", "minimum": "#3157a4"} + colors = {"static": "#6f6f6f", "old_product": "#9f1853", "minimum": "#0f62fe"} for key, f in frames.items(): if key not in names: continue @@ -228,15 +270,15 @@ def plot_comparison(frames, path): ax[0].legend(frameon=False, fontsize=9, loc="upper left") for a in ax: a.spines[["top", "right"]].set_visible(False) - a.spines[["left", "bottom"]].set_color("#cbd1dd") + a.spines[["left", "bottom"]].set_color("#c6c6c6") a.grid(axis="y", alpha=0.12) - a.tick_params(labelsize=9, colors="#596579") + a.tick_params(labelsize=9, colors="#525252") fig.text( 0.07, 0.025, "실제 ETF 종가 · 1주 단위 · 월 10만원 적립 · 수수료·슬리피지 반영 · 과거 성과이며 미래 수익을 보장하지 않음", fontsize=9, - color="#596579", + color="#525252", ) fig.tight_layout(rect=(0, 0.05, 1, 1)) path.parent.mkdir(parents=True, exist_ok=True) @@ -247,14 +289,15 @@ def plot_comparison(frames, path): def main(): parser = argparse.ArgumentParser() parser.add_argument("--as-of", default="2026-09-17") - parser.add_argument( - "--output", default="reports/research/risk_review_20260917.json" - ) - parser.add_argument("--image", default="docs/images/risk-review-20260917.png") + parser.add_argument("--output") + parser.add_argument("--image") args = parser.parse_args() + stamp = pd.Timestamp(args.as_of).strftime("%Y%m%d") + args.output = args.output or f"reports/research/risk_review_{stamp}.json" + args.image = args.image or f"docs/images/risk-review-{stamp}.png" research.AS_OF = args.as_of series = {s: research._fdr(s, "2014-01-01") for s in ["069500", "357870", "KS200"]} - panel = pd.DataFrame(series).loc[: args.as_of] + series, panel, data_audit = align_report_inputs(series, args.as_of) policies = { "static": research.Policy("static", "고정 비중"), "old_product": research.Policy("old_product", "기존 방식", trend=True, dd=True), @@ -322,6 +365,7 @@ def main(): "last_complete_bar": str(frames["static"].index[-1].date()), "integer_start": str(frames["static"].index[0].date()), "source": "FinanceDataReader, close prices", + "data_audit": data_audit, "data": manifest, "integer_etf": result, "fractional_research": long_run, @@ -331,6 +375,7 @@ def main(): "다음 거래일 종가에 비용을 더한 근사 체결; 실시간 호가·괴리율·미체결 미재현", "소수 주 연구의 현금금리는 연 3% 고정 가정; 금리 0% 민감도도 공개", "부분 연도는 연도 전체 수익률이 아님; 실전 자동 전환 없음", + "자료별 마지막 날짜가 다르면 공통 확정일까지 비교; 뒤 날짜는 제외", ], } target = Path(args.output) @@ -344,6 +389,7 @@ def main(): json.dumps( { "integer_start": payload["integer_start"], + "data_audit": data_audit, "last": payload["last_complete_bar"], "integer_etf": result, "fractional": long_run,