diff --git a/README.md b/README.md index e1e865fe..fa611461 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,68 @@ -눈금 심볼 +눈금 심볼 -# 눈금 NUNGUM +# 눈금 — 오래 투자하기 위한 기준과 기록 -**오래 투자하기 위한 기준과 기록.** +한국 주식 ETF·대형주 바스켓을 매일 자동으로 굴리고, 그 결과를 숫자로 남기고, 정한 기준을 지키게 만드는 개인용 퀀트 시스템입니다. 주가를 맞히는 프로그램이 아닙니다. 종목을 고르는 알파는 없다는 걸 검증으로 확인했고, 그 대신 **비용을 최소로 시장 수익(베타)을 담고, 떨어질 때 덜 잃는 것**에 집중합니다. -오래 투자하려면 오늘 무엇을 해야 하고, 무엇을 그냥 두어야 하는지부터 분명해야 합니다. +![스크롤하면 자산 곡선이 그려지는 첫 화면](docs/images/dashboard-story.png) -눈금은 한국 주식 포트폴리오를 모의 운용하며 자산 흐름, 적립 내역, 시스템 상태를 한 화면에서 살펴보는 로컬 대시보드입니다. 매일 시세를 쫓기보다 정한 기준을 지키고 기록을 쌓는 데 초점을 맞췄습니다. +## 한눈에 보기 -> 기본 설정은 모의 운용입니다. 실제 주문은 필요한 안전 조건을 확인하고 사용자가 직접 활성화하기 전에는 실행되지 않습니다. 수익과 원금은 보장하지 않습니다. +| | 내용 | +|---|---| +| 하는 일 | 한국 주식 바스켓 자동 리밸런싱, 매일 자산 기록(NAV), 적립 관리, 운영 상태 감시, 웹 대시보드 | +| 지금 굴러가는 것 | 모의투자 2개 트랙 (실전 주문은 운영자가 직접 켜기 전엔 나가지 않음) | +| 주력 트랙 | `kr_pocket` — 코스피200 ETF + CD금리 ETF, 시작 30만원에 매월 10만원 적립 | +| 관찰 트랙 | `kr_diversified_hold` — 대형주 9종목 동일비중, 주식 60% / 현금 40% | +| 리스크 관리 | 주식 비중 고정 + 2026-09 추가한 추세 필터·낙폭 제어 오버레이, 거래 중지·급락 감지·갭 방어 | +| 기술 | Python 3.11/3.12, aiohttp, SQLite, FinanceDataReader/pykrx, KIS API(모의·실전), 대시보드는 순수 HTML/CSS/JS | -![오늘의 운용 판단과 자산 요약](docs/images/dashboard-overview.png) +## 돈이 어떻게 굴러가나 -## 화면 둘러보기 +두 트랙 모두 `config/baskets.yaml`에 선언된 목표 비중을 향해 큰 이탈이 생길 때만 사고팝니다. 매일 아침 스케줄러가 시세를 받아 리밸런싱이 필요한지 보고, 그날의 총자산을 장부에 남깁니다. 적립금은 수익이 아니므로 시간가중수익률(TWR)로 분리해서 계산합니다. -### 오늘 볼 일만 먼저 +| 트랙 | 구성 | 목표 주식 비중 | 리밸런싱 | 손절 | +|---|---|---|---|---| +| `kr_pocket` (주력) | KODEX 200 50% + TIGER CD금리 50% | 95% 투자 (지수 47.5 · 파킹 47.5) | 비중 8%p 이탈 시 | 없음 — 지수 적립식이라 비중으로만 위험 통제 | +| `kr_diversified_hold` (관찰) | 삼성전자·현대차·NAVER 등 9종목 동일비중 | 60% | 비중 8%p 이탈 시, 회전 상한 15% | 종목 −25%, 재매수 금지 60일 | -적립 여부, 오래된 데이터, 거래 중지처럼 지금 살펴봐야 할 항목 하나를 첫 화면에 보여줍니다. 별일이 없으면 `오늘은 할 일이 없습니다`라고 알려줍니다. +정직한 기대치는 이렇습니다. 2022년 약세장을 포함한 검증에서 대형주 동일비중 보유는 연 13% 안팎, 주식 50%짜리 적립 트랙은 연 7~9%였습니다. 강세장 한 번의 숫자(연 30%대)는 상한이지 약속이 아닙니다. 자세한 근거는 [docs/PROFITABILITY_FINDINGS.md](docs/PROFITABILITY_FINDINGS.md)에 있습니다. -### 포트폴리오를 한눈에 +## 2026년 9월 업데이트 — 손실을 줄이는 두 가지 규칙 -![포트폴리오별 자산과 투자 비중](docs/images/dashboard-portfolio.png) +![적립 트랙 오버레이 백테스트](docs/images/overlay-pocket-etf.png) -주력 포트폴리오와 관찰용 포트폴리오를 나눠 보여줍니다. 현재 자산, 누적 원금, 입출금 제외 수익률(TWR), 최대 낙폭(MDD), 현금과 보유 종목을 한곳에서 볼 수 있습니다. +"수익률을 올리는 신호"는 여러 번 시도해서 전부 시장 보유에 졌습니다. 그래서 이번에는 **주식 비중을 언제 줄이는지**만 바꿨습니다. 2002년부터의 코스피200, 2014년부터의 KODEX 200 실제 ETF, 2021년 말부터의 대형주 10종목으로 같은 비용·같은 적립 규칙 아래 비교했습니다. -### 짧은 등락보다 긴 흐름 +| 규칙 | 무엇을 하나 | 적립 트랙(ETF, 2014~) 결과 | +|---|---|---| +| 추세 필터 | 코스피200이 200일선 아래로 2% 넘게 내려가면 주식 비중을 절반으로, 위로 2% 넘게 올라와야 복귀 | 연수익률 9.6 → 9.3%, 최악 연도 −11.2 → −4.6%, 샤프 0.59 → 0.62 | +| 낙폭 제어 | 시간가중 자산이 고점 대비 −10% 아래면 절반, −5% 안으로 회복해야 복귀 | 최대낙폭 −21.7 → −17.3% (추세 필터와 함께 −16.3%) | +| 변동성 목표 | 실현 변동성이 20%를 넘으면 그만큼 축소 | 연수익률을 2~4%p 깎아 **기본 꺼 둠** | -![기간별 자산 흐름과 모의 운용 기록](docs/images/dashboard-performance.png) +주력 적립 트랙에는 추세 필터와 낙폭 제어를 켰습니다. 관찰 트랙은 60거래일 검증을 끝내고 실전 전환 승인을 기다리는 중이라 권장값만 적어 두고 껐습니다. 전체 표와 읽는 법은 [docs/RISK_OVERLAY_FINDINGS.md](docs/RISK_OVERLAY_FINDINGS.md), 재현은 `python tools/risk_overlay_backtest.py`입니다. -포트폴리오와 기간을 바꿔 자산 흐름을 살펴볼 수 있습니다. 운용 기록이 아직 짧다면 성과를 서둘러 판단하지 않도록 기록 일수와 누락 여부도 함께 표시합니다. +## 화면 -### 문제가 생겼을 때만 자세히 +대시보드는 매일 아침 한 번 보는 용도로 만들었습니다. 첫 화면은 스크롤에 따라 자산 곡선이 그려지면서 총자산, 적립을 뺀 수익률, 최대 낙폭을 차례로 보여줍니다. -![거래 안전 상태와 오늘의 자동 운용 기록](docs/images/dashboard-operations.png) +![고점 대비 낙폭 구간이 음영으로 드러나는 첫 화면](docs/images/dashboard-story-drawdown.png) -평소에는 거래 안전 상태, 시장 환경, 자동 운용과 데이터 시각만 간단히 보여줍니다. 필요할 때만 오늘의 처리 기록과 고급 진단을 펼쳐볼 수 있고, 거래가 멈추면 원인과 복구 순서를 따로 안내합니다. +**오늘** — 확인할 일이 있으면 그것만 먼저 말합니다. 적립을 아직 기록하지 않았는지, 자동매매가 멈췄는지, 거래가 중지됐는지. 없으면 "오늘은 손댈 것이 없습니다"라고 씁니다. -### 넣은 돈은 수익과 따로 +![오늘 할 일과 운영 상태 요약](docs/images/dashboard-today.png) -![적립금 기록 전 최종 확인](docs/images/dashboard-deposit.png) +**포트폴리오** — 트랙별 총자산·투자원금·평가손익·시간가중수익률·최대낙폭·현금 비중, 투자 비중과 목표, 종목별 매입 비중과 목표 비중, 그리고 오늘 위험 조절 규칙이 무엇을 판단했는지. -적립금은 포트폴리오와 금액, 장부 모드를 마지막에 한 번 더 확인한 뒤 기록합니다. 입금으로 늘어난 금액이 투자 수익처럼 보이지 않도록 운용 성과와 분리해 계산합니다. +![트랙별 지표와 종목표](docs/images/dashboard-portfolio.png) + +**성과** — 평가금액(점선은 투자원금, 세로 눈금은 적립 시점), 고점 대비 낙폭, 월별 시간가중수익률. 차트 위에 커서를 올리면 날짜별 값이 보입니다. + +![평가금액 · 낙폭 · 월별 수익률](docs/images/dashboard-performance.png) + +**검증·운영** — 60거래일 모의투자 검증 진행률, 거래 안전·장 상태·자동매매·증권사 연결·데이터 갱신 상태, 오늘 생성된 신호, 웹소켓 연결 이력. + +![운영 상태표](docs/images/dashboard-operations.png) ## 실행하기 @@ -49,55 +71,47 @@ Python 3.11 또는 3.12가 필요합니다. ```bash git clone https://github.com/easygap/quant_trader.git cd quant_trader - python -m venv .venv -# Windows PowerShell -.venv\Scripts\Activate.ps1 -# macOS/Linux: source .venv/bin/activate - +.venv\Scripts\Activate.ps1 # macOS/Linux: source .venv/bin/activate pip install -r requirements.txt cp config/settings.yaml.example config/settings.yaml cp .env.example .env python main.py --mode dashboard ``` -기본 바인드는 http://127.0.0.1:8080입니다. 브라우저에서 이 주소를 열면 됩니다. 대시보드는 인증 없이 금융 정보를 다루므로 현재 PC에서만 접속할 수 있습니다. +기본 바인드는 http://127.0.0.1:8080 입니다. 대시보드는 인증 없이 계좌 숫자를 보여주므로 이 PC에서만 열리고, 외부 공개는 설정으로도 권장하지 않습니다. -처음 시작해 화면에 운용 기록이 없다면 모의 운용을 한 번 실행하세요. +처음 실행해 화면에 기록이 없다면 모의투자 사이클을 한 번 돌립니다. ```bash -python main.py --mode paper +python main.py --mode rebalance --basket kr_pocket --dry-run # 주문 미리보기 +python main.py --mode rebalance --basket kr_pocket # 모의투자 실행 + 자산 기록 ``` -KIS 모의투자나 알림을 사용할 때만 `.env`에 필요한 값을 채웁니다. `.env`와 실제 계좌 정보는 Git에 올리지 마세요. +매일 자동으로 돌리려면 `python main.py --mode schedule`을 작업 스케줄러나 systemd에 등록합니다. 적립금은 대시보드의 **적립 기록** 버튼이나 `python tools/record_deposit.py --basket kr_pocket --amount 100000`으로 남깁니다. KIS 모의투자나 알림을 쓸 때만 `.env`를 채우고, `.env`와 계좌 정보는 Git에 올리지 않습니다. -## 처음이라면 이렇게 보세요 +## 안전장치 -1. 화면 위쪽에서 오늘 살펴볼 일을 봅니다. -2. 주력 포트폴리오의 자산과 현금 비중을 살펴봅니다. -3. 장기 성과에서 자산 흐름이 어떻게 이어지고 있는지 봅니다. -4. 실제로 넣은 금액이 있다면 **적립금 기록**에 남깁니다. -5. 모의 운용 기록이 충분히 쌓이기 전에는 실전 주문을 사용하지 않습니다. +실전 주문은 다음이 모두 맞아야만 나갑니다. 60거래일 이상의 모의투자 기록, 기록 누락 5% 이하, 실패 주문 0건, 연 비용 1% 이하, `--mode live --confirm-live` 명시. 그 밖에 전역 거래 중지(HALT), 개별 종목 −5%·포트폴리오 −3% 급락 감지, 갭다운 −3% 즉시 청산, 일일 손실 3%·낙폭 15% 한도가 있습니다. 상세는 [docs/SAFETY_MODEL.md](docs/SAFETY_MODEL.md)와 [docs/OPERATING_PRINCIPLES.md](docs/OPERATING_PRINCIPLES.md)를 보세요. 후자는 이 저장소에서 실제로 났던 사고(오류 0건인데 설계대로 안 돌던 경우들)에서 뽑은 원칙입니다. -## 실제 주문 전에 +## 백테스트와 연구 -처음 내려받은 설정은 아래 상태입니다. - -```yaml -kis_api: - use_mock: true -trading: - mode: "paper" - auto_entry: false +```bash +python tools/risk_overlay_backtest.py # 오버레이 비교 (본 README 그림) +python main.py --mode validate --strategy scoring --symbol 005930 --walk-forward --validation-years 5 +python tools/buy_hold_robustness.py # 대형주 보유의 연도별 강건성 +python tools/static_allocation_analysis.py # 주식·현금 비중별 낙폭 ``` -가격, 잔고나 체결 상태가 불확실하면 새 주문을 차단합니다. 부분 체결이나 장부 저장 실패처럼 직접 점검이 필요한 상황에서는 거래를 멈추고 화면에 이유를 남깁니다. 어떤 안전장치도 시장 급변, 슬리피지, API 장애나 투자 손실을 완전히 없앨 수는 없습니다. +신호형 전략(`scoring` 등)은 정직 평가에서 동일비중 보유 대비 −140%p 뒤져 `paper_only`로 강등돼 있습니다. 백테스트 결과가 나쁘게 보이는 건 버그가 아니라 그 결론 자체입니다. 왜 그런지, 무엇을 시도했는지는 [docs/RESEARCH_LOG.md](docs/RESEARCH_LOG.md)에 날짜별로 남겨 두었습니다. -## 더 자세히 +## 문서 -- [사용·운영 가이드](docs/PROJECT_GUIDE.md) -- [안전 장치와 복구 순서](docs/SAFETY_MODEL.md) -- [소액 적립 포트폴리오 설계](docs/POCKET_TRACK_PLAN.md) -- [연구 결과와 한계](docs/PROFITABILITY_FINDINGS.md) +- [docs/PROJECT_GUIDE.md](docs/PROJECT_GUIDE.md) — 구조와 흐름, 파일별 역할 +- [docs/PROFITABILITY_FINDINGS.md](docs/PROFITABILITY_FINDINGS.md) — 수익성 정직 점검 결론 +- [docs/RISK_OVERLAY_FINDINGS.md](docs/RISK_OVERLAY_FINDINGS.md) — 추세 필터·낙폭 제어 검증 (2026-09) +- [docs/POCKET_TRACK_PLAN.md](docs/POCKET_TRACK_PLAN.md) — 소액 적립 트랙 설계와 기대치 +- [docs/BASKET_PAPER_EVALUATION.md](docs/BASKET_PAPER_EVALUATION.md) · [docs/PAPER_TO_LIVE_RUNBOOK.md](docs/PAPER_TO_LIVE_RUNBOOK.md) — 모의투자 검증 기준과 실전 전환 절차 +- [docs/BACKTEST_IMPROVEMENT.md](docs/BACKTEST_IMPROVEMENT.md) — 미래 정보 누출·생존 편향·비용 반영 내역 -이 프로젝트는 개인 연구와 모의 운용을 위한 도구이며 투자 조언이 아닙니다. 실제 자금을 사용하기 전에는 코드와 설정, 증권사 규정, 세금 조건을 직접 검토하세요. +이 저장소는 수익과 원금을 보장하지 않습니다. 실전 전환 전에 설정과 증권사 계좌를 직접 확인하세요. diff --git a/config/baskets.yaml b/config/baskets.yaml index cdf20c30..ae0b2393 100644 --- a/config/baskets.yaml +++ b/config/baskets.yaml @@ -70,6 +70,22 @@ baskets: # 60%로 명시한 지금은 미달이 곧 조치 대상(현금 누수)이다. 실제로 감시가 꺼져 있는 # 동안 배치율이 61.0% → 54.9%로 새는 것을 헬스가 전혀 잡지 못했다. deployment_tolerance: 0.05 + # 리스크 오버레이는 이 트랙에선 아직 끈다 — 60거래일 검증을 마치고 실전 전환 승인을 + # 기다리는 중이라, 정책을 바꾸면 검증 대상이 달라진다. 백테스트(2021-12~2026-09, + # docs/RISK_OVERLAY_FINDINGS.md)에서 낙폭 제어(-10%에서 절반)는 최대낙폭 -26.1→-16.4%, + # 샤프 0.82→0.85(연수익률 18.3→15.8%)였다. 켜려면 enabled: true. + overlays: + drawdown_guard: + enabled: false + trigger: -0.10 + release: -0.05 + scale: 0.5 + trend_filter: + enabled: false + index_symbol: "KS200" + ma_days: 200 + band: 0.02 + off_scale: 0.5 rebalance: trigger: drift # 분기 점검을 권장하나 drift로 큰 이탈만 교정 drift_threshold: 0.08 # 8%p 이상 이탈 시에만 리밸런싱(잦은 매매 억제) @@ -168,6 +184,38 @@ baskets: # (1주가/총액)과의 max로 자동 완화·조임한다 — 잔고가 커지면 이 값이 다시 지배한다. monitoring: deployment_tolerance: 0.10 + # --- 리스크 오버레이 (2026-09-17, docs/RISK_OVERLAY_FINDINGS.md) --- + # 종목을 고르지 않고 '주식 비중을 언제 줄이는가'만 바꾼다. 배수는 설계 비중(95%)에 + # 곱한다: 추세 필터 발동 시 95% × 0.5 = 47.5%(지수 23.75 + 파킹 23.75), 나머지 현금. + # 추세 필터: 코스피200이 200일선 아래로 2% 넘게 내려가면 절반, 위로 2% 넘게 올라와야 + # 복귀. 2002~2026 백테스트: 연수익률 7.45→7.19%(-0.26%p), 최대낙폭 + # -28.8→-21.8%, 최악 연도 -18.7→-12.3%, 샤프 0.42→0.45. ETF 구간(2014~)도 + # 연 9.62→9.32%에 최악 연도 -11.2→-4.6%. 비용은 ETF라 거래세 없이 수수료뿐. + # 낙폭 제어: 시간가중 NAV가 고점 대비 -10% 아래면 절반, -5% 안으로 회복해야 복귀. + # ETF 구간 최대낙폭 -21.7→-16.3%(추세와 결합), 샤프 0.59→0.64. + # 변동성 목표: 한국 시장에선 수익을 2~4%p 깎아(고변동 구간이 고수익 구간) 끈다. + # 상태(추세 아래/낙폭 발동)는 data/overlay_state/kr_pocket.json으로 이어지고, 헬스와 + # 대시보드는 '설계 × 배수'를 그날의 목표로 본다(오버레이가 줄인 날 미달 경보 안 울림). + # 끄려면 enabled: false — 다음 사이클부터 설계 비중으로 되돌아간다. + overlays: + trend_filter: + enabled: true + index_symbol: "KS200" + ma_days: 200 + band: 0.02 + off_scale: 0.5 + drawdown_guard: + enabled: true + trigger: -0.10 + release: -0.05 + scale: 0.5 + volatility_target: + enabled: false + target: 0.20 + lookback_days: 60 + min_scale: 0.5 + max_scale: 1.0 + step: 0.1 # 이 트랙의 리스크 정책은 '손절 없음'이며, 이는 누락이 아니라 결정이다. # 069500은 200종목 분산 지수 ETF고 이 트랙은 월 적립(DCA) 엔진이다. 지수에 # 손절을 걸면 하락을 손실로 확정하면서 회복 구간을 통째로 놓치고, 적립식이 diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index f9cc76a6..c3a00274 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -143,18 +143,132 @@ def __init__( tsw = self.basket.get("target_stock_weight") self._target_stock_weight = float(tsw) if tsw is not None else None + # 리스크 오버레이(추세 필터·낙폭 제어·변동성 목표): 설계 주식 비중에 곱할 배수. + # 판단은 인스턴스당 한 번만 계산해 plan→execute 한 사이클 안에서 같은 값을 쓴다. + from core.risk_overlays import parse_overlay_config + self._overlay_cfg = parse_overlay_config(self.basket) + self._overlay_decision = None + logger.info( - "BasketRebalancer 초기화: {} ({}종목, trigger={}, target_stock_weight={})", + "BasketRebalancer 초기화: {} ({}종목, trigger={}, target_stock_weight={}, overlays={})", basket_name, len(self.holdings), self.rebalance_cfg.get("trigger", "drift"), self._target_stock_weight if self._target_stock_weight is not None else "기본", + "on" if self._overlay_cfg.any_enabled else "off", ) - def _stock_fraction(self) -> float: - """총자산 중 주식에 배정할 비중 — 규칙은 effective_stock_fraction 한 곳에만 둔다 + def base_stock_fraction(self) -> float: + """설계 주식 비중(오버레이 적용 전) — 규칙은 effective_stock_fraction 한 곳에만 둔다 (리밸런서·평가·헬스가 각자 계산하면 '설계 비중'이 서로 어긋난다).""" from core.basket_deploy import effective_stock_fraction return effective_stock_fraction(self.basket, self._risk_params) + def _stock_fraction(self) -> float: + """총자산 중 주식에 배정할 비중 = 설계 비중 × 리스크 오버레이 배수. + + 오버레이는 설계를 대체하지 않고 위임한다(설계 60% × 배수 0.5 = 목표 30%). + 배치 진단·트리거·주문 계획이 전부 이 값을 쓰므로, 오버레이가 비중을 줄인 날 + '현금 래칫' 감시가 설계 비중을 향해 되사는 일은 생기지 않는다. + """ + return self.base_stock_fraction() * self._overlay_scale() + + def _overlay_scale(self) -> float: + decision = self.overlay_decision() + return float(decision.scale) if decision is not None else 1.0 + + def overlay_decision(self): + """오버레이 판단(인스턴스당 1회 계산·캐시). 켜진 오버레이가 없으면 None. + + - 추세 필터: 지수 종가는 전일까지만 쓴다(장중 미확정 봉 제외). + - 낙폭 제어·변동성 목표: 이 바스켓 계정의 NAV 스냅샷(시간가중 누적수익률)으로 잰다. + - 데이터가 없으면 직전 상태를 유지하고 data_issues에 남긴다(헬스가 표면화). + - 판단은 상태 파일로 이어져 다음 실행의 히스테리시스 입력이 된다. + """ + from core.risk_overlays import compute_decision, load_overlay_state, parse_overlay_config, save_overlay_state + + # __init__을 거치지 않고 만든 인스턴스(일부 테스트의 object.__new__ 경로)도 설정에서 복원한다. + if getattr(self, "_overlay_cfg", None) is None: + self._overlay_cfg = parse_overlay_config(getattr(self, "basket", None) or {}) + self._overlay_decision = None + if not self._overlay_cfg.any_enabled: + return None + if getattr(self, "_overlay_decision", None) is not None: + return self._overlay_decision + + prev = load_overlay_state(self.basket_name) + index_closes = None + if self._overlay_cfg.trend.enabled: + index_closes = self._fetch_index_closes( + self._overlay_cfg.trend.index_symbol, self._overlay_cfg.trend.ma_days, + ) + cumulative = None + daily = None + if self._overlay_cfg.drawdown.enabled or self._overlay_cfg.volatility.enabled: + cumulative, daily = self._nav_series_for_overlay() + decision = compute_decision( + self._overlay_cfg, + index_closes=index_closes, + cumulative_returns_pct=cumulative, + daily_returns=daily, + prev_state=prev, + ) + try: + save_overlay_state(self.basket_name, decision) + except OSError as exc: + logger.warning("바스켓 '{}' 오버레이 상태 저장 실패: {}", self.basket_name, exc) + if decision.data_issues: + logger.warning("바스켓 '{}' 오버레이 데이터 문제: {}", self.basket_name, "; ".join(decision.data_issues)) + if decision.reasons: + logger.info("바스켓 '{}' 리스크 오버레이 발동: {} (배수 {})", self.basket_name, " · ".join(decision.reasons), decision.scale) + else: + logger.info("바스켓 '{}' 리스크 오버레이 발동 없음 (배수 1.0)", self.basket_name) + self._overlay_decision = decision + return decision + + def _fetch_index_closes(self, symbol: str, ma_days: int) -> list[float] | None: + """추세 판단용 지수 종가(오래된 순). 오늘 날짜 봉은 제외 — 전일까지의 정보만.""" + start, end = self._recent_range(int(ma_days * 1.7) + 30) + try: + df = self.data_collector.fetch_korean_stock(symbol, start, end) + except Exception as exc: + logger.warning("오버레이 지수 조회 실패 {}: {}", symbol, exc) + 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] + except Exception: + pass + closes = [float(c) for c in frame["close"].dropna().tolist()] + return closes or None + + def _nav_series_for_overlay(self) -> tuple[list[float] | None, list[float] | None]: + """이 바스켓 계정의 시간가중 누적수익률(%)과 일간 수익률.""" + try: + from database.repositories import get_portfolio_snapshots + snaps = get_portfolio_snapshots( + days=3650, account_key=self.account_key, mode=self._ledger_mode(), + ) + except Exception as exc: + logger.warning("오버레이 NAV 조회 실패 {}: {}", self.basket_name, exc) + return None, None + if snaps is None or getattr(snaps, "empty", True) or "cumulative_return" not in snaps.columns: + return None, None + cumulative = [float(v) for v in snaps["cumulative_return"].tolist() if v is not None] + index = [1.0 + c / 100.0 for c in cumulative] + daily = [index[i] / index[i - 1] - 1.0 for i in range(1, len(index)) if index[i - 1] > 0] + return cumulative, daily + def _is_live(self) -> bool: """실전(live) 모드 여부.""" return str(self.config.trading.get("mode", "paper")).lower() == "live" @@ -361,12 +475,15 @@ def diagnose_deployment(self, prices: dict[str, float] = None) -> dict: "target_weight": float(target_w), }) + overlay = self.overlay_decision() return { "total_value": total_value, "stock_value": stock_value, "cash": cash, "deployment_ratio": deployment_ratio, - "design_fraction": design_fraction, + "design_fraction": design_fraction, # 오버레이 적용 후 '그날의 목표' + "base_stock_fraction": self.base_stock_fraction(), + "overlay": overlay.to_dict() if overlay is not None else None, "unfilled_slots": unfilled, } diff --git a/core/risk_overlays.py b/core/risk_overlays.py new file mode 100644 index 00000000..47fea321 --- /dev/null +++ b/core/risk_overlays.py @@ -0,0 +1,346 @@ +"""바스켓 주식 비중 리스크 오버레이 — 추세 필터·낙폭 제어·변동성 목표. + +이 저장소의 확정 결론은 "종목 선택으로 시장을 이길 알파는 없다"이고, 손익을 가르는 것은 +주식 비중이다. 그래서 여기서는 종목을 고르지 않고, **설계 주식 비중에 곱할 배수(0~1)**만 +계산한다. 배수는 설계를 대체하지 않고 위임한다: 설계 60%에 배수 0.5면 목표 30%. + +근거와 숫자는 tools/risk_overlay_backtest.py → docs/RISK_OVERLAY_FINDINGS.md. + - 추세 필터: 지수가 200일선 아래로 band(2%)만큼 내려가면 off_scale(0.5)배, 위로 + band만큼 올라와야 복귀. 히스테리시스가 없으면 선 근처에서 왕복 매매가 난다. + - 낙폭 제어: 시간가중 NAV가 고점 대비 trigger(-10%) 아래면 scale(0.5)배, release(-5%) + 안으로 회복해야 복귀. + - 변동성 목표: 실현 변동성이 목표(20%)를 넘으면 목표/실현 비율로 축소(0.1 단위). + 한국 시장에선 수익을 많이 깎아 기본 off — 옵션으로만 둔다. + +원칙 + - 모든 판단은 전일까지의 정보만 쓴다(리밸런싱은 장 시작 전에 전일 종가로 돌아간다). + - 상태(추세 아래/낙폭 발동)는 파일로 이어진다. 히스테리시스는 상태가 있어야 성립한다. + - 데이터가 없으면 새 상태를 지어내지 않고 직전 상태를 유지하며 data_issues에 남긴다. + 헬스가 그 사실을 표면화한다("오류 0건인데 설계대로 안 돈다"를 막기 위해). +""" +from __future__ import annotations + +import json +import math +import os +from dataclasses import asdict, dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any, Sequence + +_ROOT = Path(__file__).resolve().parent.parent +STATE_DIR_ENV = "QUANT_OVERLAY_STATE_DIR" + + +@dataclass(frozen=True) +class TrendFilterConfig: + enabled: bool = False + index_symbol: str = "KS200" + ma_days: int = 200 + band: float = 0.02 + off_scale: float = 0.5 + + +@dataclass(frozen=True) +class DrawdownGuardConfig: + enabled: bool = False + trigger: float = -0.10 + release: float = -0.05 + scale: float = 0.5 + + +@dataclass(frozen=True) +class VolatilityTargetConfig: + enabled: bool = False + target: float = 0.20 + lookback_days: int = 60 + min_scale: float = 0.5 + max_scale: float = 1.0 + step: float = 0.1 + + +@dataclass(frozen=True) +class OverlayConfig: + trend: TrendFilterConfig = TrendFilterConfig() + drawdown: DrawdownGuardConfig = DrawdownGuardConfig() + volatility: VolatilityTargetConfig = VolatilityTargetConfig() + + @property + def any_enabled(self) -> bool: + return self.trend.enabled or self.drawdown.enabled or self.volatility.enabled + + +@dataclass +class OverlayDecision: + scale: float = 1.0 + reasons: list[str] = field(default_factory=list) + data_issues: list[str] = field(default_factory=list) + trend_below: bool | None = None + trend_rel_to_ma: float | None = None + drawdown_active: bool | None = None + drawdown: float | None = None + vol_scale: float | None = None + realized_vol: float | None = None + evaluated_at: str = "" + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def _f(value: Any, default: float) -> float: + try: + return float(value) + except (TypeError, ValueError): + return float(default) + + +def parse_overlay_config(basket_cfg: dict[str, Any] | None) -> OverlayConfig: + """baskets.yaml의 overlays 블록을 읽는다. 없거나 비정상이면 전부 off.""" + raw = (basket_cfg or {}).get("overlays") or {} + if not isinstance(raw, dict): + return OverlayConfig() + t = raw.get("trend_filter") or {} + d = raw.get("drawdown_guard") or {} + v = raw.get("volatility_target") or {} + trend = TrendFilterConfig( + enabled=bool(t.get("enabled", False)), + index_symbol=str(t.get("index_symbol") or "KS200"), + ma_days=max(20, int(_f(t.get("ma_days"), 200))), + band=max(0.0, _f(t.get("band"), 0.02)), + off_scale=min(1.0, max(0.0, _f(t.get("off_scale"), 0.5))), + ) + drawdown = DrawdownGuardConfig( + enabled=bool(d.get("enabled", False)), + trigger=min(0.0, _f(d.get("trigger"), -0.10)), + release=min(0.0, _f(d.get("release"), -0.05)), + scale=min(1.0, max(0.0, _f(d.get("scale"), 0.5))), + ) + volatility = VolatilityTargetConfig( + enabled=bool(v.get("enabled", False)), + target=max(0.01, _f(v.get("target"), 0.20)), + lookback_days=max(10, int(_f(v.get("lookback_days"), 60))), + min_scale=min(1.0, max(0.0, _f(v.get("min_scale"), 0.5))), + max_scale=min(1.0, max(0.0, _f(v.get("max_scale"), 1.0))), + step=max(0.0, _f(v.get("step"), 0.1)), + ) + return OverlayConfig(trend=trend, drawdown=drawdown, volatility=volatility) + + +# ------------------------------------------------------------------ +# 개별 신호 (순수 함수) +# ------------------------------------------------------------------ + +def trend_below_ma( + index_closes: Sequence[float], cfg: TrendFilterConfig, prev_below: bool | None, +) -> tuple[bool | None, float | None]: + """지수가 이동평균 아래인가 — 히스테리시스 적용. + + 반환 (below, rel). 데이터가 ma_days 미만이면 (prev_below, None): 판단을 지어내지 않는다. + prev_below가 None(첫 실행)이면 band 없이 선 아래/위로 초기화한다. + """ + closes = [float(c) for c in index_closes if c is not None and not math.isnan(float(c))] + if len(closes) < cfg.ma_days: + return prev_below, None + window = closes[-cfg.ma_days:] + ma = sum(window) / len(window) + if ma <= 0: + return prev_below, None + rel = closes[-1] / ma - 1.0 + if prev_below is None: + return rel < 0.0, rel + if prev_below and rel > cfg.band: + return False, rel + if (not prev_below) and rel < -cfg.band: + return True, rel + return bool(prev_below), rel + + +def drawdown_from_cumulative_returns(cumulative_returns_pct: Sequence[float]) -> float | None: + """시간가중 누적수익률(%) 시계열 → 현재 고점 대비 낙폭(음수 비율). 고점은 시작 자본(지수 1.0)부터.""" + peak = 1.0 + last = None + for cr in cumulative_returns_pct: + if cr is None: + continue + try: + idx = 1.0 + float(cr) / 100.0 + except (TypeError, ValueError): + continue + if math.isnan(idx): + continue + peak = max(peak, idx) + last = idx + if last is None: + return None + return last / peak - 1.0 + + +def drawdown_guard_active(drawdown: float | None, cfg: DrawdownGuardConfig, prev_active: bool | None) -> bool | None: + """낙폭 제어 발동 여부 — trigger 아래로 내려가면 켜지고 release 안으로 회복해야 꺼진다.""" + if drawdown is None: + return prev_active + active = bool(prev_active) + if active and drawdown >= cfg.release: + return False + if (not active) and drawdown <= cfg.trigger: + return True + return active + + +def realized_volatility(daily_returns: Sequence[float], lookback_days: int) -> float | None: + """최근 lookback_days 일간수익률의 연환산 표준편차.""" + rets = [float(r) for r in daily_returns if r is not None and not math.isnan(float(r))] + if len(rets) < lookback_days: + return None + window = rets[-lookback_days:] + mean = sum(window) / len(window) + var = sum((r - mean) ** 2 for r in window) / (len(window) - 1) + return math.sqrt(var) * math.sqrt(252) + + +def volatility_scale(daily_returns: Sequence[float], cfg: VolatilityTargetConfig) -> tuple[float | None, float | None]: + """변동성 목표 배수. 데이터 부족이면 (None, None).""" + vol = realized_volatility(daily_returns, cfg.lookback_days) + if vol is None or vol <= 0: + return None, vol + scale = cfg.target / vol + scale = min(cfg.max_scale, max(cfg.min_scale, scale)) + if cfg.step > 0: + scale = round(scale / cfg.step) * cfg.step + scale = min(cfg.max_scale, max(cfg.min_scale, scale)) + return round(scale, 4), vol + + +# ------------------------------------------------------------------ +# 결합 +# ------------------------------------------------------------------ + +def compute_decision( + cfg: OverlayConfig, + *, + index_closes: Sequence[float] | None = None, + cumulative_returns_pct: Sequence[float] | None = None, + daily_returns: Sequence[float] | None = None, + 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 + + if cfg.trend.enabled: + below, rel = trend_below_ma(index_closes or [], cfg.trend, prev.get("trend_below")) + decision.trend_rel_to_ma = None if rel is None else round(rel, 4) + if rel is None: + decision.data_issues.append( + f"추세 필터: {cfg.trend.index_symbol} 종가 {cfg.trend.ma_days}일치 부족 — 직전 상태 유지" + ) + decision.trend_below = below + if below: + scale *= cfg.trend.off_scale + decision.reasons.append( + f"{cfg.trend.index_symbol} {cfg.trend.ma_days}일선 아래" + + (f"({rel:+.1%})" if rel is not None else "") + + f" → 주식 비중 ×{cfg.trend.off_scale:g}" + ) + + if cfg.drawdown.enabled: + dd = drawdown_from_cumulative_returns(cumulative_returns_pct or []) + active = drawdown_guard_active(dd, cfg.drawdown, prev.get("drawdown_active")) + decision.drawdown = None if dd is None else round(dd, 4) + if dd is None: + decision.data_issues.append("낙폭 제어: NAV 기록 없음 — 직전 상태 유지") + decision.drawdown_active = active + if active: + scale *= cfg.drawdown.scale + decision.reasons.append( + f"고점 대비 낙폭 {dd:+.1%}" if dd is not None else "낙폭 제어 발동 중" + ) + decision.reasons[-1] += f" → 주식 비중 ×{cfg.drawdown.scale:g}" + + if cfg.volatility.enabled: + vs, vol = volatility_scale(daily_returns or [], cfg.volatility) + decision.realized_vol = None if vol is None else round(vol, 4) + if vs is None: + decision.data_issues.append("변동성 목표: 일간 수익률 부족 — 배수 1.0") + prev_vs = prev.get("vol_scale") + vs = float(prev_vs) if isinstance(prev_vs, (int, float)) else 1.0 + decision.vol_scale = vs + if vs < 1.0: + scale *= vs + decision.reasons.append( + f"실현 변동성 {vol:.0%} > 목표 {cfg.volatility.target:.0%} → 주식 비중 ×{vs:g}" + if vol is not None else f"변동성 목표 배수 ×{vs:g}" + ) + + decision.scale = round(min(1.0, max(0.0, scale)), 4) + return decision + + +def describe_decision(decision: OverlayDecision | dict[str, Any] | None, cfg: OverlayConfig | None = None) -> str: + """대시보드·헬스용 한 줄 설명.""" + if decision is None: + return "위험 조절 상태 없음 — 첫 실행 대기" + d = decision.to_dict() if isinstance(decision, OverlayDecision) else dict(decision) + scale = float(d.get("scale", 1.0)) + parts = list(d.get("reasons") or []) + issues = list(d.get("data_issues") or []) + if not parts: + text = "위험 조절 발동 없음 · 설계 비중 그대로" + else: + text = " · ".join(parts) + if issues: + text += " · 확인 필요: " + "; ".join(issues) + return f"{text} (배수 {scale:g})" + + +# ------------------------------------------------------------------ +# 상태 파일 +# ------------------------------------------------------------------ + +def overlay_state_dir(state_dir: str | os.PathLike | None = None) -> Path: + if state_dir is not None: + return Path(state_dir) + env = os.environ.get(STATE_DIR_ENV) + return Path(env) if env else _ROOT / "data" / "overlay_state" + + +def overlay_state_path(basket_name: str, state_dir: str | os.PathLike | None = None) -> Path: + safe = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in str(basket_name)) + 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) + 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 + + +def save_overlay_state( + basket_name: str, decision: OverlayDecision, state_dir: str | os.PathLike | None = None, +) -> Path: + path = overlay_state_path(basket_name, state_dir) + path.parent.mkdir(parents=True, exist_ok=True) + payload = decision.to_dict() + payload["basket"] = str(basket_name) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=1), encoding="utf-8") + os.replace(tmp, path) + return path + + +def applied_stock_fraction(design_fraction: float, state: dict[str, Any] | None) -> float: + """설계 주식 비중 × 마지막 오버레이 배수. 상태가 없으면 설계 그대로.""" + if not state: + return float(design_fraction) + try: + scale = float(state.get("scale", 1.0)) + except (TypeError, ValueError): + scale = 1.0 + return float(design_fraction) * min(1.0, max(0.0, scale)) diff --git a/docs/POCKET_TRACK_PLAN.md b/docs/POCKET_TRACK_PLAN.md index 9915d24a..62259c6a 100644 --- a/docs/POCKET_TRACK_PLAN.md +++ b/docs/POCKET_TRACK_PLAN.md @@ -176,3 +176,7 @@ v1(지수 50 / 현금 50)의 현금 절반은 이자 0으로 잠들어 있었다 - 실돈 단타 (60일 페이퍼 증명 전) — 게이트 완화 금지 - 레버리지·인버스 ETF — 논외 - 첫해 수익 극대화 시도 — 이 규모에서 최적화할 것은 수익이 아니라 습관과 시스템이다 + +## 2026-09-17 추가 — 위험 조절 규칙 + +이 트랙에 추세 필터(코스피200 200일선 ±2%, 아래면 주식 비중 절반)와 낙폭 제어(시간가중 자산 고점 대비 −10%에서 절반, −5% 회복 시 복귀)를 켰다. 근거는 `docs/RISK_OVERLAY_FINDINGS.md`: 2014년부터의 KODEX 200 실제 ETF 구간에서 연수익률 9.6→8.8%를 내주고 최대낙폭 −21.7→−16.3%, 최악 연도 −11.2→−4.6%. 이 트랙의 목적(낙폭 최소, 용돈 수준 수익)에 맞는 교환이다. 설정은 `config/baskets.yaml` `kr_pocket.overlays`, 끄려면 `enabled: false`. 대시보드 포트폴리오 카드의 "위험 조절" 줄이 오늘의 판단을 보여준다. diff --git a/docs/RESEARCH_LOG.md b/docs/RESEARCH_LOG.md index de010516..6bab8d58 100644 --- a/docs/RESEARCH_LOG.md +++ b/docs/RESEARCH_LOG.md @@ -225,3 +225,14 @@ Paper trade quality gate: `tools/paper_trade_quality_report.py`는 체결 품질 Target-weight 승격 증거 보강: target-weight 계열 전략은 일반 `execution_backed=True` paper record만으로 promotion evidence day를 채우지 않습니다. `pilot_paper`/authorized record가 target-weight plan과 execution proof를 포함하고, record date와 plan trade day가 일치하며, `execution_trade_day_allowed=True`, `execution_market_session_allowed=True`, `pilot_authorization_snapshot_allowed=True`, liquidity/pre-trade risk/order result/fill/position reconciliation complete 및 plan/execution params hash 일치를 만족한 날만 승격 카운트에 들어갑니다. 60영업일 전체 verified pilot evidence는 하나의 params hash로 고정되어야 하며, promotion package 생성과 promotion/live gate는 canonical `strategy_specs`의 `base_strategy=target_weight_rotation`을 우선 기준으로 target-weight 후보를 식별합니다. paper evidence package도 canonical params hash와 pilot proof가 맞지 않으면 `BLOCKED`로 남기며, live gate도 canonical metadata의 params hash와 paper evidence params hash가 다르면 target-weight live 전환을 차단합니다. Target-weight finalize 성과 진단: `--finalize-pilot-evidence`가 `total_value`/`daily_return` 미확정으로 막히면 finalize report에 source record 보유 성과 필드, portfolio metrics probe 수행 여부, probe에서 확인된 필드, 최종 누락 필드를 기록합니다. current blockers와 `paper_pilot_control.py --status`도 이 정보를 노출해, 성과 snapshot 대기인지 benchmark 대기인지 운영자가 구분할 수 있게 합니다. `daily_return`/`portfolio_value`/benchmark excess 누락처럼 finalize로 promotable record가 될 수 있는 사유는 repair보다 finalize를 먼저 안내합니다. + +## 2026-09-17 리스크 오버레이 — 주식 비중을 언제 줄이는가 + +종목 선택 알파가 없다는 확정 결론 위에서, 운용 중인 두 트랙의 주식 비중 조절 규칙만 바꿔 비교했다(`tools/risk_overlay_backtest.py`, 전체 표는 `docs/RISK_OVERLAY_FINDINGS.md`). 비용은 실제와 같게(ETF 거래세 없음, 주식 매도세 0.20%), 적립은 시간가중으로 분리, 신호는 전일 정보만 사용. + +- 적립 트랙(코스피200 50% + 현금성, 2002~2026): 200일선 ±2% 히스테리시스 추세 필터(아래면 절반)는 연수익률 7.45→7.19%에 최대낙폭 −28.8→−21.8%, 최악 연도 −18.7→−12.3%, 샤프 0.42→0.45. 히스테리시스가 없으면 회전율이 184%/년으로 3배가 된다. +- 적립 트랙(KODEX 200 실제 ETF, 2014~): 추세 필터로 최악 연도 −11.2→−4.6%. 200일선 위에서 한 달 안에 무너진 구간(2020-03, 2026 여름)은 추세 필터가 못 막고 낙폭 제어(−10%에서 절반)가 맡는다: 최대낙폭 −21.7→−17.3%, 결합 시 −16.3%, 샤프 0.59→0.64. +- 관찰 트랙(대형주 10종목, 2021-12~): 낙폭 제어가 최대낙폭 −26.1→−16.4%, 샤프 0.82→0.85, 칼마 0.70→0.97(연수익률 18.3→15.8%). 추세 필터는 거래세 때문에 회전 비용이 크고 낙폭도 거의 못 줄였다. +- 변동성 목표(15~20%)는 고변동 구간이 고수익 구간이라 연수익률을 2~9%p 깎는다. 옵션으로만 남기고 기본 off. + +반영: `core/risk_overlays.py`(순수 함수 + 상태 파일 `data/overlay_state/.json`) + `BasketRebalancer._stock_fraction = 설계 × 배수`. 배치 진단·트리거·헬스(`main.py --mode health`)·대시보드 API가 전부 '그날의 적용 비중'을 본다. 데이터가 없으면 직전 상태를 유지하고 `data_issues`로 표면화한다. `kr_pocket`은 추세 필터 + 낙폭 제어를 켰고(2026-09-17 현재 코스피200 200일선 +12%, 낙폭 −5%라 즉시 바뀌는 주문 없음), `kr_diversified_hold`는 실전 전환 승인 대기 중이라 권장값만 기록하고 껐다. 신호형 전략은 손대지 않았다 — 구조(회전율 1,860%/년, 익절 상한) 문제라 오버레이 대상이 아니다. diff --git a/docs/RISK_OVERLAY_FINDINGS.md b/docs/RISK_OVERLAY_FINDINGS.md new file mode 100644 index 00000000..4ee5b8a3 --- /dev/null +++ b/docs/RISK_OVERLAY_FINDINGS.md @@ -0,0 +1,109 @@ +# 리스크 오버레이 검증 — 주식 비중을 언제 줄이면 손실이 얼마나 줄어드는가 + +> 작성 2026-09-17. 재현: `python tools/risk_overlay_backtest.py` → `reports/research/risk_overlay_backtest.{md,json}`, 그림은 `docs/images/overlay-*.png`. + +## 왜 이 실험인가 + +이 저장소의 확정 결론([PROFITABILITY_FINDINGS.md](PROFITABILITY_FINDINGS.md))은 두 줄이다. + +1. 넓은 유니버스에서 종목 선택으로 시장을 이기는 알파는 없었다. 모멘텀·저변동성·리스크패리티·SMA 리스크오프 전부 동일비중 보유에 졌다. +2. 손익의 크기를 정하는 것은 주식 비중이다. 주식 50~60%면 낙폭이 절반이 되고 샤프는 오히려 오른다([STATIC_ALLOCATION.md](STATIC_ALLOCATION.md)). + +그래서 "수익률을 높이는 새 신호"를 찾는 대신, **이미 운용 중인 두 트랙의 주식 비중을 언제 줄이고 언제 되돌리는지**만 바꿔 봤다. 종목은 그대로, 비용은 실제와 같게, 적립은 시간가중으로 분리해서. + +2026년 기준으로 참고한 근거는 이렇다. + +- 변동성 목표(vol targeting)는 주식·크레딧에서 샤프를 높이고 왼쪽 꼬리를 줄인다. 채권·통화·원자재에서는 효과가 거의 없다. — [Man Group, The Impact of Volatility Targeting](https://www.man.com/insights/the-impact-of-volatility-targeting) +- 10개월(≈200일) 이동평균 규칙은 1901~2012년 미국 주식에서 연수익률을 유지하며 최대낙폭을 −83%에서 −50%로 줄였고, 2025년까지 연장한 검증에서도 유효했다. 월 1회 평가로 회전이 낮다는 점이 핵심이다. — [Faber, A Quantitative Approach to Tactical Asset Allocation](https://www.trendfollowing.com/whitepaper/CMT-Simple.pdf), [CXO Advisory 재검증](https://www.cxoadvisory.com/technical-trading/long-term-outperformance-from-trends-defined-by-moving-averages/) +- 2026년 상반기 코스피 일간 변동성은 3.6%로 2025년(1.4%)의 두 배를 넘었다. 위험 관리가 더 중요해진 환경이다. — [자본시장연구원](https://www.kcmi.re.kr/report/report_view?report_no=2315) +- 2026년 1월부터 코스피 증권거래세는 0.05% + 농특세 0.15% = 매도 금액의 0.20%다. 국내 주식형 ETF는 거래세가 없다. 회전이 많은 정책일수록 개별 주식 트랙에서 불리하고, ETF 트랙에서는 부담이 작다. — [머니투데이](https://www.mt.co.kr/economy/2025/12/01/2025120108471236062), [KCIE ETF 세금 안내](https://www.kcie.or.kr/mobile/guide/3/18/web_view?series_idx=&content_idx=900) +- 2026년 시스템 운용의 화두는 "수익률보다 위기 국면에서의 행동"이다. 개인 투자자의 가장 큰 위험은 손실 구간에서 전략을 버리는 것이고, 정해진 손실 한도와 기계적 리밸런싱이 그 위험을 줄인다. — [HedgeNordic, Systematic Strategies 2026](https://hedgenordic.com/2026/06/systematic-strategies-2026/), [BNP Paribas AM](https://www.bnpparibas-am.com/en-us/institutional/forward-thinking/quant-investing-in-2026-data-ai-and-human-judgment/) + +## 실험 설계 + +| 항목 | 내용 | +|---|---| +| 트랙 1 (적립) | 위험자산 1개(코스피200 지수 2002~ / KODEX 200 ETF 2014~) + 현금성(연 3%) · 목표 주식 비중 50% · 시작 30만원, 매월 10만원 적립 | +| 트랙 2 (관찰) | 대형주 10종목 동일비중을 하나의 지수로 묶음 · 목표 주식 비중 60% · 2021-12~2026-09 (2022년 약세장 포함) | +| 비용 | ETF: 편도 수수료 0.015% + 슬리피지 0.05%, 거래세 없음. 주식: 매도 시 0.20% 추가 | +| 리밸런싱 | 목표와 실제 비중 차이 8%p 초과, 또는 정책 배수가 바뀐 날 | +| 신호 | 전부 전일까지의 정보만 사용 | +| 성과 | 시간가중수익률(적립은 수익이 아님), 최대낙폭, 샤프, 칼마, 최악 연도, 손실 연도 수, 연 회전율 | + +정책은 다섯 가지와 그 조합이다. + +- **추세 필터**: 코스피200이 200일선 아래로 2% 넘게 내려가면 주식 비중을 절반(또는 0)으로, 위로 2% 넘게 올라와야 복귀. 2% 띠(히스테리시스)가 없으면 선 근처에서 왕복 매매가 나 회전율이 3배가 된다. +- **낙폭 제어**: 시간가중 NAV가 고점 대비 −10% 아래면 절반, −5% 안으로 회복하면 복귀. +- **변동성 목표**: 최근 60일 실현 변동성이 목표(15% 또는 20%)를 넘으면 목표/실현 비율만큼 축소(0.1 단위, 하한 0.5). + +## 결과 + +![적립 트랙 2002~](images/overlay-pocket-ks200.png) + +### 적립 트랙 · 코스피200 지수 · 2002-01 → 2026-09 + +| 정책 | 연수익률 | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 고정 비중(현행) | +7.45% | −28.8% | 0.42 | 0.26 | −18.7% | 7/25 | 51% | 9% | +| **추세 필터 · 200일선 아래면 절반** | +7.19% | −21.8% | **0.45** | 0.33 | −12.3% | 7/25 | 41% | 54% | +| 추세 필터 · 200일선 아래면 0 | +6.79% | −21.8% | 0.44 | 0.31 | −6.8% | 10/25 | 31% | 102% | +| 변동성 목표 15% | +5.79% | −17.8% | 0.38 | 0.33 | −12.3% | 6/25 | 41% | 58% | +| 변동성 목표 20% | +6.32% | −20.9% | 0.39 | 0.30 | −14.7% | 6/25 | 46% | 30% | +| 낙폭 제어 · −10%에서 절반 | +6.91% | −23.8% | 0.41 | 0.29 | −14.6% | 7/25 | 48% | 32% | +| 추세 절반 + 낙폭 제어 | +6.66% | −20.3% | 0.43 | 0.33 | −13.1% | 7/25 | 41% | 68% | +| 추세 절반 + 변동성 20% | +6.22% | −13.8% | 0.44 | **0.45** | −8.9% | 7/25 | 37% | 69% | + +![적립 트랙 ETF 2014~](images/overlay-pocket-etf.png) + +### 적립 트랙 · KODEX 200 실제 ETF · 2014-06 → 2026-09 + +| 정책 | 연수익률 | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 고정 비중(현행) | +9.62% | −21.7% | 0.59 | 0.44 | −11.2% | 4/13 | 51% | 17% | +| **추세 필터 · 200일선 아래면 절반** | +9.32% | −21.7% | 0.62 | 0.43 | **−4.6%** | 4/13 | 40% | 57% | +| 추세 필터 · 200일선 아래면 0 | +8.86% | −21.7% | 0.60 | 0.41 | −6.1% | 3/13 | 28% | 101% | +| 변동성 목표 15% | +6.59% | −15.7% | 0.48 | 0.42 | −10.0% | 4/13 | 44% | 69% | +| 변동성 목표 20% | +7.44% | −17.0% | 0.52 | 0.44 | −11.0% | 4/13 | 48% | 36% | +| 낙폭 제어 · −10%에서 절반 | +8.84% | −17.3% | 0.59 | 0.51 | −11.2% | 4/13 | 50% | 43% | +| **추세 절반 + 낙폭 제어** | +8.76% | **−16.3%** | **0.64** | 0.54 | −4.6% | 4/13 | 39% | 83% | +| 추세 절반 + 변동성 20% | +7.45% | −11.9% | 0.61 | 0.63 | −4.3% | 4/13 | 36% | 69% | + +![관찰 트랙](images/overlay-basket.png) + +### 관찰 트랙 · 대형주 10종목 동일비중 · 2021-12 → 2026-09 + +| 정책 | 연수익률 | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 고정 비중(현행) | +18.32% | −26.1% | 0.82 | 0.70 | −13.5% | 2/6 | 61% | 22% | +| 추세 필터 · 200일선 아래면 절반 | +16.36% | −24.9% | 0.76 | 0.66 | −12.6% | 2/6 | 53% | 86% | +| 추세 필터 · 200일선 아래면 0 | +13.92% | −24.9% | 0.65 | 0.56 | −11.3% | 2/6 | 45% | 150% | +| 변동성 목표 15% | +9.70% | −15.1% | 0.61 | 0.64 | −11.8% | 2/6 | 41% | 97% | +| 변동성 목표 20% | +12.40% | −18.0% | 0.71 | 0.69 | −14.2% | 2/6 | 51% | 100% | +| **낙폭 제어 · −10%에서 절반** | +15.84% | **−16.4%** | **0.85** | **0.97** | −10.6% | 2/6 | 43% | 78% | +| 추세 절반 + 낙폭 제어 | +15.50% | −16.4% | 0.86 | 0.95 | −10.0% | 2/6 | 38% | 100% | +| 추세 절반 + 변동성 20% | +10.59% | −16.1% | 0.64 | 0.66 | −12.9% | 2/6 | 44% | 139% | + +## 읽는 법 — 정직하게 + +- **수익률을 올리는 정책은 없다.** 전부 연수익률을 조금씩 깎는다. 이 결과는 확정 결론과 같은 말을 한다. 베타의 수익을 더 키우는 방법은 주식 비중을 높이는 것뿐이고, 그건 낙폭도 같이 키운다. +- **손실을 줄이는 정책은 있다.** 적립 트랙에서 추세 필터(절반)는 25년간 연수익률 0.26%p를 내주고 최악 연도를 −18.7%에서 −12.3%로, 최대낙폭을 −28.8%에서 −21.8%로 줄였다. 샤프는 오히려 올랐다. ETF 구간(2014~)에서도 최악 연도가 −11.2%에서 −4.6%로 줄었다. +- **추세 필터가 못 막는 낙폭이 있다.** ETF 구간의 −21.7%는 2020년 3월과 2026년 여름처럼 200일선 위에서 한 달 안에 무너진 구간이다. 이걸 줄이는 것은 낙폭 제어(−17.3%, 결합 시 −16.3%)다. 두 개는 서로 다른 종류의 하락을 맡는다. +- **변동성 목표는 한국에서 비싸다.** 고변동 구간이 고수익 구간(2020~21, 2024~26)이었기 때문에 연수익률을 2~9%p 깎는다. 이전 연구에서 역변동성 가중이 진 것과 같은 이유다. 옵션으로만 남기고 기본은 끈다. +- **관찰 트랙(개별 주식)은 낙폭 제어가 맞다.** 추세 필터는 거래세 때문에 회전 비용이 크고 낙폭도 거의 못 줄였다. 낙폭 제어는 최대낙폭 −26.1%→−16.4%, 샤프 0.82→0.85, 칼마 0.70→0.97에 연수익률 2.5%p를 내준다. +- **표본 한계.** 관찰 트랙은 4.8년, 강세장 비중이 크다. 적립 트랙의 2002~2014 구간은 배당을 뺀 가격지수라 실제보다 보수적이다. 모든 숫자는 소수 주 단위의 연구용 시뮬레이션이고 실제 운용은 1주 단위라 소액에서 오차가 생긴다. + +## 운용 반영 + +| 트랙 | 적용 | 근거 | +|---|---|---| +| `kr_pocket` (적립, 주력) | **추세 필터 + 낙폭 제어 켬** (`config/baskets.yaml` `overlays`) | 이 트랙의 목적이 "낙폭 최소, 용돈 수준 수익"이라 연 0.3%p를 주고 최악 연도를 절반으로 줄이는 교환이 맞다. ETF라 회전 비용이 수수료뿐이다. 2026-09-17 현재 코스피200은 200일선 +12%, 낙폭 −5%라 켜도 당장 바뀌는 주문은 없다. | +| `kr_diversified_hold` (관찰) | **꺼 둠**, 낙폭 제어 권장값만 기록 | 60거래일 검증을 마치고 실전 전환 승인을 기다리는 중이다. 정책을 바꾸면 검증 대상이 달라지므로 승인 뒤 운영자가 결정한다. | +| `scoring` 등 신호 전략 | 손대지 않음 | 정직 평가에서 동일비중 보유 대비 −140%p로 이미 `paper_only`다. 오버레이로 고칠 문제가 아니라 구조(회전율 1,860%/년, 익절 상한)의 문제고, 확정 결론대로 돈이 실리는 경로가 아니다. | + +구현은 `core/risk_overlays.py`(순수 함수·상태 파일)와 `core/basket_rebalancer.py`(설계 비중 × 배수)에 있다. 원칙은 세 가지다. + +1. 배수는 설계를 대체하지 않고 곱한다. 설계 95% × 0.5 = 47.5%. 배치 진단·트리거·헬스·대시보드가 전부 "그날의 적용 비중"을 본다. 오버레이가 비중을 줄인 날 헬스가 미달 경보를 울리지 않는다. +2. 상태(추세 아래 / 낙폭 발동)는 `data/overlay_state/.json`으로 이어진다. 히스테리시스는 상태가 있어야 성립한다. +3. 데이터가 없으면 새 판단을 지어내지 않는다. 직전 상태를 유지하고 `data_issues`에 남기며, 대시보드가 그 사실을 보여준다. "오류 0건인데 설계대로 안 돈다"를 막기 위한 이 저장소의 관행이다. + +끄는 방법은 `enabled: false` 한 줄이다. 다음 사이클부터 설계 비중으로 돌아간다. diff --git a/docs/images/dashboard-deposit.png b/docs/images/dashboard-deposit.png deleted file mode 100644 index 36f5a7fa..00000000 Binary files a/docs/images/dashboard-deposit.png and /dev/null differ diff --git a/docs/images/dashboard-main.png b/docs/images/dashboard-main.png deleted file mode 100644 index 6e68b4af..00000000 Binary files a/docs/images/dashboard-main.png and /dev/null differ diff --git a/docs/images/dashboard-operations.png b/docs/images/dashboard-operations.png index df083b50..321fe351 100644 Binary files a/docs/images/dashboard-operations.png and b/docs/images/dashboard-operations.png differ diff --git a/docs/images/dashboard-overview.png b/docs/images/dashboard-overview.png deleted file mode 100644 index fd9daa0d..00000000 Binary files a/docs/images/dashboard-overview.png and /dev/null differ diff --git a/docs/images/dashboard-performance.png b/docs/images/dashboard-performance.png index 70aa0504..38b451fd 100644 Binary files a/docs/images/dashboard-performance.png and b/docs/images/dashboard-performance.png differ diff --git a/docs/images/dashboard-portfolio.png b/docs/images/dashboard-portfolio.png index 6efc5219..3f35da5c 100644 Binary files a/docs/images/dashboard-portfolio.png and b/docs/images/dashboard-portfolio.png differ diff --git a/docs/images/dashboard-story-drawdown.png b/docs/images/dashboard-story-drawdown.png new file mode 100644 index 00000000..214faff3 Binary files /dev/null and b/docs/images/dashboard-story-drawdown.png differ diff --git a/docs/images/dashboard-story.png b/docs/images/dashboard-story.png new file mode 100644 index 00000000..f9e48d78 Binary files /dev/null and b/docs/images/dashboard-story.png differ diff --git a/docs/images/dashboard-today.png b/docs/images/dashboard-today.png new file mode 100644 index 00000000..ee0a3c5a Binary files /dev/null and b/docs/images/dashboard-today.png differ diff --git a/docs/images/overlay-basket.png b/docs/images/overlay-basket.png new file mode 100644 index 00000000..f8b0403f Binary files /dev/null and b/docs/images/overlay-basket.png differ diff --git a/docs/images/overlay-pocket-etf.png b/docs/images/overlay-pocket-etf.png new file mode 100644 index 00000000..38b9efed Binary files /dev/null and b/docs/images/overlay-pocket-etf.png differ diff --git a/docs/images/overlay-pocket-ks200.png b/docs/images/overlay-pocket-ks200.png new file mode 100644 index 00000000..89c84a5a Binary files /dev/null and b/docs/images/overlay-pocket-ks200.png differ diff --git a/main.py b/main.py index 39a0eef1..c08a8544 100644 --- a/main.py +++ b/main.py @@ -1906,10 +1906,20 @@ def run_health_check() -> int: enabled_baskets = BasketRebalancer.get_enabled_baskets() baskets_cfg = BasketRebalancer._load_baskets_config() - def _design_fraction(cfg: dict) -> float: - """BasketRebalancer._stock_fraction과 동일 규칙(인스턴스 없이 계산).""" + def _design_fraction(cfg: dict, basket_name: str | None = None) -> float: + """BasketRebalancer._stock_fraction과 동일 규칙(인스턴스 없이 계산). + + 리스크 오버레이가 켜진 바스켓은 마지막 실행이 남긴 배수를 곱한 '적용 비중'을 + 쓴다 — 오버레이가 비중을 절반으로 줄인 날 설계 비중과 비교하면 매일 거짓 + 미달 경보가 울리기 때문이다(선언한 숫자마다 감시가 붙는 원칙의 대상은 + 그날 실제로 유효한 목표다). + """ from core.basket_deploy import effective_stock_fraction - return effective_stock_fraction(cfg, config.risk_params) + from core.risk_overlays import applied_stock_fraction, load_overlay_state, parse_overlay_config + design = effective_stock_fraction(cfg, config.risk_params) + if basket_name and parse_overlay_config(cfg).any_enabled: + return applied_stock_fraction(design, load_overlay_state(basket_name)) + return design # 바스켓별 전용 계정 키(basket_rebalance:) 기준으로 조회한다. # 복수 바스켓이면 '가장 오래된 최신 스냅샷'을 기준으로(가장 뒤처진 사이클 감시). @@ -1952,7 +1962,7 @@ def _design_fraction(cfg: dict) -> float: cfg_b = baskets_cfg.get(name) or {} dep_ratio = max(0.0, (snap.total_value - (snap.cash or 0)) / snap.total_value) - design = _design_fraction(cfg_b) + design = _design_fraction(cfg_b, name) floor_tol = float( (cfg_b.get("monitoring") or {}).get("deployment_tolerance", 0.05) ) diff --git a/monitoring/static/dashboard.css b/monitoring/static/dashboard.css index 1bd0ae3a..0e87111d 100644 --- a/monitoring/static/dashboard.css +++ b/monitoring/static/dashboard.css @@ -1,1711 +1,921 @@ -:root { - color-scheme: light; - --paper: #f4f0e7; - --paper-deep: #e9e3d7; - --surface: #fffdf8; - --surface-muted: #f8f5ee; - --ink: #18201f; - --ink-soft: #34413e; - --muted: #5d6966; - --faint: #626d69; - --line: #d8d5cc; - --line-strong: #b9b7af; - --oxide: #b84a2a; - --oxide-dark: #8f351d; - --oxide-soft: #f3dfd6; - --positive: #2d6f5e; - --positive-soft: #dfece6; - --negative: #a52e43; - --negative-soft: #f4dfe3; - --warning: #8a5a14; - --warning-soft: #f2e6cf; - --info: #4c668f; - --info-soft: #e2e8f1; - --focus: #2f65bd; - --shadow-dialog: 0 24px 70px rgb(24 32 31 / 22%); - --radius-xs: 4px; - --radius-sm: 8px; - --radius-md: 12px; - --radius-lg: 20px; - --content-width: 1240px; - --font-sans: "IBM Plex Sans KR", Pretendard, "Segoe UI", system-ui, sans-serif; - --font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace; -} - -* { - box-sizing: border-box; -} +/* 눈금 대시보드 — 종이·잉크 두 색, 손익 색만 한국 관례(상승 빨강·하락 파랑). + 글꼴 두 가족(Inter Tight 숫자/라틴 + Noto Sans KR 한글), 굵기 400·500만 사용. + 장식은 헤어라인 규칙과 여백으로만 만든다. 카드·유리·그림자·그라데이션 없음. */ -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} +:root { + --paper: #f2f0ec; + --paper-2: #e8e5df; + --paper-3: #dcd8d0; + --ink: #0d0c0b; + --ink-soft: rgba(13, 12, 11, 0.64); + --ink-faint: rgba(13, 12, 11, 0.42); + --rule: rgba(13, 12, 11, 0.16); + --rule-strong: rgba(13, 12, 11, 0.34); + --up: #c33d2b; + --down: #2557c9; + --warn: #b06d12; + --stop: #b3261e; + --ease: cubic-bezier(0.22, 0.61, 0.36, 1); + --pill-bg: #0a0908; + --pill-fg: #ffffff; + --font: "Inter Tight", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; + --gutter: clamp(16px, 3.4vw, 44px); + --content: 1200px; + --chrome-h: 64px; +} + +* { box-sizing: border-box; } html { - scroll-behavior: smooth; - scrollbar-color: var(--line-strong) var(--paper); + -webkit-text-size-adjust: 100%; background: var(--paper); + scrollbar-color: var(--rule-strong) var(--paper); } body { margin: 0; min-width: 320px; - min-height: 100dvh; - overflow-x: hidden; background: var(--paper); color: var(--ink); - font-family: var(--font-sans); - font-size: 16px; + font-family: var(--font); + font-weight: 400; + font-size: 15.5px; line-height: 1.55; - letter-spacing: -0.012em; - text-rendering: optimizeLegibility; + letter-spacing: -0.008em; + word-break: keep-all; + overflow-wrap: anywhere; -webkit-font-smoothing: antialiased; - -webkit-tap-highlight-color: rgb(184 74 42 / 14%); + -moz-osx-font-smoothing: grayscale; + overflow-x: hidden; + font-variant-numeric: tabular-nums; } -button, -select, -input { - font: inherit; -} +h1, h2, h3, h4, p, dl, dd, figure { margin: 0; } +button, select, input { font: inherit; color: inherit; letter-spacing: inherit; } +button { cursor: pointer; } +a { color: inherit; } +[hidden] { display: none !important; } -button, -a, -select, -summary { - touch-action: manipulation; +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } -button, -select, -summary, -a { - cursor: pointer; +:focus-visible { + outline: 2px solid var(--ink); + outline-offset: 2px; } -button:focus-visible, -select:focus-visible, -input:focus-visible, -summary:focus-visible, -a:focus-visible, -[tabindex="0"]:focus-visible { - outline: 3px solid var(--focus); - outline-offset: 3px; +.skip-link { + position: fixed; + top: 12px; + left: 12px; + z-index: 100; + padding: 8px 14px; + border-radius: 999px; + background: var(--pill-bg); + color: var(--pill-fg); + font-size: 13px; + text-decoration: none; + transform: translateY(-200%); } +.skip-link:focus { transform: none; } -[hidden] { - display: none !important; -} +/* ---------- 프리로더 ---------- */ -.skip-link { +.boot { position: fixed; - top: 10px; - left: 10px; - z-index: 1000; - padding: 10px 14px; - transform: translateY(-160%); + inset: 0; + z-index: 90; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + background: var(--paper); + transition: opacity 0.7s var(--ease), visibility 0.7s; +} +.boot.done { opacity: 0; visibility: hidden; } +.boot p { font-size: 12.5px; letter-spacing: 0.02em; color: var(--ink-faint); } +.boot-bar { width: 150px; height: 1px; background: var(--rule); overflow: hidden; } +.boot-bar i { + display: block; + height: 100%; + width: 100%; background: var(--ink); - color: #fff; - border-radius: var(--radius-sm); - font-weight: 700; - transition: transform 160ms ease-out; + transform: scaleX(0); + transform-origin: 0 50%; + transition: transform 0.25s var(--ease); } -.skip-link:focus { - transform: translateY(0); -} +/* ---------- 스크롤 진행 헤어라인 ---------- */ -.site-header { - position: sticky; +.meter { + position: fixed; top: 0; + left: 0; z-index: 50; - border-bottom: 1px solid rgb(24 32 31 / 12%); - background: rgb(244 240 231 / 94%); - backdrop-filter: blur(16px) saturate(1.1); + width: 100%; + height: 2px; + background: var(--ink); + opacity: 0.55; + transform: scaleX(0); + transform-origin: 0 50%; + transition: opacity 0.4s var(--ease); } +.meter.off { opacity: 0; } -.header-inner { - width: min(100%, var(--content-width)); - min-height: 72px; - margin: 0 auto; - padding: 12px 24px; - display: grid; - grid-template-columns: minmax(180px, auto) 1fr auto; - align-items: center; - gap: 24px; -} +/* ---------- 상단 크롬 ---------- */ -.brand { - display: inline-flex; +.chrome { + position: fixed; + top: 0; + right: 0; + left: 0; + z-index: 40; + display: flex; align-items: center; - gap: 11px; - min-height: 44px; - min-width: 0; - color: var(--ink); - text-decoration: none; + justify-content: space-between; + gap: 12px; + padding: max(14px, calc(env(safe-area-inset-top, 0px) + 12px)) var(--gutter) 14px; + background: linear-gradient(to bottom, rgba(242, 240, 236, 0.94) 0%, rgba(242, 240, 236, 0.78) 72%, rgba(242, 240, 236, 0) 100%); + transition: background 0.35s var(--ease), box-shadow 0.35s var(--ease); } - -.brand img { - flex: 0 0 auto; +.chrome.solid { + background: rgba(242, 240, 236, 0.96); + box-shadow: 0 1px 0 var(--rule); } -.brand-copy { +.mark { display: flex; - align-items: baseline; - gap: 8px; + align-items: center; + gap: 9px; min-width: 0; + font-size: 15px; + font-weight: 500; + letter-spacing: -0.012em; + text-decoration: none; } +.mark img { display: block; } -.brand-copy strong { - font-size: 1.2rem; - font-weight: 700; - letter-spacing: -0.045em; -} - -.brand-copy span { - color: var(--muted); - font-family: var(--font-mono); - font-size: 0.67rem; - font-weight: 600; - letter-spacing: 0.13em; -} - -.header-state { - justify-self: end; +.nav { display: flex; align-items: center; - gap: 8px; - min-width: 0; - color: var(--muted); - font-size: 0.76rem; + gap: clamp(14px, 2.2vw, 26px); + flex-shrink: 0; +} +.nav > a { + font-size: 14.5px; + text-decoration: none; + opacity: 0.88; + transition: opacity 0.3s var(--ease); } +.nav > a:hover, +.nav > a[aria-current="true"] { opacity: 1; } +.nav > a[aria-current="true"] { text-decoration: underline; text-underline-offset: 6px; text-decoration-thickness: 1px; } -.header-state time { - color: var(--faint); - font-family: var(--font-mono); - font-size: 0.75rem; - font-variant-numeric: tabular-nums; +.state { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 12.5px; + color: var(--ink-soft); + white-space: nowrap; } +.state time { color: var(--ink-faint); } -.sync-mark { - width: 9px; - height: 9px; +.dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--ink); flex: 0 0 auto; - border: 1px solid currentColor; - background: transparent; } +.dot[data-state="ok"] { background: var(--ink); } +.dot[data-state="info"] { background: var(--ink-faint); } +.dot[data-state="partial"], +.dot[data-state="warning"] { background: var(--warn); } +.dot[data-state="error"] { background: var(--stop); } +.dot[data-state="loading"] { background: var(--ink-faint); animation: blink 1.2s steps(2, jump-none) infinite; } +@keyframes blink { 50% { opacity: 0.3; } } -.sync-mark[data-state="ok"] { - color: var(--positive); - background: var(--positive); +.mode { + font-size: 12.5px; + color: var(--ink-soft); + white-space: nowrap; } +.mode[data-mode="live"] { color: var(--up); font-weight: 500; } -.sync-mark[data-state="partial"] { - color: var(--warning); - background: var(--warning); +.pill { + display: inline-flex; + align-items: center; + justify-content: center; + height: 40px; + padding: 0 21px; + border: 1px solid rgba(10, 9, 8, 0.12); + border-radius: 999px; + background: var(--pill-bg); + color: var(--pill-fg); + -webkit-text-fill-color: var(--pill-fg); + font-size: 14.5px; + font-weight: 500; + letter-spacing: -0.008em; + text-decoration: none; + white-space: nowrap; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.1) inset; + transition: transform 0.4s var(--ease), background 0.3s var(--ease), color 0.3s var(--ease), opacity 0.3s var(--ease); } - -.sync-mark[data-state="error"] { - color: var(--negative); - background: var(--negative); +.pill:hover:not(:disabled), +.pill:focus-visible { transform: translateY(-2px); background: #000; } +.pill:disabled { cursor: not-allowed; opacity: 0.38; } +.pill-line { + background: transparent; + color: var(--ink); + -webkit-text-fill-color: var(--ink); + border-color: var(--rule-strong); + box-shadow: none; } +.pill-line:hover:not(:disabled), +.pill-line:focus-visible { background: var(--ink); color: var(--pill-fg); -webkit-text-fill-color: var(--pill-fg); } -.sync-mark[data-state="loading"] { - color: var(--info); - animation: sync-blink 1.2s steps(2, jump-none) infinite; +.link { + padding: 0; + border: 0; + background: transparent; + color: var(--ink); + font-size: 14.5px; + font-weight: 500; + text-decoration: underline; + text-underline-offset: 5px; + text-decoration-thickness: 1px; + min-height: 36px; } +.link:hover { text-decoration-thickness: 2px; } -@keyframes sync-blink { - 50% { opacity: 0.35; } -} +/* ---------- 스크롤 스크럽 스테이지 ---------- */ -.header-actions { - display: flex; - align-items: center; - gap: 10px; +.story { + position: relative; + height: 380vh; + min-height: 2400px; } -.mode-badge { - min-height: 32px; - display: inline-flex; - align-items: center; - padding: 5px 9px; - border: 1px solid var(--line-strong); - border-radius: var(--radius-xs); - background: var(--surface); - color: var(--ink-soft); - font-family: var(--font-mono); - font-size: 0.68rem; - font-weight: 600; - letter-spacing: 0.04em; - white-space: nowrap; +.stage { + position: sticky; + top: 0; + height: 100vh; + height: 100svh; + overflow: hidden; + background: var(--paper); } -.mode-badge[data-mode="paper"] { - border-color: #9ab0a8; - background: var(--positive-soft); - color: #1f584a; +.stage canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; } -.mode-badge[data-mode="live"] { - border-color: #c9808c; - background: var(--negative-soft); - color: #7d1f31; +.grain { + position: absolute; + inset: -50%; + opacity: 0.13; + mix-blend-mode: multiply; + pointer-events: none; + background-image: url("data:image/svg+xml;utf8,"); } -.button { - min-height: 44px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - padding: 9px 14px; - border: 1px solid transparent; - border-radius: var(--radius-sm); - font-weight: 650; - line-height: 1; - transition: background-color 160ms ease-out, border-color 160ms ease-out, color 160ms ease-out, transform 120ms ease-out; +.panels { + position: absolute; + inset: 0; + pointer-events: none; } -.button:active { - transform: translateY(1px); +.panel { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + justify-content: flex-start; + max-width: calc(var(--content) + var(--gutter) * 2); + margin: 0 auto; + padding: max(112px, calc(env(safe-area-inset-top, 0px) + 96px)) var(--gutter) 0; + opacity: 0; + will-change: opacity, transform; } -.button:disabled { - cursor: not-allowed; - opacity: 0.48; +.eyebrow { + font-size: 13px; + color: var(--ink-soft); + margin-bottom: clamp(14px, 2vw, 22px); + max-width: min(52ch, 100%); } -.button-primary { - border-color: var(--oxide); - background: var(--oxide); - color: #fff; +.display { + font-weight: 500; + font-size: clamp(40px, 7.2vw, 108px); + line-height: 0.98; + letter-spacing: -0.036em; + max-width: 14ch; + text-wrap: balance; } - -.button-primary:hover:not(:disabled) { - border-color: var(--oxide-dark); - background: var(--oxide-dark); +.display .unit { + font-size: 0.42em; + font-weight: 400; + letter-spacing: -0.01em; + color: var(--ink-soft); + margin-left: 0.12em; } - -.button-secondary { - border-color: var(--line-strong); - background: var(--surface); - color: var(--ink); +.display .small { + display: block; + margin-top: 0.34em; + font-size: clamp(13px, 1.1vw, 15px); + font-weight: 400; + letter-spacing: -0.008em; + line-height: 1.4; + color: var(--ink-soft); } -.button-secondary:hover:not(:disabled) { - border-color: var(--ink-soft); - background: var(--surface-muted); +.sub { + margin-top: clamp(16px, 2.2vw, 26px); + font-size: clamp(15px, 1.28vw, 18px); + line-height: 1.55; + color: var(--ink-soft); + max-width: min(46ch, 100%); + text-wrap: pretty; } -.page-shell { - width: min(100%, var(--content-width)); - margin: 0 auto; - padding: 36px 24px 64px; +.cta { + margin-top: clamp(24px, 3vw, 36px); + pointer-events: auto; } +.cta .pill { height: 44px; padding: 0 24px; } -.decision-panel { +.story-hint { + position: absolute; + left: var(--gutter); + bottom: max(20px, calc(env(safe-area-inset-bottom, 0px) + 14px)); + display: flex; + align-items: center; + gap: 10px; + font-size: 12.5px; + color: var(--ink-faint); + transition: opacity 0.4s var(--ease); +} +.story-hint i { + width: 28px; + height: 1px; + background: var(--ink-faint); position: relative; - display: grid; - grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr); - gap: 36px; - align-items: end; - min-height: 310px; - padding: 40px; overflow: hidden; - border: 1px solid var(--ink); - border-radius: var(--radius-md); - background: var(--ink); - color: var(--paper); } - -.decision-panel::before { +.story-hint i::after { content: ""; position: absolute; - top: 0; - left: 0; - width: 86px; - height: 8px; - background: var(--oxide); -} - -.eyebrow, -.section-index { - margin: 0 0 10px; - color: var(--oxide); - font-family: var(--font-mono); - font-size: 0.7rem; - font-weight: 600; - letter-spacing: 0.13em; - text-transform: uppercase; -} - -.decision-panel .eyebrow { - color: #e68b6d; + inset: 0 auto 0 0; + width: 10px; + background: var(--ink); + animation: hint 1.6s var(--ease) infinite; } +@keyframes hint { from { transform: translateX(-12px); } to { transform: translateX(32px); } } -.decision-copy h1 { - max-width: 16ch; - margin: 0; - font-size: clamp(2rem, 4vw, 4rem); - font-weight: 600; - line-height: 1.08; - letter-spacing: -0.055em; - text-wrap: balance; - word-break: keep-all; -} +/* ---------- 본문 ---------- */ -.decision-copy > p:not(.eyebrow) { - max-width: 54ch; - margin: 18px 0 0; - color: #c9cfcb; - font-size: 1rem; - line-height: 1.7; - text-wrap: pretty; +.page { + max-width: calc(var(--content) + var(--gutter) * 2); + min-width: 0; + margin: 0 auto; + padding: 24px var(--gutter) 40px; } +.sec, .tracks, .track, .track > * { min-width: 0; } -.decision-meta { - min-height: 24px; - margin-top: 18px; - color: #9eaaa5; - font-size: 0.8rem; +.sec { + padding: 64px 0 24px; + border-top: 1px solid var(--rule); + scroll-margin-top: var(--chrome-h); } +.sec:first-child { border-top: 0; padding-top: 40px; } -.decision-meta strong { - color: var(--paper); +.sec-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + margin-bottom: 28px; } - -.text-action { - min-height: 44px; - margin-top: 18px; - padding: 0; - border: 0; - border-bottom: 1px solid #df8b6f; - background: transparent; - color: #f3ad94; - font-weight: 650; +.sec-label { + font-size: 13px; + font-weight: 500; + letter-spacing: 0; } - -.text-action:hover { - color: #fff; - border-color: #fff; +.sec-meta { + font-size: 12.5px; + color: var(--ink-faint); + text-align: right; } -.summary-ledger { - margin: 0; - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - border-top: 1px solid #4d5754; - border-left: 1px solid #4d5754; +.loading, +.fine { + font-size: 12.5px; + line-height: 1.6; + color: var(--ink-faint); + max-width: 72ch; } +.fine { margin-top: 14px; } +.fine.error { color: var(--stop); } +.loading { padding: 12px 0; } -.summary-ledger > div { - min-height: 104px; - padding: 18px; - border-right: 1px solid #4d5754; - border-bottom: 1px solid #4d5754; +.empty { + padding: 48px 0; + border-top: 1px solid var(--rule); + border-bottom: 1px solid var(--rule); + font-size: 14px; + color: var(--ink-soft); } +.empty strong { display: block; color: var(--ink); font-weight: 500; margin-bottom: 4px; } -.summary-ledger dt { - margin-bottom: 12px; - color: #9eaaa5; - font-size: 0.74rem; -} +/* 오늘 */ -.summary-ledger dd { - margin: 0; - color: #fff; - font-family: var(--font-mono); - font-size: clamp(1.05rem, 2vw, 1.42rem); +.today-title { + font-size: clamp(26px, 3.4vw, 44px); font-weight: 500; - font-variant-numeric: tabular-nums; - letter-spacing: -0.035em; + line-height: 1.12; + letter-spacing: -0.024em; + max-width: 24ch; + text-wrap: balance; } - -.summary-ledger dd.positive { color: #7fc2ad; } -.summary-ledger dd.negative { color: #f093a2; } - -.section-nav { - display: flex; - flex-wrap: wrap; - gap: 8px 24px; - margin: 18px 0 0; - padding: 13px 0; - border-bottom: 1px solid var(--line-strong); +.today-desc { + margin-top: 14px; + max-width: 58ch; + font-size: 16px; + color: var(--ink-soft); + text-wrap: pretty; } +.today-act { margin-top: 8px; } -.section-nav a { - min-height: 44px; - display: inline-flex; +.status-row { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 0 24px; + margin: 36px 0 0; + padding: 0; + list-style: none; + border-top: 1px solid var(--rule); +} +.status-row li { + display: grid; + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + column-gap: 8px; align-items: center; - color: var(--muted); - font-size: 0.82rem; - font-weight: 600; - text-decoration: none; - text-underline-offset: 5px; + padding: 14px 0 12px; } +.status-row .dot { grid-row: 1 / 3; align-self: center; } +.status-row .k { font-size: 12.5px; color: var(--ink-soft); } +.status-row .v { font-size: 14.5px; font-weight: 500; } -.section-nav a:hover { - color: var(--oxide); - text-decoration: underline; -} +/* 포트폴리오 */ -.content-section { - scroll-margin-top: 94px; - padding-top: 74px; +.tracks { display: grid; } +.track { + padding: 30px 0 36px; + border-top: 1px solid var(--rule); } +.track:first-child { border-top: 0; padding-top: 0; } -.section-heading { +.track-head { display: flex; - align-items: end; + align-items: flex-end; justify-content: space-between; gap: 24px; - margin-bottom: 24px; } - -.section-heading h2 { - margin: 0; - font-size: clamp(1.55rem, 2.8vw, 2.4rem); - font-weight: 600; +.track-kind { font-size: 12.5px; color: var(--ink-soft); } +.track-kind b { font-weight: 500; color: var(--ink); } +.track-name { + margin-top: 6px; + font-size: clamp(20px, 2.2vw, 26px); + font-weight: 500; line-height: 1.2; - letter-spacing: -0.045em; - text-wrap: balance; + letter-spacing: -0.02em; } +.track-id { margin-top: 4px; font-size: 12px; color: var(--ink-faint); } +.spark { width: 180px; height: 52px; flex: 0 0 auto; overflow: visible; } -.section-heading p:not(.section-index) { - max-width: 62ch; - margin: 8px 0 0; - color: var(--muted); - font-size: 0.9rem; - text-wrap: pretty; +.kpis { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0 20px; + margin-top: 26px; + border-top: 1px solid var(--rule); +} +.kpis > div { padding: 14px 0 0; min-width: 0; } +.kpis dt { font-size: 12.5px; color: var(--ink-soft); } +.kpis dd { + margin-top: 6px; + font-size: clamp(19px, 1.9vw, 26px); + font-weight: 500; + letter-spacing: -0.02em; + line-height: 1.15; } +.kpis dd small { font-size: 0.6em; font-weight: 400; color: var(--ink-soft); margin-left: 2px; } -.section-asof { - color: var(--muted); - font-family: var(--font-mono); - font-size: 0.72rem; - font-variant-numeric: tabular-nums; - white-space: nowrap; -} +.up { color: var(--up); } +.down { color: var(--down); } +.flat { color: var(--ink-soft); } -.track-list { - display: grid; - gap: 14px; +.alloc { margin-top: 30px; max-width: 620px; } +.alloc-line { + display: flex; + align-items: baseline; + gap: 10px; + font-size: 13px; + color: var(--ink-soft); } - -.track-card { +.alloc-line b { font-size: 15px; font-weight: 500; color: var(--ink); } +.alloc-line .target { margin-left: auto; } +.bar { position: relative; - display: grid; - grid-template-columns: minmax(260px, 0.9fr) minmax(0, 1.45fr); - gap: 32px; - padding: 26px 28px; - border: 1px solid var(--line-strong); - border-radius: var(--radius-sm); - background: var(--surface); + height: 6px; + margin-top: 10px; + background: var(--paper-2); } - -.track-card[data-primary="true"] { - border-left: 6px solid var(--oxide); - padding-left: 23px; +.bar i { + display: block; + height: 100%; + width: 0; + background: var(--ink); + transition: width 0.9s var(--ease); +} +.bar em { + position: absolute; + top: -5px; + width: 1px; + height: 16px; + background: var(--ink); + transform: translateX(-0.5px); } +.note { margin-top: 10px; font-size: 12.5px; line-height: 1.6; color: var(--ink-soft); max-width: 60ch; } +.overlay { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--rule); font-size: 12.5px; line-height: 1.6; color: var(--ink-soft); max-width: 64ch; } +.overlay b { font-weight: 500; color: var(--ink); margin-right: 6px; } +.overlay.warn { color: var(--warn); } -.track-card[data-primary="false"] { - background: var(--surface-muted); +.track .table-scroll { margin-top: 28px; } +.holdings { + width: 100%; + min-width: 640px; + border-collapse: collapse; + font-size: 13.5px; +} +.holdings th, +.holdings td { + padding: 10px 12px 10px 0; + border-bottom: 1px solid var(--rule); + text-align: left; + vertical-align: middle; + white-space: nowrap; } +.holdings th { font-size: 12px; font-weight: 400; color: var(--ink-soft); border-bottom-color: var(--rule-strong); } +.holdings td:last-child, .holdings th:last-child { padding-right: 0; } +.holdings .sym { display: block; font-size: 11.5px; color: var(--ink-faint); } +.wbar { display: grid; grid-template-columns: 3.6em minmax(60px, 140px); align-items: center; gap: 10px; } +.wbar i { display: block; height: 4px; background: var(--ink); } +.wbar i.t { background: transparent; border: 1px solid var(--ink-faint); border-radius: 0; box-sizing: border-box; height: 4px; } +.holdings tfoot td { font-size: 12.5px; color: var(--ink-soft); border-bottom: 0; padding-top: 10px; } -.track-role { +.flow { margin-top: 18px; font-size: 12.5px; color: var(--ink-soft); } +.flow.error { color: var(--stop); } + +/* 성과 */ + +.chart-controls { display: flex; align-items: center; - gap: 8px; - margin-bottom: 16px; -} - -.role-label, -.status-label { - display: inline-flex; - min-height: 26px; - align-items: center; - padding: 3px 7px; - border: 1px solid var(--line-strong); - border-radius: var(--radius-xs); - color: var(--muted); - font-family: var(--font-mono); - font-size: 0.65rem; - font-weight: 600; - letter-spacing: 0.04em; -} - -.role-label.primary { - border-color: #d39781; - background: var(--oxide-soft); - color: var(--oxide-dark); -} - -.track-title { - margin: 0; - font-size: 1.08rem; - font-weight: 650; - letter-spacing: -0.025em; -} - -.track-id { - display: block; - margin-top: 4px; - color: var(--faint); - font-family: var(--font-mono); - font-size: 0.75rem; -} - -.track-value { - margin-top: 22px; - font-family: var(--font-mono); - font-size: clamp(1.75rem, 4vw, 2.8rem); - font-weight: 500; - font-variant-numeric: tabular-nums; - letter-spacing: -0.06em; - line-height: 1; -} - -.track-value small { - margin-left: 5px; - color: var(--muted); - font-family: var(--font-sans); - font-size: 0.8rem; - font-weight: 500; -} - -.track-principal, -.track-date { - margin-top: 8px; - color: var(--muted); - font-size: 0.76rem; -} - -.track-date { - font-family: var(--font-mono); - font-variant-numeric: tabular-nums; -} - -.track-plan { - margin: 14px 0 0; - padding-top: 12px; - border-top: 1px solid var(--line); - color: var(--ink-soft); - font-size: 0.76rem; - font-weight: 600; -} - -.track-metrics { - margin: 0; - display: grid; - grid-template-columns: repeat(4, minmax(110px, 1fr)); - border-top: 1px solid var(--line); - border-left: 1px solid var(--line); -} - -.track-metrics > div { - min-width: 0; - min-height: 88px; - padding: 14px; - border-right: 1px solid var(--line); - border-bottom: 1px solid var(--line); -} - -.track-metrics dt { - margin-bottom: 8px; - color: var(--muted); - font-size: 0.7rem; -} - -.track-metrics dd { - margin: 0; - overflow-wrap: anywhere; - font-family: var(--font-mono); - font-size: 0.92rem; - font-weight: 500; - font-variant-numeric: tabular-nums; -} - -.positive { color: var(--positive) !important; } -.negative { color: var(--negative) !important; } -.warning { color: var(--warning) !important; } -.muted { color: var(--muted) !important; } - -.allocation { - margin-top: 16px; -} - -.allocation-copy { - display: flex; - justify-content: space-between; - gap: 16px; - margin-bottom: 7px; - color: var(--muted); - font-size: 0.72rem; -} - -.allocation-copy strong { - color: var(--ink-soft); - font-family: var(--font-mono); - font-weight: 500; - font-variant-numeric: tabular-nums; -} - -.allocation-bar { - height: 8px; - overflow: hidden; - border: 1px solid var(--line); - background: var(--paper-deep); -} - -.allocation-bar span { - display: block; - height: 100%; - background: var(--ink-soft); - transition: width 260ms ease-out; -} - -.allocation-note { - margin: 8px 0 0; - color: var(--muted); - font-size: 0.75rem; - line-height: 1.55; -} - -.holdings { - margin: 16px 0 0; - padding: 0; - display: flex; - flex-wrap: wrap; - gap: 7px; - list-style: none; -} - -.holdings li { - padding: 5px 8px; - border-left: 2px solid var(--line-strong); - background: var(--surface-muted); - color: var(--ink-soft); - font-family: var(--font-mono); - font-size: 0.75rem; - font-variant-numeric: tabular-nums; -} - -.recent-flow { - margin: 14px 0 0; - color: var(--muted); - font-size: 0.73rem; -} - -.performance-layout { - display: grid; - grid-template-columns: minmax(0, 1.7fr) minmax(310px, 0.7fr); - gap: 16px; - align-items: start; -} - -.module { - min-width: 0; - border: 1px solid var(--line-strong); - border-radius: var(--radius-sm); - background: var(--surface); -} - -.module-header { - display: flex; - align-items: start; - justify-content: space-between; - gap: 24px; - padding: 22px 22px 18px; - border-bottom: 1px solid var(--line); -} - -.module-header h3 { - margin: 0; - font-size: 1.05rem; - font-weight: 650; - letter-spacing: -0.02em; -} - -.module-header p { - margin: 5px 0 0; - color: var(--muted); - font-size: 0.76rem; -} - -.chart-controls { - display: grid; - grid-template-columns: auto minmax(150px, auto); - align-items: center; - gap: 7px 10px; -} - -.chart-controls > label { - color: var(--muted); - font-size: 0.7rem; - font-weight: 600; -} - -select, -input { - min-height: 44px; - border: 1px solid var(--line-strong); - border-radius: var(--radius-sm); - background: var(--surface); - color: var(--ink); - font-size: 0.9rem; + flex-wrap: wrap; + gap: 12px 14px; + margin-bottom: 22px; } +.chart-maturity { margin-left: auto; font-size: 12.5px; color: var(--ink-faint); } select { - padding: 8px 34px 8px 10px; -} - -.range-group, -.preset-group { - margin: 0; - padding: 0; - border: 0; + appearance: none; + -webkit-appearance: none; + height: 36px; + padding: 0 34px 0 14px; + border: 1px solid var(--rule-strong); + border-radius: 999px; + background: transparent url("data:image/svg+xml;utf8,") no-repeat right 13px center; + font-size: 13.5px; + max-width: 100%; } -.range-group { - grid-column: 1 / -1; - display: flex; - justify-content: flex-end; - gap: 4px; +.seg { + display: inline-flex; + padding: 2px; + border: 1px solid var(--rule-strong); + border-radius: 999px; } - -.range-group legend, -.preset-group legend { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; +.seg button { + height: 30px; + padding: 0 13px; border: 0; -} - -.range-group button { - min-height: 44px; - padding: 5px 8px; - border: 1px solid transparent; - border-radius: var(--radius-xs); + border-radius: 999px; background: transparent; - color: var(--muted); - font-size: 0.7rem; - font-weight: 600; - transition: background-color 150ms ease-out, border-color 150ms ease-out, color 150ms ease-out; -} - -.range-group button:hover, -.range-group button[aria-pressed="true"] { - border-color: var(--line-strong); - background: var(--surface-muted); - color: var(--ink); -} - -.chart-figure { - margin: 0; - padding: 18px 22px 14px; -} - -.chart-wrap { - min-height: 340px; - position: relative; -} - -.chart-wrap canvas { - display: block; - max-width: 100%; -} - -.chart-maturity { - margin-top: 7px !important; - color: var(--oxide) !important; - font-size: 0.75rem !important; - font-weight: 600; -} - -.chart-figure figcaption { - margin-top: 12px; - color: var(--faint); - font-size: 0.75rem; -} - -.empty-state { - min-height: 340px; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 32px; - border: 1px dashed var(--line-strong); - color: var(--muted); - text-align: center; -} - -.empty-state strong { - margin-bottom: 6px; - color: var(--ink); -} - -.data-disclosure { - margin: 0 22px 22px; - border-top: 1px solid var(--line); -} - -.data-disclosure summary { - min-height: 44px; - display: flex; - align-items: center; - color: var(--muted); - font-size: 0.74rem; - font-weight: 600; -} - -.review-module { - scroll-margin-top: 94px; -} - -.review-list { - margin: 0; - padding: 0; - list-style: none; -} - -.review-item { - padding: 18px 22px; - border-bottom: 1px solid var(--line); -} - -.review-item:last-child { - border-bottom: 0; + color: var(--ink-soft); + font-size: 13px; + transition: background 0.25s var(--ease), color 0.25s var(--ease); } +.seg button:hover { color: var(--ink); } +.seg button[aria-pressed="true"] { background: var(--ink); color: var(--pill-fg); } -.review-head { - display: flex; - justify-content: space-between; - gap: 12px; - font-size: 0.82rem; -} +.chart { margin-bottom: 22px; } +.chart-title { font-size: 13px; font-weight: 500; margin-bottom: 10px; } +.chart-title span { margin-left: 8px; font-weight: 400; color: var(--ink-faint); } +.chart-box { position: relative; height: clamp(240px, 34vw, 360px); } +.chart-box-small { height: 120px; } +.chart-box canvas { display: block; width: 100%; height: 100%; cursor: crosshair; touch-action: pan-y; } -.review-head strong { - overflow-wrap: anywhere; -} - -.review-state { - color: var(--muted); - font-family: var(--font-mono); - font-size: 0.67rem; +.tip { + position: absolute; + top: 0; + left: 0; + z-index: 2; + display: grid; + gap: 1px; + min-width: 132px; + padding: 8px 10px; + border-radius: 6px; + background: var(--ink); + color: var(--paper); + font-size: 12px; + line-height: 1.4; white-space: nowrap; + pointer-events: none; + transform: translate(-50%, calc(-100% - 12px)); } +.tip time { color: rgba(242, 240, 236, 0.62); } +.tip strong { font-size: 13.5px; font-weight: 500; } +.tip .up { color: #f0a094; } +.tip .down { color: #9db8f5; } +.tip .flat { color: rgba(242, 240, 236, 0.62); } -.review-progress { - height: 7px; - margin-top: 12px; - overflow: hidden; - border: 1px solid var(--line); - background: var(--paper-deep); -} - -.review-progress span { - display: block; - height: 100%; - background: var(--info); - transition: width 260ms ease-out; -} - -.review-detail { +.months { display: flex; - justify-content: space-between; - gap: 10px; - margin-top: 8px; - color: var(--muted); - font-family: var(--font-mono); - font-size: 0.66rem; - font-variant-numeric: tabular-nums; -} - -.review-issues { - margin: 10px 0 0; - padding-left: 17px; - color: var(--negative); - font-size: 0.7rem; -} - -.module-note { - margin: 0; - padding: 16px 22px; - border-top: 1px solid var(--line); - background: var(--surface-muted); - color: var(--muted); - font-size: 0.72rem; -} - -.trust-grid { - display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); - gap: 10px; -} - -.trust-grid.compact { - grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); -} - -.trust-card { - min-height: 104px; - padding: 16px; - border-top: 3px solid var(--line-strong); - background: var(--surface); -} - -.trust-card[data-state="ok"] { border-color: var(--positive); } -.trust-card[data-state="warning"] { border-color: var(--warning); } -.trust-card[data-state="error"] { border-color: var(--negative); } -.trust-card[data-state="info"] { border-color: var(--info); } - -.trust-card .label { - display: block; - margin-bottom: 9px; - color: var(--muted); - font-size: 0.7rem; -} - -.trust-card .value { - display: block; - overflow-wrap: anywhere; - font-family: var(--font-mono); - font-size: 0.94rem; - font-weight: 500; - font-variant-numeric: tabular-nums; -} - -.trust-card .support { - display: block; - margin-top: 7px; - color: var(--faint); - font-size: 0.75rem; + flex-wrap: wrap; + gap: 1px; + width: max-content; + max-width: 100%; + margin-top: 6px; + border: 1px solid var(--rule); + background: var(--rule); } +.months > div { flex: 0 0 84px; padding: 10px 8px 9px; background: var(--paper); text-align: center; } +.months b { display: block; font-size: 11.5px; font-weight: 400; color: var(--ink-faint); } +.months span { display: block; margin-top: 2px; font-size: 13.5px; font-weight: 500; } -.runtime-meta { - min-height: 20px; - margin: 12px 0 0; - color: var(--muted); - font-size: 0.72rem; -} +/* 검증 */ -.halt-guidance { - margin-top: 18px; - padding: 20px; +.review-item { display: grid; - grid-template-columns: minmax(220px, 0.8fr) minmax(300px, 1.2fr) auto; - align-items: center; - gap: 24px; - border: 1px solid var(--negative); - border-left-width: 5px; - background: var(--negative-soft); -} - -.halt-guidance[hidden] { - display: none; -} - -.halt-guidance h3, -.halt-guidance p, -.halt-guidance ol { - margin: 0; -} - -.halt-guidance h3 { - margin-top: 5px; - font-size: 1rem; -} - -.halt-guidance #haltGuidanceReason { - margin-top: 8px; - color: var(--ink-soft); - font-size: 0.8rem; -} - -.halt-guidance ol { - padding-left: 20px; - color: var(--ink-soft); - font-size: 0.78rem; - line-height: 1.7; -} - -.halt-guidance a { - min-height: 44px; - display: inline-flex; + grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.6fr) auto; + gap: 12px 28px; align-items: center; - color: var(--negative); - font-size: 0.78rem; - font-weight: 700; - text-underline-offset: 4px; -} - -.primary-inline { - display: inline-block; - margin-left: 7px; - padding: 2px 5px; - border: 1px solid var(--oxide); - color: var(--oxide); - font-size: 0.62rem; - font-weight: 700; - vertical-align: 1px; -} - -.disclosure-stack { + padding: 22px 0; + border-top: 1px solid var(--rule); +} +.review-item:first-child { border-top: 0; padding-top: 0; } +.review-name { font-size: 15.5px; font-weight: 500; } +.review-name small { display: block; margin-top: 3px; font-size: 12.5px; font-weight: 400; color: var(--ink-faint); } +.review-progress .bar { margin-top: 0; } +.review-progress p { margin-top: 8px; font-size: 12.5px; color: var(--ink-soft); display: flex; justify-content: space-between; gap: 12px; } +.verdict { font-size: 13px; font-weight: 500; white-space: nowrap; } +.verdict.warn { color: var(--warn); } +.verdict.stop { color: var(--stop); } +.review-issues { grid-column: 1 / -1; margin: 0; padding-left: 18px; font-size: 13px; color: var(--stop); } + +/* 운영 */ + +.status-table { border-top: 1px solid var(--rule); } +.status-item { display: grid; - gap: 10px; - margin-top: 24px; + grid-template-columns: 168px minmax(0, 1fr); + gap: 6px 20px; + align-items: baseline; + padding: 14px 0; + border-bottom: 1px solid var(--rule); } +.status-item dt { display: flex; align-items: center; gap: 9px; font-size: 13px; color: var(--ink-soft); } +.status-item dd b { font-size: 14.5px; font-weight: 500; } +.status-item dd span { display: block; margin-top: 2px; font-size: 12.5px; color: var(--ink-faint); } +.status-table.compact { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 32px; } +.status-table.compact .status-item { grid-template-columns: 120px minmax(0, 1fr); } -.disclosure { - border: 1px solid var(--line-strong); - border-radius: var(--radius-sm); - background: var(--surface); +.halt { + margin: 28px 0 8px; + padding: 4px 0 4px 18px; + border-left: 2px solid var(--stop); } +.halt h3 { font-size: 16px; font-weight: 500; } +.halt p { margin-top: 6px; font-size: 14px; color: var(--ink-soft); } +.halt ol { margin: 12px 0; padding-left: 20px; font-size: 14px; line-height: 1.7; color: var(--ink-soft); } +.halt a { font-size: 14px; font-weight: 500; text-underline-offset: 4px; } +.sub-label { margin: 28px 0 10px; font-size: 13px; font-weight: 500; } + +.disclosure { margin-top: 28px; border-top: 1px solid var(--rule); } .disclosure > summary { - min-height: 72px; display: flex; align-items: center; justify-content: space-between; - gap: 20px; - padding: 14px 18px; + gap: 16px; + min-height: 54px; + font-size: 14px; + font-weight: 500; list-style: none; + cursor: pointer; } - -.disclosure > summary::-webkit-details-marker { - display: none; -} - +.disclosure > summary::-webkit-details-marker { display: none; } .disclosure > summary::after { content: "+"; - margin-left: auto; - color: var(--oxide); - font-family: var(--font-mono); - font-size: 1.2rem; - font-weight: 500; -} - -.disclosure[open] > summary::after { - content: "−"; -} - -.disclosure > summary:hover { - background: var(--surface-muted); -} - -.disclosure summary span:first-child { - display: flex; - flex-direction: column; - gap: 2px; -} - -.disclosure summary strong { - color: var(--ink); - font-size: 0.86rem; -} - -.disclosure summary small { - color: var(--muted); - font-size: 0.68rem; -} - -.summary-count { - margin-left: auto; - color: var(--muted); - font-family: var(--font-mono); - font-size: 0.7rem; - font-variant-numeric: tabular-nums; -} - -.disclosure-body { - padding: 18px; - border-top: 1px solid var(--line); -} - -.disclosure-body h4 { - margin: 26px 0 12px; - font-size: 0.86rem; -} - -.table-scroll { - max-width: 100%; - overflow-x: auto; - overscroll-behavior-inline: contain; -} - -table { - width: 100%; - min-width: 640px; - border-collapse: collapse; - font-size: 0.78rem; -} - -caption { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -th, -td { - padding: 11px 10px; - border-bottom: 1px solid var(--line); - text-align: left; - white-space: nowrap; -} - -th { - color: var(--muted); - font-size: 0.67rem; - font-weight: 600; -} - -td { - font-variant-numeric: tabular-nums; -} - -.num { - text-align: right; - font-family: var(--font-mono); - font-variant-numeric: tabular-nums; -} - -.empty-inline, -.error-inline, -.loading-line, -.loading-card { - margin: 0; - padding: 18px; - color: var(--muted); - font-size: 0.78rem; + font-size: 18px; + font-weight: 400; + color: var(--ink-soft); + transition: transform 0.3s var(--ease); } +.disclosure[open] > summary::after { transform: rotate(45deg); } +.disclosure .count { margin-left: 8px; font-weight: 400; color: var(--ink-faint); } +.disclosure > *:not(summary) { margin-bottom: 18px; } -.error-inline { - border-left: 3px solid var(--negative); - background: var(--negative-soft); - color: #7d1f31; -} +.table-scroll { max-width: 100%; overflow-x: auto; overscroll-behavior-inline: contain; } +table { width: 100%; min-width: 560px; border-collapse: collapse; font-size: 13.5px; } +caption { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; } +th, td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--rule); text-align: left; white-space: nowrap; } +th { font-size: 12px; font-weight: 400; color: var(--ink-soft); border-bottom-color: var(--rule-strong); } +.num { text-align: right; } +th.num { text-align: right; } -.loading-card { - min-height: 140px; +.foot { display: flex; - align-items: center; - border: 1px solid var(--line-strong); - background: var(--surface); -} - -.site-footer { - display: grid; - grid-template-columns: auto 1fr minmax(280px, 0.8fr); - gap: 14px; - align-items: center; - margin-top: 80px; - padding: 28px 0 0; - border-top: 1px solid var(--line-strong); - color: var(--muted); - font-size: 0.72rem; -} - -.site-footer p { - margin: 0; -} - -.site-footer strong { - color: var(--ink); + justify-content: space-between; + gap: 16px; + margin-top: 96px; + padding: 22px 0 max(24px, env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--rule); + font-size: 12px; + color: var(--ink-faint); } +.foot p { display: flex; align-items: center; gap: 8px; } +.foot p + p { max-width: 48ch; text-align: right; } -.footer-note { - justify-self: end; - max-width: 52ch; - text-align: right; -} +/* ---------- 적립 기록 ---------- */ .deposit-dialog { - width: min(520px, calc(100vw - 32px)); - max-height: min(760px, calc(100dvh - 32px)); + width: min(460px, calc(100vw - 32px)); + max-height: min(720px, calc(100dvh - 32px)); margin: auto; padding: 0; overflow: auto; - overscroll-behavior: contain; - border: 1px solid var(--ink); - border-radius: var(--radius-md); - background: var(--surface); + border: 1px solid var(--rule-strong); + border-radius: 8px; + background: var(--paper); color: var(--ink); - box-shadow: var(--shadow-dialog); -} - -.deposit-dialog::backdrop { - background: rgb(24 32 31 / 60%); - backdrop-filter: blur(3px); -} - -.deposit-dialog form { - padding: 24px; -} - -.dialog-header { - display: flex; - align-items: start; - justify-content: space-between; - gap: 24px; } - -.dialog-header h2 { - margin: 0; - font-size: 1.45rem; - letter-spacing: -0.035em; -} - +.deposit-dialog::backdrop { background: rgba(13, 12, 11, 0.55); } +.deposit-dialog form { padding: 22px 22px 20px; } +.dialog-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.dialog-head h2 { font-size: 20px; font-weight: 500; letter-spacing: -0.02em; } .icon-button { - width: 44px; - height: 44px; + width: 36px; + height: 36px; display: inline-grid; place-items: center; - border: 1px solid var(--line-strong); - border-radius: var(--radius-sm); - background: var(--surface); - color: var(--ink); - transition: background-color 150ms ease-out, border-color 150ms ease-out; -} - -.icon-button:hover { - border-color: var(--ink); - background: var(--surface-muted); -} - -.dialog-description { - margin: 14px 0 22px; - color: var(--muted); - font-size: 0.82rem; - text-wrap: pretty; -} - -.field { - margin-bottom: 18px; -} - -.field > label { - display: block; - margin-bottom: 7px; - color: var(--ink-soft); - font-size: 0.78rem; - font-weight: 600; -} - -.field > label span { - color: var(--faint); - font-weight: 400; + padding: 0; + border: 1px solid var(--rule-strong); + border-radius: 999px; + background: transparent; } +.icon-button:hover { background: var(--paper-2); } +.dialog-desc { margin: 12px 0 20px; font-size: 13.5px; color: var(--ink-soft); } +.field { margin-bottom: 16px; } +.field > label { display: block; margin-bottom: 6px; font-size: 13px; } +.field > label span { color: var(--ink-faint); } .field input, .field select { width: 100%; - padding: 10px 12px; + height: 44px; + padding: 0 12px; + border: 1px solid var(--rule-strong); + border-radius: 6px; + background: #fbfaf8; font-size: 16px; } - -.field small { - display: block; - margin-top: 6px; - color: var(--muted); - font-size: 0.7rem; -} - -.money-input { - position: relative; -} - -.money-input input { - padding-right: 42px; - font-family: var(--font-mono); - font-variant-numeric: tabular-nums; -} - -.money-input > span { - position: absolute; - top: 50%; - right: 13px; - transform: translateY(-50%); - color: var(--muted); - font-size: 0.76rem; -} - -.preset-group { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 7px; - margin-top: 10px; -} - -.preset-group button { - min-height: 44px; - border: 1px solid var(--line-strong); - border-radius: var(--radius-sm); - background: var(--surface); - color: var(--ink-soft); - font-size: 0.78rem; - font-weight: 600; - transition: background-color 150ms ease-out, border-color 150ms ease-out, color 150ms ease-out; -} - -.preset-group button:hover, -.preset-group button[aria-pressed="true"] { - border-color: var(--oxide); - background: var(--oxide-soft); - color: var(--oxide-dark); -} - -.deposit-confirm { - padding: 16px; - border: 1px solid var(--line-strong); - background: var(--surface-muted); -} - -.deposit-confirm > p { - margin: 0 0 12px; - color: var(--muted); - font-size: 0.76rem; -} - -.deposit-confirm dl { - margin: 0; -} - -.deposit-confirm dl > div { - display: flex; - justify-content: space-between; - gap: 16px; - padding: 8px 0; - border-top: 1px solid var(--line); -} - -.deposit-confirm dt { - color: var(--muted); - font-size: 0.74rem; -} - -.deposit-confirm dd { - margin: 0; - font-family: var(--font-mono); - font-size: 0.78rem; - font-weight: 500; - text-align: right; +.field select { background-color: #fbfaf8; border-radius: 6px; padding-right: 34px; } +.money { position: relative; } +.money input { padding-right: 36px; } +.money span { position: absolute; top: 50%; right: 12px; transform: translateY(-50%); font-size: 13px; color: var(--ink-soft); } +.help { margin-top: 6px; font-size: 12.5px; color: var(--ink-faint); } +.quick { display: flex; gap: 6px; margin-top: 10px; } +.quick button { + height: 32px; + padding: 0 14px; + border: 1px solid var(--rule-strong); + border-radius: 999px; + background: transparent; + font-size: 13px; } +.quick button[aria-pressed="true"], +.quick button:hover { background: var(--ink); color: var(--pill-fg); border-color: var(--ink); } -.form-error { - margin: 14px 0 0; - padding: 10px 12px; - border-left: 3px solid var(--negative); - background: var(--negative-soft); - color: #7d1f31; - font-size: 0.76rem; -} +.confirm { padding: 14px 0 4px; border-top: 1px solid var(--rule); } +.confirm > p { font-size: 13.5px; color: var(--ink-soft); margin-bottom: 6px; } +.confirm dl > div { display: flex; justify-content: space-between; gap: 16px; padding: 9px 0; border-bottom: 1px solid var(--rule); font-size: 14px; } +.confirm dt { color: var(--ink-soft); } +.confirm dd { font-weight: 500; } -.dialog-actions { - display: flex; - justify-content: flex-end; - gap: 8px; - margin-top: 24px; -} +.form-error { margin-top: 12px; font-size: 13px; color: var(--stop); } +.dialog-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 22px; } +.dialog-actions .pill { height: 40px; padding: 0 18px; } .toast { position: fixed; left: 50%; - bottom: max(24px, env(safe-area-inset-bottom)); + bottom: max(20px, env(safe-area-inset-bottom, 0px)); z-index: 200; - max-width: min(560px, calc(100vw - 32px)); - padding: 12px 16px; - transform: translate(-50%, 18px); - border: 1px solid var(--ink); - border-radius: var(--radius-sm); + max-width: min(520px, calc(100vw - 32px)); + padding: 11px 16px; + border-radius: 999px; background: var(--ink); - color: #fff; - box-shadow: 0 12px 32px rgb(24 32 31 / 18%); - font-size: 0.8rem; + color: var(--paper); + font-size: 13.5px; opacity: 0; + transform: translate(-50%, 12px); pointer-events: none; - transition: opacity 180ms ease-out, transform 180ms ease-out; -} - -.toast.show { - transform: translate(-50%, 0); - opacity: 1; -} - -.toast[data-kind="error"] { - border-color: var(--negative); - background: #6d1d2c; -} - -@media (max-width: 1040px) { - .header-inner { - grid-template-columns: auto 1fr; - } - - .header-state { - order: 3; - grid-column: 1 / -1; - justify-self: stretch; - padding-top: 8px; - border-top: 1px solid var(--line); - } - - .header-actions { - justify-self: end; - } - - .decision-panel { - grid-template-columns: 1fr; - } - - .summary-ledger { - max-width: 720px; - } - - .performance-layout { - grid-template-columns: 1fr; - } - - .track-metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .trust-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .halt-guidance { - grid-template-columns: 1fr; - gap: 14px; - } -} - -@media (max-width: 760px) { - .site-header { - position: relative; - backdrop-filter: none; - } - - .header-inner { - grid-template-columns: 1fr; - gap: 10px; - padding: 12px 16px 14px; - } - - .brand, - .header-actions, - .header-state { - justify-self: stretch; - } - - .header-actions { - display: grid; - grid-template-columns: auto 1fr; - } - - .header-actions .button { - width: 100%; - } - - .header-state { - grid-column: auto; - order: initial; - flex-wrap: wrap; - } - - .header-state time { - margin-left: 17px; - flex-basis: 100%; - } - - .page-shell { - padding: 20px 16px 48px; - } - - .decision-panel { - min-height: 0; - padding: 32px 22px 22px; - gap: 28px; - } - - .decision-copy h1 { - max-width: 18ch; - font-size: clamp(2rem, 10vw, 3rem); - } - - .summary-ledger > div { - min-height: 92px; - padding: 14px; - } - - .section-nav { - gap: 4px 14px; - } - - .content-section { - scroll-margin-top: 20px; - padding-top: 56px; - } - - .section-heading { - align-items: start; - flex-direction: column; - gap: 14px; - } - - .section-heading .button { - width: 100%; - } - - .track-card { - grid-template-columns: 1fr; - gap: 24px; - padding: 22px 18px; - } - - .track-card[data-primary="true"] { - padding-left: 13px; - } - - .track-metrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .module-header { - flex-direction: column; - } - - .chart-controls { - width: 100%; - grid-template-columns: 1fr; - } - - .chart-controls > label, - .chart-controls > select { - width: 100%; - } - - .range-group { - grid-column: auto; - display: grid; - grid-template-columns: repeat(4, 1fr); - } - - .range-group button { - min-height: 44px; - } - - .chart-figure { - padding: 16px 12px 12px; - } - - .chart-wrap, - .empty-state { - min-height: 280px; - } - - .data-disclosure { - margin: 0 12px 16px; - } - - .trust-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .disclosure > summary { - min-height: 78px; - padding: 14px; - } - - .disclosure-body { - padding: 12px; - } - - .site-footer { - grid-template-columns: auto 1fr; - } - - .footer-note { - grid-column: 1 / -1; - justify-self: start; - text-align: left; - } - - .deposit-dialog form { - padding: 20px 16px; - } + transition: opacity 0.25s var(--ease), transform 0.25s var(--ease); +} +.toast.show { opacity: 1; transform: translate(-50%, 0); } +.toast[data-kind="error"] { background: var(--stop); } + +/* ---------- 반응형 ---------- */ + +@media (max-width: 1080px) { + .kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .review-item { grid-template-columns: minmax(0, 1fr) auto; } + .review-progress { grid-column: 1 / -1; } +} + +@media (max-width: 900px) { + .status-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .status-item { grid-template-columns: 128px minmax(0, 1fr); } + .track-head { flex-direction: column; align-items: flex-start; } + .spark { width: 100%; max-width: 320px; height: 44px; } +} + +@media (max-width: 720px) { + .nav > a, .state { display: none; } + .mark { font-size: 14px; } + .nav .pill { height: 38px; padding: 0 16px; font-size: 13.5px; } + .story { height: 300vh; min-height: 1900px; } + .panel { padding-top: max(92px, calc(env(safe-area-inset-top, 0px) + 76px)); } + .display { font-size: clamp(34px, 11vw, 60px); max-width: 12ch; } + .sub { font-size: 15px; max-width: 34ch; } + .today-title { max-width: 16ch; } + .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .kpis dd { font-size: 20px; } + .status-item { grid-template-columns: 1fr; gap: 2px; } + .status-table.compact .status-item { grid-template-columns: 1fr; } + .chart-maturity { margin-left: 0; flex-basis: 100%; } + .sec { padding-top: 52px; } + .foot { flex-direction: column; } + .foot p + p { text-align: left; } + .dialog-actions { flex-wrap: wrap; } } @media (max-width: 420px) { - .brand-copy span { - display: none; - } - - .summary-ledger > div { - min-height: 78px; - } - - .trust-grid { - grid-template-columns: 1fr; - } - - .track-metrics > div { - min-height: 76px; - } - - .range-group { - grid-template-columns: repeat(2, 1fr); - } - - .dialog-actions { - display: grid; - grid-template-columns: 1fr 1fr; - } + .mark span { font-size: 14px; } + .nav .pill { height: 36px; padding: 0 14px; font-size: 13px; } + .display { font-size: clamp(30px, 10.5vw, 44px); } + .eyebrow { font-size: 12px; max-width: 28ch; } + .cta .pill { width: 100%; max-width: 280px; height: 44px; font-size: 14px; } + .status-row { grid-template-columns: 1fr; } + .status-row li { padding: 10px 0; } +} - .dialog-actions .button-primary { - grid-column: 1 / -1; - grid-row: 1; - } +@media (max-height: 520px) and (orientation: landscape) { + .panel { padding-top: max(72px, calc(env(safe-area-inset-top, 0px) + 56px)); } + .display { font-size: clamp(28px, 8vh, 44px); } + .sub { margin-top: 12px; font-size: 14px; } + .cta { margin-top: 16px; } } @media (prefers-reduced-motion: reduce) { - html { - scroll-behavior: auto; - } - - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - } -} - -@media (prefers-contrast: more) { - :root { - --line: #999b95; - --line-strong: #666b68; - --muted: #3f4946; - --faint: #505b57; - } + *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } + .story-hint i::after { animation: none; } } diff --git a/monitoring/static/dashboard.js b/monitoring/static/dashboard.js index 19f93879..5bfbc7df 100644 --- a/monitoring/static/dashboard.js +++ b/monitoring/static/dashboard.js @@ -1,1234 +1,1764 @@ "use strict"; -const $ = (id) => document.getElementById(id); -const won = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }); -const compactWon = new Intl.NumberFormat("ko-KR", { - notation: "compact", - maximumFractionDigits: 1, -}); -const dateOnly = new Intl.DateTimeFormat("ko-KR", { - year: "numeric", - month: "long", - day: "numeric", -}); -const dateShort = new Intl.DateTimeFormat("ko-KR", { - month: "short", - day: "numeric", -}); -const dateTime = new Intl.DateTimeFormat("ko-KR", { - month: "numeric", - day: "numeric", - hour: "2-digit", - minute: "2-digit", -}); - -const query = new URLSearchParams(window.location.search); -const requestedDays = Number(query.get("days")); -const allowedDays = [30, 90, 365, 3650]; - -const state = { - mode: "unknown", - baskets: null, - evaluations: null, - runtime: null, - legacy: null, - flows: new Map(), - flowStatus: new Map(), - flowError: false, - chartRows: [], - chartDays: allowedDays.includes(requestedDays) ? requestedDays : 90, - chartAccount: query.get("account") || null, - lastCoreSuccess: null, - coreError: null, - coreStatus: "loading", - runtimeStatus: "loading", - activeRequests: new Map(), - depositConfirming: false, - depositRequestId: null, -}; - -const elements = { - basketTracks: $("basketTracks"), - portfolioSummary: $("portfolioSummary"), - portfolioAsOf: $("portfolioAsOf"), - chartAccount: $("chartAccount"), - chartSummary: $("chartSummary"), - chartMaturity: $("chartMaturity"), - chartWrap: $("chartWrap"), - chartEmpty: $("chartEmpty"), - chartRows: $("chartDataRows"), - basketEval: $("basketEval"), - runtimeOps: $("runtimeOps"), - runtimeMeta: $("runtimeMeta"), - syncMark: $("syncMark"), - syncStatus: $("syncStatus"), - lastUpdate: $("lastUpdate"), - modeBadge: $("modeBadge"), - decisionTitle: $("decisionTitle"), - decisionDescription: $("decisionDescription"), - decisionMeta: $("decisionMeta"), - decisionAction: $("decisionAction"), - openDeposit: $("openDepositButton"), - depositAvailability: $("depositAvailability"), - depositDialog: $("depositDialog"), - depositForm: $("depositForm"), - depositFields: $("depositFields"), - depositConfirm: $("depositConfirm"), - depositError: $("depositError"), - depositSubmit: $("depositSubmitButton"), - depositBack: $("depositBackButton"), - haltGuidance: $("haltGuidance"), -}; - -function escapeHtml(value) { - const node = document.createElement("div"); - node.textContent = value == null ? "" : String(value); - return node.innerHTML - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function formatWon(value) { - const number = Number(value); - return Number.isFinite(number) ? `${won.format(number)}원` : "—"; -} - -function formatPercent(value, { sign = true } = {}) { - const number = Number(value); - if (!Number.isFinite(number)) return "—"; - const prefix = sign && number > 0 ? "+" : ""; - return `${prefix}${number.toFixed(2)}%`; -} - -function toneFor(value) { - const number = Number(value); - if (!Number.isFinite(number) || number === 0) return "muted"; - return number > 0 ? "positive" : "negative"; -} - -function parseDate(value) { - if (!value) return null; - const text = String(value); - const normalized = /^\d{4}-\d{2}-\d{2}$/.test(text) - ? `${text}T00:00:00+09:00` - : text; - const parsed = new Date(normalized); - return Number.isNaN(parsed.getTime()) ? null : parsed; -} - -function formatDate(value, formatter = dateOnly) { - const parsed = parseDate(value); - return parsed ? formatter.format(parsed) : "—"; -} - -function localIsoDate(value = new Date()) { - const year = value.getFullYear(); - const month = String(value.getMonth() + 1).padStart(2, "0"); - const day = String(value.getDate()).padStart(2, "0"); - return `${year}-${month}-${day}`; -} - -function calendarAgeDays(value) { - const parsed = parseDate(value); - if (!parsed) return null; - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const target = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()); - return Math.max(0, Math.floor((today - target) / 86_400_000)); -} - -async function fetchJson(url, { timeout = 15_000, options = {}, key = url } = {}) { - const previous = state.activeRequests.get(key); - if (previous) previous.abort(); - - const controller = new AbortController(); - state.activeRequests.set(key, controller); - const timer = window.setTimeout(() => controller.abort(), timeout); - try { - const response = await fetch(url, { ...options, signal: controller.signal }); - let payload; +(function () { + const $ = (id) => document.getElementById(id); + + /* ---------- 서식 ---------- */ + + const nf0 = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 }); + const compact = new Intl.NumberFormat("ko-KR", { notation: "compact", maximumFractionDigits: 1 }); + const MINUS = "−"; + + // null·undefined·빈 문자열은 0이 아니라 "값 없음"으로 다룬다. + const num = (value) => (value == null || value === "" ? NaN : Number(value)); + function won(value) { + const n = num(value); + return Number.isFinite(n) ? `${nf0.format(Math.round(n))}원` : "—"; + } + function signedWon(value) { + const n = num(value); + if (!Number.isFinite(n)) return "—"; + const sign = n > 0 ? "+" : n < 0 ? MINUS : ""; + return `${sign}${nf0.format(Math.round(Math.abs(n)))}원`; + } + function compactWon(value) { + const n = num(value); + return Number.isFinite(n) ? `${compact.format(n)}원` : "—"; + } + function pct(value, { sign = true, digits = 2 } = {}) { + const n = num(value); + if (!Number.isFinite(n)) return "—"; + const s = !sign ? "" : n > 0 ? "+" : n < 0 ? MINUS : ""; + return `${s}${Math.abs(n).toFixed(digits)}%`; + } + function tone(value) { + const n = num(value); + if (!Number.isFinite(n) || Math.abs(n) < 1e-9) return "flat"; + return n > 0 ? "up" : "down"; + } + function sinceCopy(minutes) { + if (minutes == null) return ""; + if (minutes < 60) return `${Math.max(1, Math.round(minutes))}분째`; + if (minutes < 1440) return `${Math.round(minutes / 60)}시간째`; + return `${Math.round(minutes / 1440)}일째`; + } + function parseDate(value) { + if (!value) return null; + const text = String(value); + const normalized = /^\d{4}-\d{2}-\d{2}$/.test(text) + ? `${text}T00:00:00+09:00` + : (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}/.test(text) ? `${text.replace(" ", "T")}+09:00` : text); + const parsed = new Date(normalized); + return Number.isNaN(parsed.getTime()) ? null : parsed; + } + const pad2 = (n) => String(n).padStart(2, "0"); + function fmtLong(value) { + const d = parseDate(value); + return d ? `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일` : "—"; + } + function fmtMD(value) { + const d = parseDate(value); + return d ? `${d.getMonth() + 1}월 ${d.getDate()}일` : "—"; + } + function fmtDT(value) { + const d = parseDate(value); + return d ? `${d.getMonth() + 1}월 ${d.getDate()}일 ${pad2(d.getHours())}:${pad2(d.getMinutes())}` : "—"; + } + function fmtHM(d) { + return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; + } + function formatAge(minutes) { + if (minutes == null) return "기록 없음"; + if (minutes < 1) return "방금"; + if (minutes < 60) return `${Math.round(minutes)}분 전`; + if (minutes < 1440) return `${Math.round(minutes / 60)}시간 전`; + return `${Math.round(minutes / 1440)}일 전`; + } + function localIsoDate(value = new Date()) { + return `${value.getFullYear()}-${pad2(value.getMonth() + 1)}-${pad2(value.getDate())}`; + } + function calendarAgeDays(value) { + const parsed = parseDate(value); + if (!parsed) return null; + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const target = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()); + return Math.max(0, Math.floor((today - target) / 86_400_000)); + } + function escapeHtml(value) { + return String(value == null ? "" : value) + .replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") + .replaceAll('"', """).replaceAll("'", "'"); + } + const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); + const smooth = (t) => t * t * (3 - 2 * t); + function ramp(p, a, b) { + if (b <= a) return p >= b ? 1 : 0; + return smooth(clamp((p - a) / (b - a), 0, 1)); + } + const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); + // 캡처 모드(?scrub= / ?section=)에서는 등장·드로인 애니메이션 없이 최종 화면만 그린다. + const captureMode = new URLSearchParams(window.location.search).has("scrub") || new URLSearchParams(window.location.search).has("section"); + const noMotion = () => reducedMotion.matches || captureMode; + const FONT = '"Inter Tight", "Noto Sans KR", sans-serif'; + const COLORS = { ink: "#0d0c0b", up: "#c33d2b", down: "#2557c9", warn: "#b06d12" }; + const inkAlpha = (a) => `rgba(13, 12, 11, ${a})`; + + /* ---------- 상태 ---------- */ + + const query = new URLSearchParams(window.location.search); + const requestedDays = Number(query.get("days")); + const allowedDays = [30, 90, 365, 3650]; + + const state = { + mode: "unknown", + baskets: null, + evaluations: null, + runtime: null, + legacy: null, + flows: new Map(), + flowStatus: new Map(), + flowError: false, + series: new Map(), + seriesStatus: new Map(), + chartRows: [], + chartDays: allowedDays.includes(requestedDays) ? requestedDays : 90, + chartAccount: query.get("account") || null, + lastCoreSuccess: null, + coreError: null, + coreStatus: "loading", + runtimeStatus: "loading", + activeRequests: new Map(), + depositConfirming: false, + depositRequestId: null, + tracksAnimated: false, + }; + + const el = { + boot: $("boot"), bootBar: $("bootBar"), bootPct: $("bootPct"), + meter: $("meter"), chrome: $("chrome"), + syncMark: $("syncMark"), syncStatus: $("syncStatus"), lastUpdate: $("lastUpdate"), modeBadge: $("modeBadge"), + openDeposit: $("openDepositButton"), depositAvailability: $("depositAvailability"), + story: $("top"), stage: document.querySelector(".stage"), storyCanvas: $("storyCanvas"), storyHint: $("storyHint"), + panels: [].slice.call(document.querySelectorAll("[data-panel]")), + decisionTitle: $("decisionTitle"), decisionDescription: $("decisionDescription"), decisionMeta: $("decisionMeta"), + decisionAction: $("decisionAction"), statusRow: $("statusRow"), + basketTracks: $("basketTracks"), portfolioAsOf: $("portfolioAsOf"), + chartAccount: $("chartAccount"), chartSummary: $("chartSummary"), chartMaturity: $("chartMaturity"), + chartWrap: $("chartWrap"), chartBox: $("chartBox"), chartEquity: $("chartEquity"), chartDrawdown: $("chartDrawdown"), + chartTip: $("chartTip"), chartEmpty: $("chartEmpty"), chartRows: $("chartDataRows"), monthStrip: $("monthStrip"), + basketEval: $("basketEval"), runtimeOps: $("runtimeOps"), runtimeMeta: $("runtimeMeta"), haltGuidance: $("haltGuidance"), + depositDialog: $("depositDialog"), depositForm: $("depositForm"), depositFields: $("depositFields"), + depositConfirm: $("depositConfirm"), depositError: $("depositError"), depositSubmit: $("depositSubmitButton"), + depositBack: $("depositBackButton"), + }; + + /* ---------- 네트워크 ---------- */ + + async function fetchJson(url, { timeout = 15_000, options = {}, key = url } = {}) { + const previous = state.activeRequests.get(key); + if (previous) previous.abort(); + const controller = new AbortController(); + state.activeRequests.set(key, controller); + const timer = window.setTimeout(() => controller.abort(), timeout); try { - payload = await response.json(); - } catch { - payload = null; + const response = await fetch(url, { ...options, signal: controller.signal }); + let payload = null; + try { payload = await response.json(); } catch { payload = null; } + if (!response.ok) throw new Error(payload && payload.error ? payload.error : `HTTP ${response.status}`); + return payload; + } finally { + window.clearTimeout(timer); + if (state.activeRequests.get(key) === controller) state.activeRequests.delete(key); } - if (!response.ok) { - const reason = payload && payload.error ? payload.error : `HTTP ${response.status}`; - throw new Error(reason); + } + + /* ---------- 프리로더 ---------- */ + + const boot = { total: 3, done: 0, finished: false }; + function bootStep() { + boot.done = Math.min(boot.total, boot.done + 1); + setBootProgress(boot.done / boot.total); + if (boot.done >= boot.total) finishBoot(); + } + function setBootProgress(fraction) { + el.bootBar.style.transform = `scaleX(${fraction})`; + el.bootPct.textContent = `장부 여는 중 ${Math.round(fraction * 100)}%`; + } + function finishBoot() { + if (boot.finished) return; + boot.finished = true; + setBootProgress(1); + el.boot.classList.add("done"); + if (scrubOverride != null || query.has("section")) { + // 캡처 모드: 전환 애니메이션 없이 즉시 최종 화면을 그린다. + el.boot.hidden = true; + story.seekAt = story.progress; } - return payload; - } finally { - window.clearTimeout(timer); - if (state.activeRequests.get(key) === controller) { - state.activeRequests.delete(key); + story.dirty = true; + wakeStory(); + // ?section=portfolio : 데이터가 자리 잡은 뒤 해당 섹션을 화면 맨 위에 둔다 (딥링크·화면 캡처용). + // 스크롤 대신 스테이지와 앞 섹션을 숨겨, 스크롤이 없는 헤드리스 캡처에서도 같은 화면이 나온다. + const section = query.get("section") && document.getElementById(query.get("section")); + if (section) { + el.story.hidden = true; + el.meter.hidden = true; + el.chrome.classList.add("solid"); + let before = true; + document.querySelectorAll(".page > .sec").forEach((node) => { + if (node === section) before = false; + else if (before) node.hidden = true; + }); + window.setTimeout(() => window.scrollTo(0, 0), 50); } } -} - -function setSyncState(kind, label) { - elements.syncMark.dataset.state = kind; - elements.syncStatus.textContent = label; -} - -function updateSyncIndicator() { - if (state.coreStatus === "loading") { - setSyncState("loading", "장부 데이터 확인 중…"); - } else if (state.coreStatus === "error") { - setSyncState("error", "장부 연결 실패"); - } else if (state.runtimeStatus === "error") { - setSyncState("partial", "장부 정상 · 안전 상태 확인 불가"); - } else if (state.coreStatus === "partial") { - setSyncState("partial", "일부 장부 데이터 지연"); - } else { - setSyncState("ok", "장부 연결 정상"); - } - updateDepositAvailability(); -} - -function canRecordDeposit() { - const baskets = state.baskets; - const halt = state.runtime && state.runtime.trading_halt; - return ( - state.coreStatus === "ready" - && state.runtimeStatus === "ready" - && ["paper", "live"].includes(state.mode) - && Array.isArray(baskets) - && baskets.length > 0 - && baskets.every((basket) => state.flowStatus.get(basket.basket) === "ready") - && halt - && halt.halted === false - ); -} - -function updateDepositAvailability() { - const available = canRecordDeposit(); - elements.openDeposit.disabled = !available; - elements.depositAvailability.textContent = available - ? "적립금을 기록할 수 있습니다." - : "장부와 거래 안전 상태가 정상으로 확인된 뒤 적립금을 기록할 수 있습니다."; -} - -function markCoreSuccess(timestamp) { - const parsed = parseDate(timestamp) || new Date(); - state.lastCoreSuccess = parsed; - state.coreError = null; - elements.lastUpdate.dateTime = parsed.toISOString(); - elements.lastUpdate.textContent = `마지막 성공 ${dateTime.format(parsed)}`; -} - -function setMode(mode) { - const normalized = String(mode || "unknown").toLowerCase(); - state.mode = ["paper", "live"].includes(normalized) ? normalized : "unknown"; - elements.modeBadge.dataset.mode = state.mode; - if (state.mode === "paper") { - elements.modeBadge.textContent = "모의 운용 · 실제 주문 없음"; - } else if (state.mode === "live") { - elements.modeBadge.textContent = "실전 운용 · 실계좌 주문 가능"; - } else { - elements.modeBadge.textContent = "운용 모드 확인 불가"; - } - updateDepositCopy(); -} - -function sortedBaskets() { - return [...(state.baskets || [])].sort((a, b) => { - const primaryDelta = Number(Boolean(b.is_primary)) - Number(Boolean(a.is_primary)); - if (primaryDelta) return primaryDelta; - return String(a.basket).localeCompare(String(b.basket)); - }); -} - -function primaryBasket() { - return sortedBaskets().find((basket) => basket.is_primary) || null; -} - -function renderPortfolioSummary() { - const basket = primaryBasket() || sortedBaskets().find((item) => item.snapshot) || null; - const assetLabel = state.mode === "live" ? "현재 실전 자산" : "현재 모의 자산"; - if (!basket || !basket.snapshot) { - elements.portfolioSummary.innerHTML = [ - [assetLabel, "기록 없음"], - ["누적 원금", "기록 없음"], - ["원금 대비 손익", "기록 없음"], - ["현금 비중", "기록 없음"], - ].map(([label, value]) => `
${label}
${value}
`).join(""); - return; - } - - const totalValue = Number(basket.snapshot.total_value || 0); - const principal = Number(basket.principal || 0); - const cash = Number(basket.snapshot.cash || 0); - const profit = totalValue - principal; - const cashRatio = totalValue > 0 ? (cash / totalValue) * 100 : null; - elements.portfolioSummary.setAttribute( - "aria-label", - `${basket.display_name} 주력 포트폴리오 요약`, - ); - - const rows = [ - [assetLabel, formatWon(totalValue), ""], - ["누적 원금", formatWon(principal), ""], - ["원금 대비 손익", `${profit > 0 ? "+" : ""}${formatWon(profit)}`, toneFor(profit)], - ["현금 비중", cashRatio == null ? "—" : formatPercent(cashRatio, { sign: false }), ""], + + /* ---------- 연결 상태 · 모드 ---------- */ + + function setSync(kind, label) { + el.syncMark.dataset.state = kind; + el.syncStatus.textContent = label; + } + function updateSyncIndicator() { + if (state.coreStatus === "loading") setSync("loading", "장부 확인 중"); + else if (state.coreStatus === "error") setSync("error", "장부 연결 실패"); + else if (state.runtimeStatus === "error") setSync("partial", "장부 정상 · 운영 상태 확인 불가"); + else if (state.coreStatus === "partial") setSync("partial", "일부 데이터 지연"); + else setSync("ok", "연결 정상"); + updateDepositAvailability(); + } + function markCoreSuccess(timestamp) { + const parsed = parseDate(timestamp) || new Date(); + state.lastCoreSuccess = parsed; + state.coreError = null; + el.lastUpdate.dateTime = parsed.toISOString(); + el.lastUpdate.textContent = `${fmtHM(parsed)} 갱신`; + el.lastUpdate.hidden = false; + } + function setMode(mode) { + const normalized = String(mode || "unknown").toLowerCase(); + state.mode = ["paper", "live"].includes(normalized) ? normalized : "unknown"; + el.modeBadge.dataset.mode = state.mode; + el.modeBadge.textContent = state.mode === "paper" ? "모의투자" : state.mode === "live" ? "실전 · 실계좌 주문" : "모드 확인 불가"; + updateDepositCopy(); + } + const modeLabel = () => (state.mode === "live" ? "실전" : state.mode === "paper" ? "모의투자" : "모드 확인 불가"); + + function canRecordDeposit() { + const baskets = state.baskets; + const halt = state.runtime && state.runtime.trading_halt; + return state.coreStatus === "ready" && state.runtimeStatus === "ready" + && ["paper", "live"].includes(state.mode) + && Array.isArray(baskets) && baskets.length > 0 + && baskets.every((b) => state.flowStatus.get(b.basket) === "ready") + && halt && halt.halted === false; + } + function updateDepositAvailability() { + const available = canRecordDeposit(); + el.openDeposit.disabled = !available; + el.depositAvailability.textContent = available ? "적립을 기록할 수 있습니다." : "장부와 거래 상태를 확인한 뒤 기록할 수 있습니다."; + } + + /* ---------- 바스켓 정렬 ---------- */ + + function sortedBaskets() { + return [...(state.baskets || [])].sort((a, b) => { + const d = Number(Boolean(b.is_primary)) - Number(Boolean(a.is_primary)); + return d || String(a.basket).localeCompare(String(b.basket)); + }); + } + const primaryBasket = () => sortedBaskets().find((b) => b.is_primary) || sortedBaskets().find((b) => b.snapshot) || null; + + /* ---------- 시계열 계산 ---------- */ + + function rowInstant(row) { + const created = parseDate(row.created_at); + if (created) return created; + const d = parseDate(row.date); + if (!d) return null; + d.setHours(23, 59, 59, 999); + return d; + } + + /** 스냅샷·입금 기록으로 평가금액, 투자원금(계단), 시간가중 자산, 낙폭 시계열을 만든다. */ + function buildSeries(rows, flows, initialCapital) { + 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 twr = cr.map((c) => initialCapital * (1 + c / 100)); + const sortedFlows = [...(flows || [])] + .map((f) => ({ at: parseDate(f.occurred_at), amount: Number(f.amount || 0), note: f.note || "" })) + .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 peak = initialCapital > 0 ? initialCapital : -Infinity; + let peakIdx = -1; + let maxDD = 0; + let maxDDIdx = 0; + let maxDDPeakIdx = -1; + const dd = twr.map((v, i) => { + if (v > peak) { peak = v; peakIdx = i; } + const d = peak > 0 ? v / peak - 1 : 0; + if (d < maxDD) { maxDD = d; maxDDIdx = i; maxDDPeakIdx = peakIdx; } + return d; + }); + const markers = sortedFlows + .map((f) => ({ ...f, index: instants.findIndex((at) => at && at >= f.at) })) + .filter((m) => m.index >= 0); + return { rows: sorted, dates, value, cr, twr, principal, dd, maxDD, maxDDIdx, maxDDPeakIdx, markers, initialCapital }; + } + + function monthlyReturns(series) { + const out = []; + let prevIndex = null; + let current = null; + series.dates.forEach((d, i) => { + const key = `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`; + if (!current || current.key !== key) { + if (current) out.push(current); + current = { key, label: `${d.getFullYear() !== new Date().getFullYear() ? `${String(d.getFullYear()).slice(2)}년 ` : ""}${d.getMonth() + 1}월`, startIndex: prevIndex, endIndex: i }; + } + current.endIndex = i; + prevIndex = i; + }); + if (current) out.push(current); + // 월 수익률 = 전월 말 시간가중 지수 대비. 첫 달은 시작 자본(지수 1) 대비. + return out.map((m) => { + const start = m.startIndex == null ? 1 : 1 + series.cr[m.startIndex] / 100; + const end = 1 + series.cr[m.endIndex] / 100; + return { label: m.label, value: start > 0 ? (end / start - 1) * 100 : 0 }; + }); + } + + function makeScale(values, top, bottom, padRatio = 0.08) { + let min = Math.min(...values); + let max = Math.max(...values); + if (!Number.isFinite(min) || !Number.isFinite(max)) { min = 0; max = 1; } + const spread = Math.max(1, max - min); + min -= spread * padRatio; + max += spread * padRatio; + return { min, max, y: (v) => top + ((max - v) / (max - min)) * (bottom - top) }; + } + + function setupCanvas(canvas, width, height) { + const ratio = Math.min(2, window.devicePixelRatio || 1); + const w = Math.max(1, Math.round(width)); + const h = Math.max(1, Math.round(height)); + if (canvas.width !== Math.round(w * ratio) || canvas.height !== Math.round(h * ratio)) { + canvas.width = Math.round(w * ratio); + canvas.height = Math.round(h * ratio); + } + const context = canvas.getContext("2d"); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + context.clearRect(0, 0, w, h); + return context; + } + + function niceTicks(min, max, count) { + const ticks = []; + for (let i = 0; i <= count; i += 1) ticks.push(max - ((max - min) * i) / count); + return ticks; + } + + /* ---------- 스크롤 스크럽 스테이지 ---------- */ + + // 패널마다 [등장 시작, 등장 끝, 퇴장 시작, 퇴장 끝] — 사이 공백은 차트만 보이는 구간. + const CUES = [ + [0.00, 0.00, 0.15, 0.23], + [0.35, 0.43, 0.57, 0.65], + [0.77, 0.85, 1.10, 1.20], ]; - elements.portfolioSummary.innerHTML = rows.map(([label, value, tone]) => ( - `
${label}
${escapeHtml(value)}
` - )).join(""); -} - -function positionLabel(position) { - const name = position.name ? `${position.name} · ` : ""; - return `${name}${position.symbol} ${won.format(Number(position.quantity || 0))}주`; -} - -function renderBasketTracks(data) { - const baskets = (data && data.baskets) || []; - state.baskets = baskets; - elements.basketTracks.setAttribute("aria-busy", "false"); - setMode(data && data.mode); - markCoreSuccess(data && data.timestamp); - - if (!baskets.length) { - elements.basketTracks.innerHTML = ` -
- 활성화된 포트폴리오가 없습니다. - config/baskets.yaml에서 모의 운용 포트폴리오를 먼저 선택하세요. -
`; - renderPortfolioSummary(); - elements.portfolioAsOf.textContent = "기준일 없음"; - elements.portfolioAsOf.dateTime = ""; - return; - } - - elements.basketTracks.innerHTML = sortedBaskets().map((basket) => { - const snapshot = basket.snapshot; - const twr = snapshot ? Number(snapshot.cumulative_return) : null; - const profit = basket.profit_vs_principal == null ? null : Number(basket.profit_vs_principal); - const deployment = basket.deployment_ratio == null ? null : Number(basket.deployment_ratio) * 100; - const target = basket.design_fraction == null ? null : Number(basket.design_fraction) * 100; - const primary = Boolean(basket.is_primary); - const flowItems = state.flows.get(basket.basket) || []; - const flowState = state.flowStatus.get(basket.basket); - const latestFlow = flowItems[0]; - const holdings = (basket.positions || []).length - ? `
    ${basket.positions.map((position) => `
  • ${escapeHtml(positionLabel(position))}
  • `).join("")}
` - : '

아직 보유 종목이 없습니다.

'; - const allocationWidth = deployment == null ? 0 : Math.max(0, Math.min(100, deployment)); - const role = basket.purpose || (primary ? "월 적립 중심" : "장기 관찰용"); - const plan = basket.contribution_plan || {}; - const planCopy = primary && plan.enabled && Number(plan.amount) > 0 - ? `운용 기준 · 월 ${formatWon(plan.amount)} 적립 · 큰 비중 이탈 때만 리밸런싱` - : ""; - const allocationGap = deployment == null || target == null ? null : target - deployment; - const allocationNote = allocationGap != null && allocationGap > 1 - ? (primary - ? `목표보다 ${Math.round(allocationGap)}%p 낮음 · ETF 1주 단위라 다음 적립 때 조정될 수 있습니다.` - : `목표보다 ${Math.round(allocationGap)}%p 낮습니다. 관찰 기준에 따라 큰 이탈만 점검합니다.`) - : "목표 범위에 가깝게 운용 중입니다."; - - return ` -
-
-
- ${primary ? "주력 포트폴리오" : "관찰 포트폴리오"} - ${escapeHtml(role)} -
-

${escapeHtml(basket.display_name)}

- ${escapeHtml(basket.basket)} -
${snapshot ? won.format(Number(snapshot.total_value)) : "기록 없음"}${snapshot ? "" : ""}
-

누적 원금 ${formatWon(basket.principal)}${basket.deposits_total > 0 ? ` · 적립 ${formatWon(basket.deposits_total)}` : ""}

-

${snapshot ? `자산 기준 ${formatDate(snapshot.date)}` : "모의 운용을 실행하면 첫 기록이 만들어집니다."}

- ${planCopy ? `

${escapeHtml(planCopy)}

` : ""} -
-
-
-
입출금 제외 수익 (TWR)
${twr == null ? "—" : formatPercent(twr)}
-
원금 대비 손익
${profit == null ? "—" : `${profit > 0 ? "+" : ""}${formatWon(profit)}`}
-
고점 대비 최대 하락 (MDD)
${snapshot ? formatPercent(-Math.abs(Number(snapshot.mdd || 0)), { sign: false }) : "—"}
-
현금
${snapshot ? formatWon(snapshot.cash) : "—"}
-
-
-
투자 배치율${deployment == null ? "—" : `${Math.round(deployment)}%`} / 목표 ${target == null ? "—" : `${Math.round(target)}%`}
-
-

${escapeHtml(allocationNote)}

+ const DRIFT = 22; + + const story = { + series: null, + basket: null, + progress: 0, + seekAt: 0, + visible: true, + frame: null, + dirty: true, + width: 0, + height: 0, + }; + + // ?scrub=0.6 : 화면 캡처용. 스크롤 대신 고정 진행률로 스테이지를 그린다. + const scrubOverride = query.has("scrub") ? clamp(Number(query.get("scrub")) || 0, 0, 1) : null; + + function readStoryScroll() { + const rect = el.story.getBoundingClientRect(); + const max = el.story.offsetHeight - window.innerHeight; + story.progress = scrubOverride != null ? scrubOverride : (max > 0 ? clamp(-rect.top / max, 0, 1) : 0); + story.visible = rect.bottom > 0; + if (!story.visible && !el.chrome.classList.contains("solid")) el.chrome.classList.add("solid"); + } + + function wakeStory() { + if (story.frame == null) story.frame = requestAnimationFrame(storyFrame); + } + + function storyFrame() { + story.frame = null; + if (!story.visible) { paintStoryChrome(); return; } + const gap = story.progress - story.seekAt; + let moving = false; + if (reducedMotion.matches) { + if (Math.abs(gap) > 0) { story.seekAt = story.progress; story.dirty = true; } + } else if (Math.abs(gap) > 0.0008) { + story.seekAt += gap * 0.115; + story.dirty = true; + moving = true; + } else if (gap !== 0) { + story.seekAt = story.progress; + story.dirty = true; + } + if (story.dirty) { + paintStory(); + story.dirty = false; + } + if (moving) story.frame = requestAnimationFrame(storyFrame); + } + + function paintStoryChrome() { + const p = story.progress; + el.meter.style.transform = `scaleX(${p})`; + el.meter.classList.toggle("off", p >= 0.995 || !story.visible); + el.chrome.classList.toggle("solid", p >= 0.995 || !story.visible); + el.storyHint.style.opacity = String(1 - ramp(p, 0, 0.06)); + } + + function paintStory() { + paintStoryChrome(); + const t = story.seekAt; + el.panels.forEach((panel, i) => { + const c = CUES[i]; + const enter = ramp(t, c[0], c[1]); + const leave = ramp(t, c[2], c[3]); + const o = enter * (1 - leave); + const y = (1 - enter) * DRIFT - leave * DRIFT; + panel.style.opacity = o.toFixed(3); + panel.style.transform = `translate3d(0, ${y.toFixed(2)}px, 0)`; + panel.style.pointerEvents = o > 0.6 ? "auto" : "none"; + }); + drawStory(t); + } + + function storyLayout() { + const W = story.width; + const H = story.height; + const narrow = W < 720; + const gutter = clamp(W * 0.034, 16, 44); + return { + W, H, narrow, gutter, + left: gutter, + right: W - gutter, + top: H * (narrow ? 0.56 : 0.5), + bottom: H - (narrow ? 88 : 76), + }; + } + + function drawStory(t) { + const canvas = el.storyCanvas; + story.width = el.stage.clientWidth; + story.height = el.stage.clientHeight; + const context = setupCanvas(canvas, story.width, story.height); + const L = storyLayout(); + const s = story.series; + context.font = `400 12px ${FONT}`; + context.textBaseline = "middle"; + + if (!s || s.rows.length === 0) { + context.strokeStyle = inkAlpha(0.16); + context.beginPath(); + context.moveTo(L.left, L.bottom); + context.lineTo(L.right, L.bottom); + context.stroke(); + context.fillStyle = inkAlpha(0.42); + context.textAlign = "left"; + context.fillText(s ? "아직 기록이 한 건뿐이라 곡선이 없습니다." : "자산 기록을 불러오는 중입니다.", L.left, L.bottom - 18); + return; + } + + const n = s.rows.length; + const tip = t * (n - 1); + const i0 = Math.floor(tip); + const frac = tip - i0; + const x = (i) => L.left + ((L.right - L.left) * i) / Math.max(1, n - 1); + const allValues = [...s.value, ...s.principal, ...s.twr]; + const scale = makeScale(allValues, L.top, L.bottom - 4, 0.1); + const wB = ramp(t, 0.3, 0.44); + const wA = 1 - wB; + const wD = ramp(t, 0.7, 0.82); + + // 눈금 + context.textAlign = "right"; + niceTicks(scale.min, scale.max, 4).forEach((v, i) => { + const y = scale.y(v); + context.strokeStyle = inkAlpha(i === 4 ? 0.34 : 0.12); + context.beginPath(); + context.moveTo(L.left, y); + context.lineTo(L.right, y); + context.stroke(); + if (i < 4) { + context.fillStyle = inkAlpha(0.42); + context.fillText(compactWon(v), L.right, y - 9); + } + }); + // 날짜 눈금: 양 끝은 화면 밖으로 넘치지 않게 안쪽 정렬한다. + const xTicks = Math.max(1, Math.ceil((n - 1) / (L.narrow ? 2 : 5))); + context.fillStyle = inkAlpha(0.42); + for (let i = 0; i < n - 1; i += xTicks) { + if (n > 1 && i > n - 1 - xTicks * 0.5) break; + context.textAlign = i === 0 ? "left" : "center"; + context.fillText(fmtMD(s.rows[i].date), x(i), L.bottom + 16); + } + context.textAlign = n > 1 ? "right" : "left"; + context.fillText(fmtMD(s.rows[n - 1].date), x(n - 1), L.bottom + 16); + + const at = (arr, i) => arr[i]; + const interp = (arr) => (i0 >= n - 1 ? arr[n - 1] : arr[i0] + (arr[i0 + 1] - arr[i0]) * frac); + const tipX = x(tip); + + function traceTo(arr, close) { + context.beginPath(); + for (let i = 0; i <= i0; i += 1) { + const px = x(i); + const py = scale.y(at(arr, i)); + if (i === 0) context.moveTo(px, py); else context.lineTo(px, py); + } + if (i0 < n - 1) context.lineTo(tipX, scale.y(interp(arr))); + if (close) { + context.lineTo(tipX, L.bottom - 4); + context.lineTo(x(0), L.bottom - 4); + context.closePath(); + } + } + + // 낙폭 음영 (시간가중 자산 기준, 3번째 패널에서 드러남) + if (wD > 0.01 && i0 > 0) { + let peak = -Infinity; + context.save(); + context.beginPath(); + let started = false; + for (let i = 0; i <= Math.min(i0 + 1, n - 1); i += 1) { + const v = i <= i0 ? s.twr[i] : interp(s.twr); + const px = i <= i0 ? x(i) : tipX; + peak = Math.max(peak, v); + const py = scale.y(v); + const peakY = scale.y(peak); + if (!started) { context.moveTo(px, peakY); started = true; } + context.lineTo(px, peakY); + } + for (let i = Math.min(i0 + 1, n - 1); i >= 0; i -= 1) { + const v = i <= i0 ? s.twr[i] : interp(s.twr); + const px = i <= i0 ? x(i) : tipX; + context.lineTo(px, scale.y(v)); + } + context.closePath(); + context.fillStyle = `rgba(37, 87, 201, ${(0.16 * wD).toFixed(3)})`; + context.fill(); + context.restore(); + if (tip >= s.maxDDIdx && s.maxDD < 0) { + const tx = x(s.maxDDIdx); + const ty = scale.y(s.twr[s.maxDDIdx]); + context.fillStyle = `rgba(37, 87, 201, ${wD.toFixed(3)})`; + context.beginPath(); + context.arc(tx, ty, 3.5, 0, Math.PI * 2); + context.fill(); + context.font = `500 12.5px ${FONT}`; + context.textAlign = tx > L.W * 0.7 ? "right" : "left"; + context.fillText(`최대 낙폭 ${pct(s.maxDD * 100)}`, tx + (tx > L.W * 0.7 ? -10 : 10), ty + 16); + context.font = `400 12px ${FONT}`; + } + } + + // 투자원금(계단, 점선) + context.save(); + context.setLineDash([3, 4]); + context.strokeStyle = inkAlpha(0.55 * wA + 0.18 * wB); + context.lineWidth = 1; + context.beginPath(); + for (let i = 0; i <= i0; i += 1) { + const px = x(i); + const py = scale.y(s.principal[i]); + if (i === 0) context.moveTo(px, py); + else { context.lineTo(px, scale.y(s.principal[i - 1])); context.lineTo(px, py); } + } + if (i0 < n - 1) context.lineTo(tipX, scale.y(s.principal[i0])); + context.stroke(); + context.restore(); + + // 평가금액 + context.lineJoin = "round"; + context.lineCap = "round"; + context.strokeStyle = inkAlpha(1 * wA + 0.22 * wB); + context.lineWidth = 1.6; + traceTo(s.value, false); + context.stroke(); + + // 시간가중 자산(적립 제외) + if (wB > 0.01) { + context.strokeStyle = inkAlpha(wB); + context.lineWidth = 1.6; + traceTo(s.twr, false); + context.stroke(); + } + + // 적립 시점 + s.markers.forEach((m) => { + if (m.index > tip) return; + const mx = x(m.index); + const alpha = 0.35 + 0.55 * wA; + context.strokeStyle = inkAlpha(0.35 * alpha); + context.lineWidth = 1; + context.beginPath(); + context.moveTo(mx, L.bottom - 4); + context.lineTo(mx, scale.y(s.value[m.index]) - 26); + context.stroke(); + context.fillStyle = inkAlpha(alpha); + context.textAlign = mx > L.W * 0.75 ? "right" : "left"; + context.font = `500 12px ${FONT}`; + context.fillText(`${signedWon(m.amount)} 적립`, mx + (mx > L.W * 0.75 ? -6 : 6), scale.y(s.value[m.index]) - 34); + context.font = `400 12px ${FONT}`; + }); + + // 끝점 + const emphasizeTwr = wB >= 0.5; + const tipValue = emphasizeTwr ? interp(s.twr) : interp(s.value); + const tipY = scale.y(tipValue); + context.fillStyle = COLORS.ink; + context.beginPath(); + context.arc(tipX, tipY, 4, 0, Math.PI * 2); + context.fill(); + context.strokeStyle = inkAlpha(0.25); + context.beginPath(); + context.arc(tipX, tipY, 9, 0, Math.PI * 2); + context.stroke(); + const rowIdx = Math.min(n - 1, Math.round(tip)); + const labelRight = tipX < L.W * 0.62; + context.textAlign = labelRight ? "left" : "right"; + const lx = tipX + (labelRight ? 14 : -14); + context.fillStyle = COLORS.ink; + context.font = `500 13.5px ${FONT}`; + context.fillText(emphasizeTwr ? `${pct(interp(s.cr))} 시간가중` : won(tipValue), lx, tipY - 9); + context.font = `400 12px ${FONT}`; + context.fillStyle = inkAlpha(0.55); + context.fillText(fmtMD(s.rows[rowIdx].date), lx, tipY + 9); + } + + function renderStoryPanels() { + const b = story.basket; + const s = story.series; + if (!b) { + $("p1Eyebrow").textContent = "활성화된 계좌가 없습니다"; + $("p1Title").innerHTML = '기록 없음'; + $("p1Sub").textContent = "config/baskets.yaml에서 모의투자 계좌를 켜면 첫 기록부터 이곳에 그려집니다."; + return; + } + const snap = b.snapshot; + const total = snap ? Number(snap.total_value) : null; + const principal = Number(b.principal || 0); + const profit = total == null ? null : total - principal; + $("p1Eyebrow").textContent = `${b.display_name} · ${modeLabel()}${snap ? ` · ${fmtMD(snap.date)} 기준` : ""}`; + $("p1Title").innerHTML = total == null + ? '첫 기록 대기' + : `${escapeHtml(nf0.format(Math.round(total)))}`; + $("p1Sub").textContent = total == null + ? "모의투자를 한 번 실행하면 총자산, 투자원금, 수익률이 여기에 나타납니다." + : `투자원금 ${won(principal)} · 평가손익 ${signedWon(profit)} (${pct(principal > 0 ? (profit / principal) * 100 : null)})`; + + if (!s || s.rows.length < 2) { + $("p2Title").innerHTML = ''; + $("p2Sub").textContent = "기록이 이틀 이상 쌓이면 적립을 제외한 운용 성과가 계산됩니다."; + $("p3Title").innerHTML = ''; + $("p3Sub").textContent = "고점과 낙폭은 기록이 쌓인 뒤 계산됩니다."; + return; + } + const last = s.rows[s.rows.length - 1]; + const cr = Number(last.cumulative_return || 0); + const lastFlow = s.markers.length ? s.markers[s.markers.length - 1] : null; + $("p2Title").innerHTML = `${escapeHtml(pct(cr))}시간가중수익률 · ${escapeHtml(fmtMD(s.rows[0].date))}부터 ${s.rows.length}거래일`; + $("p2Sub").textContent = lastFlow + ? `${fmtMD(lastFlow.at)}에 넣은 적립금 ${won(lastFlow.amount)}은 수익이 아니라 원금입니다. 시간가중수익률은 입금 시점의 영향을 걷어내고 운용 결과만 남깁니다.` + : "아직 적립 기록이 없어 평가금액과 운용 성과가 같습니다. 적립이 시작되면 입금 시점의 영향을 걷어낸 시간가중수익률로 봅니다."; + + const currentDD = s.dd[s.dd.length - 1] * 100; + const fromStart = s.maxDDPeakIdx < 0; + const peakLabel = fromStart ? `${fmtMD(s.rows[0].date)} 시작` : `${fmtMD(s.dates[s.maxDDPeakIdx])} 고점`; + const troughDate = s.dates[s.maxDDIdx]; + const fallDays = s.maxDDIdx - Math.max(0, s.maxDDPeakIdx); + const recovered = currentDD > -0.005; + $("p3Title").innerHTML = `${escapeHtml(pct(s.maxDD * 100))}${s.maxDD < 0 ? `${escapeHtml(peakLabel)} → ${escapeHtml(fmtMD(troughDate))} 저점` : "아직 고점 아래로 내려간 적이 없습니다"}`; + $("p3Sub").textContent = s.maxDD < 0 + ? `${fromStart ? `시작 자본 ${won(s.initialCapital)}을 고점으로` : `${peakLabel}에서`} ${fmtMD(troughDate)}까지 ${fallDays}거래일 동안 내려온 폭입니다. ${recovered ? "지금은 고점을 회복했습니다." : `지금은 고점보다 ${pct(currentDD, { sign: false })} 낮은 자리에 있습니다.`}` + : "기록 기간 내내 새 고점을 갱신했습니다. 낙폭은 첫 하락이 나타난 뒤부터 계산됩니다."; + } + + function initStory() { + const onScroll = () => { readStoryScroll(); wakeStory(); }; + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", () => { readStoryScroll(); story.dirty = true; wakeStory(); }); + if ("ResizeObserver" in window) { + new ResizeObserver(() => { story.dirty = true; wakeStory(); }).observe(el.stage); + } + if (document.fonts && document.fonts.ready) { + document.fonts.ready.then(() => { story.dirty = true; wakeStory(); drawChart(state.chartRows); }); + } + readStoryScroll(); + story.seekAt = story.progress; + wakeStory(); + } + + function updateStoryData() { + const basket = primaryBasket(); + story.basket = basket; + if (basket && state.series.get(basket.account_key)) { + story.series = buildSeries(state.series.get(basket.account_key), state.flows.get(basket.basket) || [], Number(basket.initial_capital || 0)); + } else { + story.series = null; + } + renderStoryPanels(); + story.dirty = true; + wakeStory(); + } + + /* ---------- 오늘 ---------- */ + + function latestSnapshotDate() { + const b = primaryBasket(); + return b && b.snapshot ? b.snapshot.date : null; + } + function currentMonthContributionState(basket) { + if (!basket || !basket.contribution_plan?.enabled) return "not-planned"; + if (state.flowStatus.get(basket.basket) !== "ready") return "unknown"; + const now = new Date(); + const recorded = (state.flows.get(basket.basket) || []).some((flow) => { + const when = parseDate(flow.occurred_at); + return when && when.getFullYear() === now.getFullYear() && when.getMonth() === now.getMonth(); + }); + return recorded ? "recorded" : "empty"; + } + function runtimeAgeMinutes() { + const parsed = parseDate(state.runtime && state.runtime.runtime_file_updated_at); + return parsed ? Math.max(0, Math.round((Date.now() - parsed.getTime()) / 60_000)) : null; + } + function setDecision({ title, description, action = null, actionLabel = "확인하기" }) { + el.decisionTitle.textContent = title; + el.decisionDescription.textContent = description; + el.decisionAction.hidden = !action; + el.decisionAction.dataset.action = action || ""; + el.decisionAction.textContent = actionLabel; + } + function renderDecision() { + const baskets = state.baskets; + const halt = state.runtime && state.runtime.trading_halt; + const latest = latestSnapshotDate(); + const ageDays = calendarAgeDays(latest); + const issues = (state.evaluations || []).flatMap((item) => item.issues || []); + const primary = primaryBasket(); + const contribution = currentMonthContributionState(primary); + el.decisionMeta.textContent = `${modeLabel()}${latest ? ` · 마지막 자산 기록 ${fmtLong(latest)}` : " · 자산 기록 없음"}`; + + if (halt && halt.halted) { + setDecision({ title: "거래가 중지된 상태입니다", description: halt.reason || "체결과 장부를 대조하기 전까지 신규 주문이 막혀 있습니다.", action: "operations", actionLabel: "운영 상태 보기" }); + return; + } + if (state.coreError) { + setDecision({ title: "장부를 읽지 못했습니다", description: "화면의 숫자는 마지막으로 성공한 조회 결과입니다. 연결을 확인한 뒤 다시 시도하세요.", action: "retry", actionLabel: "다시 확인" }); + return; + } + if (state.runtimeStatus === "loading") { + setDecision({ title: "운영 상태를 확인하고 있습니다", description: "거래 중지 여부와 자동매매 기록을 확인한 뒤 오늘 할 일을 정리합니다." }); + return; + } + if (state.runtimeStatus === "error") { + setDecision({ title: "운영 상태를 확인할 수 없습니다", description: "거래 중지 여부를 모르는 상태에서는 적립 기록이나 설정 변경을 하지 마세요.", action: "retry", actionLabel: "다시 확인" }); + return; + } + if (baskets && !baskets.length) { + setDecision({ title: "첫 모의투자 계좌를 켜 주세요", description: "config/baskets.yaml에서 계좌를 활성화하고 모의투자를 한 번 실행하면 첫 기록이 생깁니다.", action: "portfolio", actionLabel: "포트폴리오 보기" }); + return; + } + if (baskets && baskets.length && !latest) { + setDecision({ title: "첫 자산 기록을 기다리고 있습니다", description: "모의투자를 한 번 실행하면 투자원금, 총자산, 수익률이 분리되어 보입니다.", action: "portfolio", actionLabel: "포트폴리오 보기" }); + return; + } + if (ageDays != null && ageDays > 4) { + setDecision({ title: `자산 기록이 ${ageDays}일째 멈춰 있습니다`, description: `마지막 기록은 ${fmtLong(latest)}입니다. 자동매매가 멈췄을 수 있으니 운영 상태부터 확인하세요.`, action: "operations", actionLabel: "운영 상태 보기" }); + return; + } + const loopMetrics = state.runtime && state.runtime.loop_metrics; + const loopLastAt = parseDate(loopMetrics && loopMetrics.last_success) || parseDate(state.runtime && state.runtime.runtime_file_updated_at); + const runtimeAge = loopLastAt ? Math.max(0, Math.round((Date.now() - loopLastAt.getTime()) / 60_000)) : null; + if (runtimeAge == null || runtimeAge > 720) { + setDecision({ + title: runtimeAge == null ? "자동매매 실행 기록이 없습니다" : `자동매매가 ${sinceCopy(runtimeAge)} 실행되지 않았습니다`, + description: runtimeAge == null ? "스케줄러 기록이 보이지 않습니다. 오늘 사이클이 돌았는지 먼저 확인하세요." : `마지막 실행은 ${fmtDT(loopLastAt)}입니다. 적립보다 스케줄러 상태를 먼저 확인하세요.`, + action: "operations", actionLabel: "운영 상태 보기", + }); + return; + } + if (issues.length) { + setDecision({ title: `검토가 필요한 항목이 ${issues.length}건 있습니다`, description: "실전 전환은 검토가 끝날 때까지 잠겨 있습니다.", action: "review", actionLabel: "검토 항목 보기" }); + return; + } + if (contribution === "unknown") { + setDecision({ title: "적립 기록을 확인할 수 없습니다", description: "조회가 끝나기 전에는 같은 적립금을 두 번 기록하지 않도록 기다려 주세요.", action: "retry", actionLabel: "다시 확인" }); + return; + } + if (contribution === "empty") { + const planned = Number(primary.contribution_plan?.amount || 0); + setDecision({ title: "이번 달 적립을 아직 기록하지 않았습니다", description: `${planned > 0 ? `기준은 매월 ${won(planned)}입니다. ` : ""}실제 입금이 끝난 뒤에만 같은 금액을 기록하세요.`, action: "deposit", actionLabel: "적립 기록" }); + return; + } + setDecision({ title: "오늘은 손댈 것이 없습니다", description: "계획대로 기록을 쌓는 중입니다. 시세를 매일 확인하거나 전략을 바꿀 이유가 없습니다." }); + } + + function statusItem(key, value, stateName) { + return `
  • ${escapeHtml(key)}${escapeHtml(value)}
  • `; + } + + /* ---------- 포트폴리오 ---------- */ + + function sparkline(rows) { + const values = (rows || []).slice(-60).map((r) => Number(r.total_value || 0)).filter(Number.isFinite); + if (values.length < 2) return ""; + const w = 180; + const h = 52; + const min = Math.min(...values); + const max = Math.max(...values); + const span = Math.max(1, max - min); + const px = (i) => (i / (values.length - 1)) * w; + const py = (v) => 6 + ((max - v) / span) * (h - 12); + const d = values.map((v, i) => `${i === 0 ? "M" : "L"}${px(i).toFixed(1)} ${py(v).toFixed(1)}`).join(" "); + const last = values[values.length - 1]; + return ``; + } + + function holdingsTable(basket) { + const positions = basket.positions || []; + if (!positions.length) return '

    아직 보유 종목이 없습니다.

    '; + const total = basket.snapshot ? Number(basket.snapshot.total_value) : null; + const targets = basket.target_weights || {}; + const rows = positions.map((p) => ({ + ...p, + weight: total ? Number(p.invested || 0) / total : null, + target: targets[p.symbol] == null ? null : Number(targets[p.symbol]), + })); + const maxW = Math.max(0.01, ...rows.map((r) => Math.max(r.weight || 0, r.target || 0))); + const barWidth = (w) => `${Math.round(((w || 0) / maxW) * 100)}%`; + const holdingsValue = basket.holdings_value == null ? null : Number(basket.holdings_value); + const holdingsCost = basket.holdings_cost == null ? null : Number(basket.holdings_cost); + const holdingsPnl = holdingsValue == null || holdingsCost == null ? null : holdingsValue - holdingsCost; + return `
    + + + ${rows.map((r) => ` + + + + + + + `).join("")} + +
    ${escapeHtml(basket.display_name)} 보유 종목과 비중
    종목수량평단가매입금액매입 비중목표 비중
    ${escapeHtml(r.name || r.symbol)}${escapeHtml(r.symbol)}${nf0.format(Number(r.quantity || 0))}주${won(r.avg_price)}${won(r.invested)}${r.weight == null ? "—" : pct(r.weight * 100, { sign: false, digits: 1 })}${r.target == null ? "—" : pct(r.target * 100, { sign: false, digits: 1 })}
    보유분 평가금액 ${won(holdingsValue)} · 매입금액 ${won(holdingsCost)} · 평가손익 ${signedWon(holdingsPnl)}${holdingsCost && holdingsPnl != null ? ` (${pct(holdingsPnl / holdingsCost * 100)})` : ""}
    +

    현재가는 장부에 저장하지 않으므로 종목별 비중은 매입금액 기준이고, 보유분 평가금액은 총자산에서 현금을 뺀 값입니다.

    `; + } + + function renderBasketTracks(data) { + const baskets = (data && data.baskets) || []; + state.baskets = baskets; + el.basketTracks.setAttribute("aria-busy", "false"); + setMode(data && data.mode); + markCoreSuccess(data && data.timestamp); + + if (!baskets.length) { + el.basketTracks.innerHTML = '
    활성화된 계좌가 없습니다.config/baskets.yaml에서 모의투자 계좌를 먼저 켜 주세요.
    '; + el.portfolioAsOf.textContent = "기준일 없음"; + el.portfolioAsOf.dateTime = ""; + updateStoryData(); + return; + } + + const firstRender = !state.tracksAnimated && !noMotion(); + el.basketTracks.innerHTML = sortedBaskets().map((b) => { + const snap = b.snapshot; + const total = snap ? Number(snap.total_value) : null; + const principal = Number(b.principal || 0); + const profit = total == null ? null : total - principal; + const cr = snap ? Number(snap.cumulative_return) : null; + const cashRatio = snap && total > 0 ? (Number(snap.cash) / total) * 100 : null; + const deployment = b.deployment_ratio == null ? null : Number(b.deployment_ratio) * 100; + const target = b.design_fraction == null ? null : Number(b.design_fraction) * 100; + // 장부의 mdd는 그날의 고점 대비 낙폭이므로, 최대낙폭은 기록 전체에서 가장 깊었던 값을 고른다. + const maxDD = Math.max(Number(snap ? snap.mdd : 0) || 0, ...(state.series.get(b.account_key) || []).map((r) => Number(r.mdd) || 0)); + const gap = deployment == null || target == null ? null : target - deployment; + const primary = Boolean(b.is_primary); + const plan = b.contribution_plan || {}; + const kind = primary + ? `주력${plan.enabled && Number(plan.amount) > 0 ? ` · 매월 ${won(plan.amount)} 적립` : ""}` + : `관찰 · ${escapeHtml(b.purpose || "장기 관찰용")}`; + const flows = state.flows.get(b.basket) || []; + const latestFlow = flows[0]; + const flowState = state.flowStatus.get(b.basket); + const note = gap != null && gap > 1 + ? (primary + ? `목표보다 ${Math.round(gap)}%p 낮습니다. ETF를 1주 단위로 사기 때문에 소액에서는 미달이 흔하고, 적립이 쌓이면 좁혀집니다.` + : `목표보다 ${Math.round(gap)}%p 낮습니다. 관찰 계좌는 큰 이탈만 점검합니다.`) + : "목표 범위 안에서 운용 중입니다."; + const overlay = b.overlay; + const baseTarget = b.base_stock_fraction == null ? null : Number(b.base_stock_fraction) * 100; + const overlayLine = overlay && overlay.enabled + ? `

    위험 조절 ${escapeHtml(overlay.summary || "")}${baseTarget != null && target != null && Math.abs(baseTarget - target) > 0.5 ? ` · 설계 ${Math.round(baseTarget)}% → 적용 ${Math.round(target)}%` : ""}${overlay.evaluated_at ? ` · ${escapeHtml(fmtDT(overlay.evaluated_at))} 판단` : ""}

    ` + : ""; + const width = deployment == null ? 0 : clamp(deployment, 0, 100); + return `
    +
    +
    +

    ${kind}

    +

    ${escapeHtml(b.display_name)}

    +

    ${escapeHtml(b.basket)}${snap ? ` · ${escapeHtml(fmtLong(snap.date))} 기준` : " · 첫 기록 대기"}

    - ${holdings} - ${latestFlow ? `

    최근 적립 · ${formatDate(latestFlow.occurred_at, dateShort)} · +${formatWon(latestFlow.amount)}${latestFlow.note ? ` · ${escapeHtml(latestFlow.note)}` : ""}

    ` : ""} - ${flowState === "error" ? '

    적립 기록을 확인할 수 없습니다. 새 기록을 추가하지 말고 다시 확인하세요.

    ' : ""} + ${sparkline(state.series.get(b.account_key))} +
    +
    +
    총자산
    ${total == null ? "—" : won(total)}
    +
    투자원금
    ${won(principal)}${Number(b.deposits_total) > 0 ? ` 적립 ${compactWon(b.deposits_total)} 포함` : ""}
    +
    평가손익
    ${profit == null ? "—" : signedWon(profit)}${profit == null || principal <= 0 ? "" : ` ${pct((profit / principal) * 100)}`}
    +
    시간가중수익률
    ${cr == null ? "—" : pct(cr)}
    +
    최대낙폭
    ${snap ? pct(-maxDD) : "—"}${snap ? ` 지금 ${pct(-Math.abs(Number(snap.mdd || 0)))}` : ""}
    +
    현금 비중
    ${cashRatio == null ? "—" : pct(cashRatio, { sign: false, digits: 1 })}${snap ? ` ${compactWon(snap.cash)}` : ""}
    +
    +
    +
    투자 비중${deployment == null ? "—" : `${Math.round(deployment)}%`}목표 ${target == null ? "—" : `${Math.round(target)}%`}
    +
    ${target == null ? "" : ``}
    +

    ${escapeHtml(note)}

    + ${overlayLine}
    + ${holdingsTable(b)} + ${latestFlow ? `

    최근 적립 ${escapeHtml(fmtMD(latestFlow.occurred_at))} · ${escapeHtml(signedWon(latestFlow.amount))}${latestFlow.note ? ` · ${escapeHtml(latestFlow.note)}` : ""}

    ` : ""} + ${flowState === "error" ? '

    적립 기록을 읽지 못했습니다. 다시 확인하기 전까지 새 기록을 추가하지 마세요.

    ' : ""}
    `; - }).join(""); - - const referenceBasket = primaryBasket() || sortedBaskets().find((basket) => basket.snapshot); - const referenceDate = referenceBasket && referenceBasket.snapshot && referenceBasket.snapshot.date; - elements.portfolioAsOf.dateTime = referenceDate || ""; - elements.portfolioAsOf.textContent = referenceDate - ? `주력 기준 ${formatDate(referenceDate)}` - : "첫 기록 대기 중"; - renderPortfolioSummary(); -} - -async function refreshFlows() { - const baskets = state.baskets || []; - const tasks = baskets.map(async (basket) => { - state.flowStatus.set(basket.basket, "loading"); - try { - const data = await fetchJson(`/api/cash_flows?basket=${encodeURIComponent(basket.basket)}`, { - timeout: 10_000, - key: `flows:${basket.basket}`, - }); - state.flows.set(basket.basket, (data && data.flows) || []); - state.flowStatus.set(basket.basket, "ready"); - return true; - } catch { - state.flowStatus.set(basket.basket, "error"); - return false; + }).join(""); + + if (firstRender) { + state.tracksAnimated = true; + requestAnimationFrame(() => requestAnimationFrame(() => { + el.basketTracks.querySelectorAll(".bar i[data-width]").forEach((bar) => { bar.style.width = `${bar.dataset.width}%`; }); + })); } - }); - const results = await Promise.all(tasks); - state.flowError = results.some((ok) => !ok); - if (state.baskets) renderBasketTracks({ baskets: state.baskets, mode: state.mode, timestamp: state.lastCoreSuccess }); - renderDecision(); - return !state.flowError; -} - -function ensureChartAccountOptions() { - const wanted = sortedBaskets().map((basket) => ({ - value: basket.account_key, - label: basket.display_name, - })); - wanted.push({ value: "", label: "레거시 기본 계정" }); - const signature = JSON.stringify(wanted); - if (elements.chartAccount.dataset.signature === signature) return; - - const previous = state.chartAccount; - elements.chartAccount.dataset.signature = signature; - elements.chartAccount.innerHTML = wanted.map((option) => ( - `` - )).join(""); - const exists = previous !== null && wanted.some((option) => option.value === previous); - elements.chartAccount.value = exists ? previous : (wanted[0] ? wanted[0].value : ""); - state.chartAccount = elements.chartAccount.value; -} - -function syncChartQuery() { - const params = new URLSearchParams(window.location.search); - params.set("days", String(state.chartDays)); - if (state.chartAccount) params.set("account", state.chartAccount); - else params.delete("account"); - const queryString = params.toString(); - history.replaceState(null, "", `${window.location.pathname}${queryString ? `?${queryString}` : ""}${window.location.hash}`); -} - -function renderChartTable(snapshots) { - elements.chartRows.innerHTML = snapshots.map((snapshot) => ` - - - ${escapeHtml(formatWon(snapshot.total_value))} - ${escapeHtml(formatPercent(snapshot.cumulative_return))} + + const reference = primaryBasket(); + const referenceDate = reference && reference.snapshot && reference.snapshot.date; + el.portfolioAsOf.dateTime = referenceDate || ""; + el.portfolioAsOf.textContent = referenceDate ? `${fmtLong(referenceDate)} 기록 기준` : "첫 기록 대기"; + updateStoryData(); + } + + async function refreshFlows() { + const baskets = state.baskets || []; + const results = await Promise.all(baskets.map(async (b) => { + state.flowStatus.set(b.basket, "loading"); + try { + const data = await fetchJson(`/api/cash_flows?basket=${encodeURIComponent(b.basket)}`, { timeout: 10_000, key: `flows:${b.basket}` }); + state.flows.set(b.basket, (data && data.flows) || []); + state.flowStatus.set(b.basket, "ready"); + return true; + } catch { + state.flowStatus.set(b.basket, "error"); + return false; + } + })); + state.flowError = results.some((ok) => !ok); + return !state.flowError; + } + + async function refreshSeries() { + const baskets = state.baskets || []; + await Promise.all(baskets.map(async (b) => { + try { + const data = await fetchJson(`/api/snapshots?days=3650&account_key=${encodeURIComponent(b.account_key)}`, { timeout: 15_000, key: `series:${b.account_key}` }); + state.series.set(b.account_key, (data && data.snapshots) || []); + state.seriesStatus.set(b.account_key, "ready"); + } catch { + state.seriesStatus.set(b.account_key, "error"); + } + })); + } + + /* ---------- 성과 ---------- */ + + const chart = { progress: 1, frame: null, hover: null, geometry: null, series: null, rowsKey: "" }; + + function ensureChartAccountOptions() { + const wanted = sortedBaskets().map((b) => ({ value: b.account_key, label: b.display_name })); + wanted.push({ value: "", label: "레거시 기본 계정" }); + const signature = JSON.stringify(wanted); + if (el.chartAccount.dataset.signature === signature) return; + const previous = state.chartAccount; + el.chartAccount.dataset.signature = signature; + el.chartAccount.innerHTML = wanted.map((o) => ``).join(""); + const exists = previous !== null && wanted.some((o) => o.value === previous); + el.chartAccount.value = exists ? previous : (wanted[0] ? wanted[0].value : ""); + state.chartAccount = el.chartAccount.value; + } + function syncChartQuery() { + const params = new URLSearchParams(window.location.search); + params.set("days", String(state.chartDays)); + if (state.chartAccount) params.set("account", state.chartAccount); else params.delete("account"); + const qs = params.toString(); + history.replaceState(null, "", `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`); + } + function chartBasket() { + return (state.baskets || []).find((b) => b.account_key === state.chartAccount) || null; + } + function renderChartMaturity() { + const basket = chartBasket(); + if (!basket) { el.chartMaturity.textContent = "레거시 기본 계정은 모의투자 검증과 별개입니다."; return; } + const evaluation = (state.evaluations || []).find((item) => item.basket === basket.basket); + if (!evaluation) { el.chartMaturity.textContent = ""; return; } + const days = Number(evaluation.progress_days || 0); + const minimum = Math.max(1, Number(evaluation.min_trading_days || 60)); + el.chartMaturity.textContent = days < 20 + ? `운용 ${days}거래일째 · 추세를 판단하기엔 이릅니다` + : days < minimum ? `운용 ${days}거래일째 · ${minimum}거래일까지는 기록 무결성을 먼저 봅니다` : `운용 ${days}거래일째 · 장기 검토가 가능한 구간입니다`; + } + + function renderChartTable(series) { + el.chartRows.innerHTML = series.rows.map((row, i) => ` + + ${won(row.total_value)} + ${won(series.principal[i])} + ${pct(row.cumulative_return)} `).join(""); -} - -function renderChartMaturity() { - const basket = (state.baskets || []).find((item) => item.account_key === state.chartAccount); - if (!basket) { - elements.chartMaturity.textContent = "레거시 기본 계정은 모의 운용 검증 트랙과 분리됩니다."; - return; - } - const evaluation = (state.evaluations || []).find((item) => item.basket === basket.basket); - if (!evaluation) { - elements.chartMaturity.textContent = "운용 기록 성숙도를 확인하고 있습니다."; - return; - } - const days = Number(evaluation.progress_days || 0); - const minimum = Math.max(1, Number(evaluation.min_trading_days || 60)); - if (days < 20) { - elements.chartMaturity.textContent = `운용 ${days}영업일차 · 장기 추세를 판단하기 전입니다.`; - } else if (days < minimum) { - elements.chartMaturity.textContent = `운용 ${days}영업일차 · ${minimum}영업일까지 기록 무결성을 우선 확인합니다.`; - } else { - elements.chartMaturity.textContent = `운용 ${days}영업일차 · 장기 기록 검토가 가능한 구간입니다.`; - } -} - -function drawChart(rows) { - if (!rows.length || elements.chartWrap.hidden) return; - const canvas = $("chartEquity"); - const width = Math.max(280, elements.chartWrap.clientWidth); - const height = Math.max(280, elements.chartWrap.clientHeight); - const ratio = Math.min(2, window.devicePixelRatio || 1); - canvas.width = Math.round(width * ratio); - canvas.height = Math.round(height * ratio); - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - - const context = canvas.getContext("2d"); - context.setTransform(ratio, 0, 0, ratio, 0, 0); - context.clearRect(0, 0, width, height); - - const padding = { top: 22, right: 18, bottom: 38, left: width < 480 ? 58 : 72 }; - const plotWidth = width - padding.left - padding.right; - const plotHeight = height - padding.top - padding.bottom; - const values = rows.map((row) => Number(row.total_value || 0)); - let minimum = Math.min(...values); - let maximum = Math.max(...values); - const spread = Math.max(1, maximum - minimum); - minimum -= spread * 0.08; - maximum += spread * 0.08; - - const x = (index) => padding.left + (plotWidth * index) / Math.max(1, rows.length - 1); - const y = (value) => padding.top + ((maximum - value) / (maximum - minimum)) * plotHeight; - - context.font = '10px "IBM Plex Mono", monospace'; - context.fillStyle = "#5d6966"; - context.strokeStyle = "#e5e1d8"; - context.lineWidth = 1; - context.textAlign = "right"; - context.textBaseline = "middle"; - for (let index = 0; index <= 4; index += 1) { - const value = maximum - ((maximum - minimum) * index) / 4; - const lineY = padding.top + (plotHeight * index) / 4; + } + + function renderMonthStrip(series) { + const months = monthlyReturns(series); + el.monthStrip.innerHTML = months.map((m) => `
    ${escapeHtml(m.label)}${pct(m.value, { digits: 1 })}
    `).join(""); + } + + function equityGeometry(series, width, height) { + const padding = { top: 20, right: 12, bottom: 28, left: width < 480 ? 52 : 64 }; + const n = series.rows.length; + const x = (i) => padding.left + ((width - padding.left - padding.right) * i) / Math.max(1, n - 1); + const scale = makeScale([...series.value, ...series.principal], padding.top, height - padding.bottom, 0.08); + return { padding, x, scale, width, height, n }; + } + + function drawChart(rows) { + if (!chart.series || el.chartWrap.hidden) return; + const series = chart.series; + const width = Math.max(280, el.chartBox.clientWidth); + const height = Math.max(200, el.chartBox.clientHeight); + const context = setupCanvas(el.chartEquity, width, height); + const g = equityGeometry(series, width, height); + chart.geometry = g; + const { padding, x, scale, n } = g; + context.font = `400 11.5px ${FONT}`; + context.textBaseline = "middle"; + + context.textAlign = "right"; + niceTicks(scale.min, scale.max, 4).forEach((v, i) => { + const y = scale.y(v); + context.strokeStyle = inkAlpha(i === 4 ? 0.34 : 0.12); + context.lineWidth = 1; + context.beginPath(); + context.moveTo(padding.left, y); + context.lineTo(width - padding.right, y); + context.stroke(); + context.fillStyle = inkAlpha(0.42); + context.fillText(compactWon(v), padding.left - 8, y); + }); + const step = Math.max(1, Math.ceil((n - 1) / (width < 480 ? 3 : 6))); + context.fillStyle = inkAlpha(0.42); + for (let i = 0; i < n - 1; i += step) { + if (n > 1 && i > n - 1 - step * 0.5) break; + context.textAlign = i === 0 ? "left" : "center"; + context.fillText(fmtMD(series.rows[i].date), x(i), height - padding.bottom + 14); + } + context.textAlign = n > 1 ? "right" : "left"; + context.fillText(fmtMD(series.rows[n - 1].date), x(n - 1), height - padding.bottom + 14); + + const visible = Math.max(1, Math.ceil((n - 1) * chart.progress) + 1); + const clipRight = n > 1 ? x(0) + (x(n - 1) - x(0)) * chart.progress : width; + context.save(); + context.beginPath(); + context.rect(0, 0, clipRight + 1, height); + context.clip(); + + context.save(); + context.setLineDash([3, 4]); + context.strokeStyle = inkAlpha(0.5); + context.lineWidth = 1; context.beginPath(); - context.moveTo(padding.left, lineY); - context.lineTo(width - padding.right, lineY); + series.principal.forEach((v, i) => { + if (i >= visible) return; + const px = x(i); + const py = scale.y(v); + if (i === 0) context.moveTo(px, py); + else { context.lineTo(px, scale.y(series.principal[i - 1])); context.lineTo(px, py); } + }); context.stroke(); - context.fillText(`${compactWon.format(value)}원`, padding.left - 9, lineY); - } - - context.strokeStyle = "#18201f"; - context.lineWidth = 2; - context.beginPath(); - rows.forEach((row, index) => { - const pointX = x(index); - const pointY = y(values[index]); - if (index === 0) context.moveTo(pointX, pointY); - else context.lineTo(pointX, pointY); - }); - context.stroke(); - - const tickStep = Math.max(1, Math.ceil((rows.length - 1) / 5)); - context.textAlign = "center"; - context.textBaseline = "top"; - rows.forEach((row, index) => { - if (index % tickStep !== 0 && index !== rows.length - 1) return; - context.fillStyle = "#5d6966"; - context.fillText(formatDate(row.date, dateShort), x(index), height - padding.bottom + 12); - }); - - if (rows.length <= 14) { - rows.forEach((row, index) => { + context.restore(); + + series.markers.forEach((m) => { + if (m.index >= visible) return; + const mx = x(m.index); + context.strokeStyle = inkAlpha(0.28); context.beginPath(); - context.arc(x(index), y(values[index]), 3, 0, Math.PI * 2); - context.fillStyle = "#b84a2a"; - context.fill(); - context.strokeStyle = "#fffdf8"; - context.lineWidth = 1; + context.moveTo(mx, padding.top); + context.lineTo(mx, height - padding.bottom); context.stroke(); + context.fillStyle = inkAlpha(0.7); + context.font = `500 11.5px ${FONT}`; + context.textAlign = mx > width * 0.8 ? "right" : "left"; + context.fillText(`${signedWon(m.amount)} 적립`, mx + (mx > width * 0.8 ? -5 : 5), padding.top - 8); + context.font = `400 11.5px ${FONT}`; }); - } -} - -function updateChart(snapshots) { - const rows = Array.isArray(snapshots) ? snapshots : []; - state.chartRows = rows; - renderChartTable(rows); - renderChartMaturity(); - - if (!rows.length) { - elements.chartWrap.hidden = true; - elements.chartEmpty.hidden = false; - elements.chartSummary.textContent = "아직 선택한 기간의 자산 기록이 없습니다."; - return; - } - - const first = rows[0]; - const last = rows.at(-1); - const change = Number(last.total_value || 0) - Number(first.total_value || 0); - elements.chartSummary.textContent = `${formatDate(first.date, dateShort)}부터 ${formatDate(last.date, dateShort)}까지 ${change >= 0 ? "+" : ""}${formatWon(change)} · 최근 TWR ${formatPercent(last.cumulative_return)}`; - elements.chartWrap.hidden = false; - elements.chartEmpty.hidden = true; - drawChart(rows); -} - -async function refreshChart() { - ensureChartAccountOptions(); - state.chartAccount = elements.chartAccount.value; - syncChartQuery(); - try { - const data = await fetchJson(`/api/snapshots?days=${state.chartDays}&account_key=${encodeURIComponent(state.chartAccount || "")}`, { - timeout: 15_000, - key: "chart", + + context.strokeStyle = COLORS.ink; + context.lineWidth = 1.6; + context.lineJoin = "round"; + context.beginPath(); + series.value.forEach((v, i) => { + if (i >= visible) return; + const px = x(i); + const py = scale.y(v); + if (i === 0) context.moveTo(px, py); else context.lineTo(px, py); }); - updateChart((data && data.snapshots) || []); - } catch { - elements.chartSummary.textContent = "성과 기록을 불러오지 못했습니다. 연결을 확인한 뒤 다시 시도하세요."; - elements.chartWrap.hidden = true; - elements.chartEmpty.hidden = false; - elements.chartEmpty.innerHTML = "성과 데이터를 확인할 수 없습니다.기존 장부는 변경되지 않았습니다. 잠시 후 다시 확인하세요."; - } -} - -const verdictCopy = { - PASS_CANDIDATE: ["검토 준비", "positive"], - FAIL_REVIEW: ["재점검 필요", "negative"], - WAIT: ["관찰 중", "muted"], -}; - -function renderEvaluations(evaluations) { - const items = Array.isArray(evaluations) ? evaluations : []; - state.evaluations = items; - elements.basketEval.setAttribute("aria-busy", "false"); - if (!items.length) { - elements.basketEval.innerHTML = '

    검토 중인 포트폴리오가 없습니다.

    '; + context.stroke(); + if (n <= 14) { + series.value.forEach((v, i) => { + if (i >= visible) return; + context.beginPath(); + context.arc(x(i), scale.y(v), 2.5, 0, Math.PI * 2); + context.fillStyle = COLORS.ink; + context.fill(); + }); + } + context.restore(); + + if (chart.hover != null && chart.progress >= 1) { + const i = chart.hover; + const px = x(i); + const py = scale.y(series.value[i]); + context.save(); + context.setLineDash([2, 3]); + context.strokeStyle = inkAlpha(0.5); + context.beginPath(); + context.moveTo(px, padding.top); + context.lineTo(px, height - padding.bottom); + context.stroke(); + context.restore(); + context.beginPath(); + context.arc(px, py, 4.5, 0, Math.PI * 2); + context.fillStyle = COLORS.ink; + context.fill(); + context.strokeStyle = "#f2f0ec"; + context.lineWidth = 2; + context.stroke(); + } + drawDrawdown(series); + } + + function drawDrawdown(series) { + const box = $("drawdownBox"); + const width = Math.max(280, box.clientWidth); + const height = Math.max(80, box.clientHeight); + const context = setupCanvas(el.chartDrawdown, width, height); + const padding = { top: 10, right: 12, bottom: 8, left: width < 480 ? 52 : 64 }; + const n = series.rows.length; + const x = (i) => padding.left + ((width - padding.left - padding.right) * i) / Math.max(1, n - 1); + const minDD = Math.min(-0.005, ...series.dd); + const y = (v) => padding.top + ((0 - v) / (0 - minDD)) * (height - padding.top - padding.bottom); + context.font = `400 11.5px ${FONT}`; + context.textBaseline = "middle"; + context.textAlign = "right"; + context.strokeStyle = inkAlpha(0.34); + context.beginPath(); + context.moveTo(padding.left, y(0)); + context.lineTo(width - padding.right, y(0)); + context.stroke(); + context.fillStyle = inkAlpha(0.42); + context.fillText("0%", padding.left - 8, y(0)); + context.fillText(pct(minDD * 100, { digits: 1 }), padding.left - 8, y(minDD)); + const visible = Math.max(1, Math.ceil((n - 1) * chart.progress) + 1); + context.beginPath(); + context.moveTo(x(0), y(0)); + series.dd.forEach((v, i) => { if (i < visible) context.lineTo(x(i), y(v)); }); + context.lineTo(x(Math.min(n - 1, visible - 1)), y(0)); + context.closePath(); + context.fillStyle = "rgba(37, 87, 201, 0.16)"; + context.fill(); + context.strokeStyle = COLORS.down; + context.lineWidth = 1.2; + context.beginPath(); + series.dd.forEach((v, i) => { if (i < visible) { if (i === 0) context.moveTo(x(i), y(v)); else context.lineTo(x(i), y(v)); } }); + context.stroke(); + if (chart.hover != null && chart.progress >= 1) { + const i = chart.hover; + context.beginPath(); + context.arc(x(i), y(series.dd[i]), 3.5, 0, Math.PI * 2); + context.fillStyle = COLORS.down; + context.fill(); + } + } + + function animateChartIn() { + if (chart.frame) cancelAnimationFrame(chart.frame); + if (noMotion() || !chart.series || chart.series.rows.length < 2) { + chart.progress = 1; + drawChart(); + return; + } + chart.progress = 0; + const started = performance.now(); + const duration = 600; + const step = (now) => { + const t = Math.min(1, (now - started) / duration); + chart.progress = 1 - (1 - t) ** 3; + drawChart(); + chart.frame = chart.progress < 1 ? requestAnimationFrame(step) : null; + }; + chart.frame = requestAnimationFrame(step); + } + + function hideChartTip() { + if (chart.hover == null) return; + chart.hover = null; + el.chartTip.hidden = true; + drawChart(); + } + function handleChartPointer(event) { + const series = chart.series; + const g = chart.geometry; + if (!series || !g || chart.progress < 1) return; + const bounds = event.currentTarget.getBoundingClientRect(); + const px = event.clientX - bounds.left; + const plotWidth = g.width - g.padding.left - g.padding.right; + const index = clamp(Math.round(((px - g.padding.left) / Math.max(1, plotWidth)) * (g.n - 1)), 0, g.n - 1); + if (index === chart.hover) return; + chart.hover = index; + const row = series.rows[index]; + const tip = el.chartTip; + tip.querySelector("time").textContent = fmtLong(row.date); + tip.querySelector("strong").textContent = won(row.total_value); + const twr = tip.querySelector("span"); + twr.textContent = `투자원금 ${won(series.principal[index])} · 시간가중 ${pct(row.cumulative_return)} · 낙폭 ${pct(series.dd[index] * 100, { digits: 1 })}`; + twr.className = tone(row.cumulative_return); + const tipX = clamp(g.x(index), 110, g.width - 110); + tip.style.transform = `translate(${tipX}px, ${g.scale.y(series.value[index])}px) translate(-50%, calc(-100% - 12px))`; + tip.hidden = false; + drawChart(); + } + + function updateChart(snapshots) { + const rows = Array.isArray(snapshots) ? snapshots : []; + const basket = chartBasket(); + const initial = basket ? Number(basket.initial_capital || 0) : Number(state.legacy?.initial_capital || 0); + const flows = basket ? (state.flows.get(basket.basket) || []) : []; + const series = buildSeries(rows, flows, initial); + const key = JSON.stringify([state.chartAccount, series.rows.map((r) => [r.date, r.total_value])]); + const changed = key !== chart.rowsKey; + chart.rowsKey = key; + chart.series = series; + state.chartRows = series.rows; + chart.hover = null; + el.chartTip.hidden = true; + renderChartMaturity(); + + if (!series.rows.length) { + el.chartWrap.hidden = true; + el.chartEmpty.hidden = false; + el.chartSummary.textContent = "선택한 기간에 자산 기록이 없습니다."; + el.chartRows.innerHTML = ""; + el.monthStrip.innerHTML = ""; + return; + } + const first = series.rows[0]; + const last = series.rows[series.rows.length - 1]; + const change = Number(last.total_value || 0) - Number(first.total_value || 0); + const deposits = series.markers.reduce((s, m) => s + m.amount, 0); + el.chartSummary.textContent = `${fmtMD(first.date)} → ${fmtMD(last.date)} · 평가금액 ${signedWon(change)}${deposits ? ` (적립 ${won(deposits)} 포함)` : ""} · 시간가중 ${pct(last.cumulative_return)}`; + el.chartWrap.hidden = false; + el.chartEmpty.hidden = true; + renderChartTable(series); + renderMonthStrip(series); + if (changed) animateChartIn(); else drawChart(); + } + + async function refreshChart() { + ensureChartAccountOptions(); + state.chartAccount = el.chartAccount.value; + syncChartQuery(); + try { + const data = await fetchJson(`/api/snapshots?days=${state.chartDays}&account_key=${encodeURIComponent(state.chartAccount || "")}`, { timeout: 15_000, key: "chart" }); + updateChart((data && data.snapshots) || []); + } catch { + el.chartSummary.textContent = "성과 기록을 불러오지 못했습니다. 연결을 확인한 뒤 다시 시도하세요."; + el.chartWrap.hidden = true; + el.chartEmpty.hidden = false; + el.chartEmpty.innerHTML = "성과 기록을 읽지 못했습니다.장부는 바뀌지 않았습니다. 잠시 후 다시 확인하세요."; + } + } + + /* ---------- 검증 ---------- */ + + const verdictCopy = { + PASS_CANDIDATE: ["검토 가능", ""], + FAIL_REVIEW: ["재점검 필요", "stop"], + WAIT: ["관찰 중", "warn"], + }; + function renderEvaluations(evaluations) { + const items = Array.isArray(evaluations) ? evaluations : []; + state.evaluations = items; + el.basketEval.setAttribute("aria-busy", "false"); + if (!items.length) { + el.basketEval.innerHTML = '

    검증 중인 계좌가 없습니다.

    '; + renderDecision(); + return; + } + const byName = new Map((state.baskets || []).map((b) => [b.basket, b])); + const ordered = [...items].sort((a, b) => Number(Boolean(byName.get(b.basket)?.is_primary)) - Number(Boolean(byName.get(a.basket)?.is_primary))); + el.basketEval.innerHTML = ordered.map((item) => { + const basket = byName.get(item.basket); + const name = basket?.display_name || item.basket; + const days = Number(item.progress_days || 0); + const minimum = Math.max(1, Number(item.min_trading_days || 60)); + const progress = Math.min(100, Math.round((days / minimum) * 100)); + const coverage = item.snapshot_coverage == null ? null : Math.round(Number(item.snapshot_coverage) * 100); + const [copy, cls] = verdictCopy[item.verdict] || ["관찰 중", "warn"]; + const issues = (item.issues || []).slice(0, 3); + return `
    +

    ${escapeHtml(name)}${basket?.is_primary ? " · 주력" : ""}${escapeHtml(item.basket)}

    +
    +
    +

    ${days} / ${minimum} 거래일${coverage == null ? "기록 누락 확인 중" : coverage >= 100 ? "기록 누락 없음" : `기록 누락 ${Math.max(0, 100 - coverage)}%`}

    +
    +

    ${copy}

    + ${issues.length ? `
      ${issues.map((i) => `
    • ${escapeHtml(i)}
    • `).join("")}
    ` : ""} +
    `; + }).join(""); + renderChartMaturity(); renderDecision(); - return; - } - - const basketByName = new Map((state.baskets || []).map((basket) => [basket.basket, basket])); - const ordered = [...items].sort((left, right) => ( - Number(Boolean(basketByName.get(right.basket)?.is_primary)) - - Number(Boolean(basketByName.get(left.basket)?.is_primary)) - )); - elements.basketEval.innerHTML = `
      ${ordered.map((item) => { - const basket = basketByName.get(item.basket); - const displayName = basket?.display_name || item.basket; - const isPrimary = Boolean(basket?.is_primary); - const progressDays = Number(item.progress_days || 0); - const minimumDays = Math.max(1, Number(item.min_trading_days || 60)); - const progress = Math.min(100, Math.round((progressDays / minimumDays) * 100)); - const coverage = item.snapshot_coverage == null ? null : Math.round(Number(item.snapshot_coverage) * 100); - const [copy, tone] = verdictCopy[item.verdict] || ["관찰 중", "muted"]; - const issues = (item.issues || []).slice(0, 3); - return `
    1. -
      ${escapeHtml(displayName)}${isPrimary ? '주력' : ""}${copy}
      -
      -
      ${progressDays}/${minimumDays} 영업일${coverage == null ? "기록 누락 확인 중" : (coverage >= 100 ? "기록 누락 없음" : `기록 누락 ${Math.max(0, 100 - coverage)}%`)}
      - ${issues.length ? `
        ${issues.map((issue) => `
      • ${escapeHtml(issue)}
      • `).join("")}
      ` : ""} -
    2. `; - }).join("")}
    `; - renderChartMaturity(); - renderDecision(); -} - -function trustCard(label, value, stateName = "info", support = "") { - return `
    ${escapeHtml(label)}${value}${support ? `${escapeHtml(support)}` : ""}
    `; -} - -function marketCopy(regime) { - const value = String(regime || "").toLowerCase(); - if (value === "bullish") return ["안정", "ok", "신규 매수 허용 구간"]; - if (value === "bearish") return ["방어", "warning", "신규 매수 제한 가능"]; - if (value === "caution") return ["주의", "warning", "포지션 규모 축소 구간"]; - return ["확인 불가", "warning", "시장 상태 데이터 없음"]; -} - -function strategyCopy(strategy) { - const value = String(strategy || "").toLowerCase(); - if (value === "scoring") return "종합 점수형"; - if (value === "basket_rebalance") return "포트폴리오 리밸런싱"; - return strategy || "—"; -} - -function signalCopy(signal) { - const value = String(signal || "").toUpperCase(); - return ({ BUY: "매수", SELL: "매도", HOLD: "대기" })[value] || signal || "—"; -} - -function signalSourceCopy(source) { - const value = String(source || "").toLowerCase(); - return ({ pre_market: "장 시작 전", intraday: "장중", post_market: "장 마감 후" })[value] || source || "—"; -} - -function formatAgeMinutes(minutes) { - if (minutes == null) return "기록 없음"; - if (minutes < 60) return `${minutes}분 전`; - if (minutes < 1_440) return `${Math.round(minutes / 60)}시간 전`; - return `${Math.round(minutes / 1_440)}일 전`; -} - -function renderRuntime(runtime) { - state.runtime = runtime || null; - state.runtimeStatus = runtime && runtime.trading_halt ? "ready" : "error"; - updateSyncIndicator(); - elements.runtimeOps.setAttribute("aria-busy", "false"); - if (!runtime) { - elements.haltGuidance.hidden = true; - elements.runtimeOps.innerHTML = [ - trustCard("거래 안전 상태", "확인 불가", "warning", "거래 중지 상태를 읽지 못함"), - trustCard("시장 환경", "확인 불가", "warning", "최근 데이터 없음"), - trustCard("자동 운용", "확인 불가", "warning", "스케줄러 상태 없음"), - trustCard("증권사 연결", "확인 불가", "warning", "요청 통계 없음"), - trustCard("데이터 기준", "확인 불가", "warning", "잠시 후 다시 확인"), - ].join(""); - elements.runtimeMeta.textContent = "일부 운영 정보를 불러오지 못했습니다. 자산 장부는 변경되지 않았습니다."; - renderSignals(null, null); - renderWsGap(null); + } + + /* ---------- 운영 ---------- */ + + function statusRowItem(label, value, detail, stateName) { + return `
    ${escapeHtml(label)}
    ${escapeHtml(value)}${detail ? `${escapeHtml(detail)}` : ""}
    `; + } + function marketCopy(regime) { + const v = String(regime || "").toLowerCase(); + if (v === "bullish") return ["안정", "신규 매수 가능", "ok"]; + if (v === "bearish") return ["방어", "신규 매수 제한", "warning"]; + if (v === "caution") return ["주의", "포지션 축소 구간", "warning"]; + return ["확인 불가", "시장 상태 데이터 없음", "warning"]; + } + const strategyCopy = (s) => ({ scoring: "종합 점수형", basket_rebalance: "바스켓 리밸런싱" })[String(s || "").toLowerCase()] || s || "—"; + const signalCopy = (s) => ({ BUY: "매수", SELL: "매도", HOLD: "보유" })[String(s || "").toUpperCase()] || s || "—"; + const signalSourceCopy = (s) => ({ pre_market: "장 시작 전", intraday: "장중", post_market: "장 마감 후" })[String(s || "").toLowerCase()] || s || "—"; + + function renderRuntime(runtime) { + state.runtime = runtime || null; + state.runtimeStatus = runtime && runtime.trading_halt ? "ready" : "error"; + updateSyncIndicator(); + el.runtimeOps.setAttribute("aria-busy", "false"); + if (!runtime) { + el.haltGuidance.hidden = true; + el.runtimeOps.innerHTML = [ + statusRowItem("거래 안전", "확인 불가", "거래 중지 상태를 읽지 못했습니다", "warning"), + statusRowItem("장 상태", "확인 불가", "최근 데이터 없음", "warning"), + statusRowItem("자동매매", "확인 불가", "스케줄러 기록 없음", "warning"), + statusRowItem("증권사 연결", "확인 불가", "요청 통계 없음", "warning"), + statusRowItem("데이터 갱신", "확인 불가", "잠시 후 다시 확인", "warning"), + ].join(""); + el.runtimeMeta.textContent = "운영 정보를 불러오지 못했습니다. 자산 장부는 바뀌지 않았습니다."; + el.statusRow.innerHTML = statusItem("거래", "확인 불가", "warning") + statusItem("장 상태", "확인 불가", "warning") + statusItem("자동매매", "확인 불가", "warning") + statusItem("데이터", "확인 불가", "warning"); + renderSignals(null, null); + renderWsGap(null); + renderDecision(); + return; + } + const halt = runtime.trading_halt; + const haltKnown = Boolean(halt && typeof halt.halted === "boolean"); + const halted = Boolean(halt && halt.halted); + const [market, marketSupport, marketState] = marketCopy(runtime.market_regime && runtime.market_regime.regime); + const loop = runtime.loop_metrics; + const kis = runtime.kis_stats; + const updatedAt = runtime.runtime_file_updated_at; + const age = runtimeAgeMinutes(); + const fresh = age != null && age <= 720; + const loopElapsed = loop && loop.recent_avg_elapsed_s != null ? `${Number(loop.recent_avg_elapsed_s).toFixed(1)}초` : null; + const haltValue = !haltKnown ? "확인 불가" : halted ? "거래 중지" : "운용 가능"; + const haltDetail = !haltKnown ? "거래 중지 상태를 읽지 못했습니다" : halted ? (halt.reason || "운영자 확인 필요") : "거래 중지 없음"; + const haltState = !haltKnown ? "warning" : halted ? "error" : "ok"; + // 자동매매 신선도는 런타임 파일이 아니라 스케줄러 루프의 마지막 성공 시각으로 본다. + // 수동 리밸런싱 점검만으로도 런타임 파일은 갱신되므로, 그걸 기준으로 삼으면 멈춘 루프가 정상으로 보인다. + const loopLast = parseDate(loop && loop.last_success) || (loop ? parseDate(updatedAt) : null); + const loopAge = loopLast ? Math.max(0, Math.round((Date.now() - loopLast.getTime()) / 60_000)) : null; + const loopFresh = loopAge != null && loopAge <= 720; + const autoValue = loopElapsed ? (loopFresh ? "정상" : `${formatAge(loopAge)} 실행`) : "기록 없음"; + const autoDetail = loopElapsed ? `마지막 실행 ${fmtDT(loopLast)} · 루프 ${loopElapsed}` : "스케줄러 기록 없음"; + const autoState = loop && loopFresh ? "ok" : "warning"; + const kisValue = kis && kis.minute_utilization_pct != null ? `${Number(kis.minute_utilization_pct).toFixed(1)}% 사용` : null; + const kisRow = state.mode === "paper" + ? statusRowItem("증권사 연결", "모의투자", "실계좌 연결 대상 아님", "info") + : statusRowItem("증권사 연결", kisValue ? (fresh ? kisValue : "기록 오래됨") : "기록 없음", kisValue ? `분당 요청 한도 기준${fresh ? "" : ` · ${formatAge(age)}`}` : "요청 통계 없음", kis && fresh ? "ok" : "warning"); + const dataState = age == null ? "warning" : age > 30 ? "warning" : "ok"; + + el.runtimeOps.innerHTML = + statusRowItem("거래 안전", haltValue, haltDetail, haltState) + + statusRowItem("장 상태", market, marketSupport, marketState) + + statusRowItem("자동매매", autoValue, autoDetail, autoState) + + kisRow + + statusRowItem("데이터 갱신", formatAge(age), updatedAt ? fmtDT(updatedAt) : "스케줄러 데이터 없음", dataState); + el.statusRow.innerHTML = + statusItem("거래", haltValue, haltState) + + statusItem("장 상태", market, marketState) + + statusItem("자동매매", autoValue, autoState) + + statusItem("데이터", formatAge(age), dataState); + + const meta = []; + if (runtime.strategy) meta.push(`전략 ${strategyCopy(runtime.strategy)}`); + if (updatedAt) meta.push(`스케줄러 기록 ${fmtDT(updatedAt)}`); + el.runtimeMeta.textContent = meta.join(" · "); + el.haltGuidance.hidden = !halted; + if (halted) $("haltGuidanceReason").textContent = halt.reason || "신규 매수는 직접 해제하기 전까지 막혀 있습니다."; + renderSignals(runtime.signals_today, runtime.signals_date); + renderWsGap(runtime); renderDecision(); - return; - } - - const halt = runtime.trading_halt; - const haltKnown = Boolean(halt && typeof halt.halted === "boolean"); - const halted = Boolean(halt && halt.halted); - const [market, marketState, marketSupport] = marketCopy(runtime.market_regime && runtime.market_regime.regime); - const loop = runtime.loop_metrics; - const kis = runtime.kis_stats; - const updatedAt = runtime.runtime_file_updated_at; - const ageMinutes = updatedAt ? Math.max(0, Math.round((Date.now() - (parseDate(updatedAt)?.getTime() || Date.now())) / 60_000)) : null; - const freshnessState = ageMinutes == null ? "warning" : (ageMinutes > 30 ? "warning" : "ok"); - const runtimeIsFresh = ageMinutes != null && ageMinutes <= 720; - const loopElapsed = loop && loop.recent_avg_elapsed_s != null - ? `${Number(loop.recent_avg_elapsed_s).toFixed(1)}초` - : null; - const loopValue = loopElapsed - ? (runtimeIsFresh ? loopElapsed : "기록 오래됨") - : "기록 없음"; - const loopSupport = loopElapsed - ? (runtimeIsFresh ? "최근 루프 평균" : `최근 루프 ${loopElapsed} · ${formatAgeMinutes(ageMinutes)}`) - : "스케줄러 상태 없음"; - const kisValue = kis && kis.minute_utilization_pct != null - ? `${Number(kis.minute_utilization_pct).toFixed(1)}%` - : null; - const kisCard = state.mode === "paper" - ? trustCard("증권사 연결", "모의 운용", "info", "실계좌 연결 대상 아님") - : trustCard("증권사 연결", kisValue ? (runtimeIsFresh ? kisValue : "기록 오래됨") : "기록 없음", kis && runtimeIsFresh ? "info" : "warning", kisValue ? (runtimeIsFresh ? "분당 요청 한도 사용률" : `최근 사용률 ${kisValue} · ${formatAgeMinutes(ageMinutes)}`) : "요청 통계 없음"); - - elements.runtimeOps.innerHTML = - trustCard("거래 안전 상태", !haltKnown ? "확인 불가" : (halted ? "거래 중지" : "운용 가능"), !haltKnown ? "warning" : (halted ? "error" : "ok"), !haltKnown ? "거래 중지 상태를 읽지 못함" : (halted ? (halt.reason || "운영자 확인 필요") : "거래 중지 없음")) + - trustCard("시장 환경", market, marketState, marketSupport) + - trustCard("자동 운용", loopValue, loop && runtimeIsFresh ? "ok" : "warning", loopSupport) + - kisCard + - trustCard("데이터 기준", formatAgeMinutes(ageMinutes), freshnessState, updatedAt ? formatDate(updatedAt, dateTime) : "스케줄러 데이터 없음"); - - const meta = []; - if (runtime.strategy) meta.push(`운용 전략 ${strategyCopy(runtime.strategy)}`); - if (updatedAt) meta.push(`스케줄러 마지막 기록 ${formatDate(updatedAt, dateTime)}`); - elements.runtimeMeta.textContent = meta.join(" · "); - elements.haltGuidance.hidden = !halted; - if (halted) { - $("haltGuidanceReason").textContent = halt.reason || "신규 매수는 명시적으로 해제하기 전까지 차단됩니다."; - } - renderSignals(runtime.signals_today, runtime.signals_date); - renderWsGap(runtime); - renderDecision(); -} - -function renderSignals(signals, signalsDate) { - const table = $("signalsTableWrap"); - const empty = $("signalEmpty"); - const error = $("signalError"); - const count = $("signalCount"); - if (signals == null) { - table.hidden = true; - empty.hidden = true; - error.hidden = false; - count.textContent = "확인 불가"; - return; - } - const isToday = !signalsDate || signalsDate === localIsoDate(); - const rows = isToday && Array.isArray(signals) ? signals : []; - count.textContent = `${won.format(rows.length)}건`; - table.hidden = !rows.length; - empty.hidden = Boolean(rows.length); - error.hidden = true; - empty.textContent = isToday - ? "오늘 기록된 신호가 없습니다. 이상 상태가 아닙니다." - : `오늘 기록된 신호가 없습니다. 마지막 신호 기록은 ${formatDate(signalsDate)}입니다.`; - $("signalRows").innerHTML = rows.map((signal) => ` - - - ${escapeHtml(signal.symbol || "—")} - ${escapeHtml(signalCopy(signal.signal))} - ${Number.isFinite(Number(signal.score)) ? Number(signal.score).toFixed(2) : "—"} - ${escapeHtml(signalSourceCopy(signal.source))} + } + + function renderSignals(signals, signalsDate) { + const table = $("signalsTableWrap"); + const empty = $("signalEmpty"); + const error = $("signalError"); + const count = $("signalCount"); + if (signals == null) { + table.hidden = true; empty.hidden = true; error.hidden = false; count.textContent = "확인 불가"; + return; + } + const isToday = !signalsDate || signalsDate === localIsoDate(); + const rows = isToday && Array.isArray(signals) ? signals : []; + count.textContent = `${rows.length}건`; + table.hidden = !rows.length; + empty.hidden = Boolean(rows.length); + error.hidden = true; + empty.textContent = isToday ? "오늘 생성된 신호가 없습니다. 이상 상태는 아닙니다." : `오늘 생성된 신호가 없습니다. 마지막 신호는 ${fmtLong(signalsDate)}입니다.`; + $("signalRows").innerHTML = rows.map((s) => ` + + ${escapeHtml(s.symbol || "—")} + ${escapeHtml(signalCopy(s.signal))} + ${Number.isFinite(Number(s.score)) ? Number(s.score).toFixed(2) : "—"} + ${escapeHtml(signalSourceCopy(s.source))} `).join(""); -} - -function renderWsGap(runtime) { - const gap = runtime && runtime.ws_gap; - const summary = $("wsGapSummary"); - const table = $("wsGapTableWrap"); - const empty = $("wsGapEmpty"); - const unavailable = $("wsGapNA"); - if (!gap || !gap.available) { - summary.innerHTML = trustCard("웹소켓", "정보 없음", "warning", "스케줄러 기록 대기"); - table.hidden = true; - empty.hidden = true; - unavailable.hidden = false; - return; - } - - const gaps = gap.recent_gaps || []; - unavailable.hidden = true; - summary.innerHTML = - trustCard("웹소켓 상태", gap.is_connected ? "연결됨" : "연결 끊김", gap.is_connected ? "ok" : "error") + - trustCard("최근 공백", `${won.format(Number(gap.total_gap_count || 0))}건`, gap.total_gap_count > 0 ? "warning" : "ok"); - table.hidden = !gaps.length; - empty.hidden = Boolean(gaps.length); - $("wsGapRows").innerHTML = [...gaps].reverse().map((item) => ` - - ${escapeHtml(formatDate(item.disconnect_at, dateTime))} - ${escapeHtml(formatDate(item.reconnect_at, dateTime))} - ${escapeHtml(`${Number(item.gap_seconds || 0).toFixed(1)}초`)} - ${escapeHtml((item.affected_symbols || []).join(", ") || "—")} - ${item.rest_backfill_performed ? `${won.format(Number(item.rest_backfill_count || 0))}건` : "미수행"} - ${item.blackswan_cooldown_triggered ? '안전 정지' : (item.blackswan_checked ? "정상" : "—")} + } + + function renderWsGap(runtime) { + const gap = runtime && runtime.ws_gap; + const summary = $("wsGapSummary"); + const table = $("wsGapTableWrap"); + const empty = $("wsGapEmpty"); + const na = $("wsGapNA"); + if (!gap || !gap.available) { + summary.innerHTML = statusRowItem("웹소켓", "정보 없음", "스케줄러 기록 대기", "warning"); + table.hidden = true; empty.hidden = true; na.hidden = false; + return; + } + const gaps = gap.recent_gaps || []; + na.hidden = true; + summary.innerHTML = statusRowItem("웹소켓", gap.is_connected ? "연결됨" : "연결 끊김", "", gap.is_connected ? "ok" : "warning") + + statusRowItem("최근 끊김", `${nf0.format(Number(gap.total_gap_count || 0))}건`, "", gap.total_gap_count > 0 ? "warning" : "ok"); + table.hidden = !gaps.length; + empty.hidden = Boolean(gaps.length); + $("wsGapRows").innerHTML = [...gaps].reverse().map((g) => ` + ${escapeHtml(fmtDT(g.disconnect_at))} + ${escapeHtml(fmtDT(g.reconnect_at))} + ${escapeHtml(`${Number(g.gap_seconds || 0).toFixed(1)}초`)} + ${escapeHtml((g.affected_symbols || []).join(", ") || "—")} + ${g.rest_backfill_performed ? `${nf0.format(Number(g.rest_backfill_count || 0))}건` : "안 함"} + ${g.blackswan_cooldown_triggered ? '안전 정지' : g.blackswan_checked ? "정상" : "—"} `).join(""); -} - -function renderLegacy(portfolio) { - state.legacy = portfolio || null; - if (!portfolio) { - $("summary").innerHTML = trustCard("레거시 계정", "확인 불가", "warning"); - $("positionsWrap").hidden = true; - $("noPositions").hidden = false; - return; - } - $("summary").innerHTML = - trustCard("총 평가금", formatWon(portfolio.total_value), "info") + - trustCard("총 수익률", formatPercent(portfolio.total_return), Number(portfolio.total_return) >= 0 ? "ok" : "error") + - trustCard("현금", formatWon(portfolio.cash), "info") + - trustCard("실현 손익", formatWon(portfolio.realized_pnl), Number(portfolio.realized_pnl) >= 0 ? "ok" : "error") + - trustCard("최대 낙폭", formatPercent(-Math.abs(Number(portfolio.mdd || 0)), { sign: false }), "warning") + - trustCard("보유 종목", `${won.format(Number(portfolio.position_count || 0))}개`, "info"); - - const positions = portfolio.positions || []; - $("positionsWrap").hidden = !positions.length; - $("noPositions").hidden = Boolean(positions.length); - $("positions").innerHTML = positions.map((position) => ` - - ${escapeHtml(position.symbol || "—")} - ${won.format(Number(position.quantity || 0))} - ${escapeHtml(formatWon(position.avg_price))} - ${escapeHtml(formatWon(position.current_price))} - ${escapeHtml(formatWon(position.current_value))} - ${escapeHtml(formatPercent(position.pnl_rate))} + } + + function renderLegacy(portfolio) { + state.legacy = portfolio || null; + if (!portfolio) { + $("summary").innerHTML = statusRowItem("레거시 계정", "확인 불가", "", "warning"); + $("positionsWrap").hidden = true; + $("noPositions").hidden = false; + return; + } + $("summary").innerHTML = + statusRowItem("총자산", won(portfolio.total_value), "", "info") + + statusRowItem("수익률", pct(portfolio.total_return), "", "info") + + statusRowItem("현금", won(portfolio.cash), "", "info") + + statusRowItem("실현손익", signedWon(portfolio.realized_pnl), "", "info") + + statusRowItem("최대낙폭", pct(-Math.abs(Number(portfolio.mdd || 0))), "", "info") + + statusRowItem("보유 종목", `${nf0.format(Number(portfolio.position_count || 0))}개`, "", "info"); + const positions = portfolio.positions || []; + $("positionsWrap").hidden = !positions.length; + $("noPositions").hidden = Boolean(positions.length); + $("positions").innerHTML = positions.map((p) => ` + ${escapeHtml(p.symbol || "—")} + ${nf0.format(Number(p.quantity || 0))} + ${won(p.avg_price)} + ${won(p.current_price)} + ${won(p.current_value)} + ${pct(p.pnl_rate)} `).join(""); -} - -function latestSnapshotDate() { - const basket = primaryBasket() || sortedBaskets().find((item) => item.snapshot); - return basket && basket.snapshot ? basket.snapshot.date : null; -} - -function currentMonthContributionState(basket) { - if (!basket || !basket.contribution_plan?.enabled) return "not-planned"; - if (state.flowStatus.get(basket.basket) !== "ready") return "unknown"; - const now = new Date(); - const recorded = (state.flows.get(basket.basket) || []).some((flow) => { - const when = parseDate(flow.occurred_at); - return when && when.getFullYear() === now.getFullYear() && when.getMonth() === now.getMonth(); - }); - return recorded ? "recorded" : "empty"; -} - -function runtimeAgeMinutes() { - const updatedAt = state.runtime && state.runtime.runtime_file_updated_at; - const parsed = parseDate(updatedAt); - return parsed ? Math.max(0, Math.round((Date.now() - parsed.getTime()) / 60_000)) : null; -} - -function setDecision({ title, description, meta = "", action = null, actionLabel = "확인하기" }) { - elements.decisionTitle.textContent = title; - elements.decisionDescription.textContent = description; - elements.decisionMeta.innerHTML = meta; - elements.decisionAction.hidden = !action; - elements.decisionAction.dataset.action = action || ""; - elements.decisionAction.textContent = actionLabel; -} - -function renderDecision() { - const baskets = state.baskets; - const halt = state.runtime && state.runtime.trading_halt; - const latest = latestSnapshotDate(); - const ageDays = calendarAgeDays(latest); - const issues = (state.evaluations || []).flatMap((item) => item.issues || []); - const primary = primaryBasket(); - const contributionState = currentMonthContributionState(primary); - const modeCopy = state.mode === "live" ? "실전 운용" : (state.mode === "paper" ? "모의 운용" : "모드 확인 불가"); - const meta = `${escapeHtml(modeCopy)}${latest ? ` · 최근 자산 기록 ${escapeHtml(formatDate(latest))}` : " · 자산 기록 없음"}`; - - if (halt && halt.halted) { - setDecision({ - title: "거래가 안전하게 중지되어 있습니다", - description: halt.reason || "체결 또는 장부 상태를 확인하기 전까지 신규 주문을 막고 있습니다.", - meta, - action: "operations", - actionLabel: "운용 상태 보기", - }); - return; - } - if (state.coreError) { - setDecision({ - title: "자산 데이터를 확인할 수 없습니다", - description: "이전 화면을 최신 데이터로 표시하지 않았습니다. 연결을 확인한 뒤 다시 시도하세요.", - meta: state.lastCoreSuccess ? `마지막 성공 ${escapeHtml(dateTime.format(state.lastCoreSuccess))}` : "아직 성공한 갱신이 없습니다.", - action: "retry", - actionLabel: "지금 다시 확인", - }); - return; } - if (state.runtimeStatus === "loading") { - setDecision({ - title: "거래 안전 상태를 확인하고 있습니다", - description: "거래 중지 여부와 자동 운용 기록을 확인한 뒤 오늘의 판단을 표시합니다.", - meta, - }); - return; - } - if (state.runtimeStatus === "error") { - setDecision({ - title: "거래 안전 상태를 확인할 수 없습니다", - description: "거래 중지 여부가 확인되기 전에는 적립 기록이나 운용 판단을 진행하지 마세요.", - meta, - action: "retry", - actionLabel: "안전 상태 다시 확인", - }); - return; - } - if (baskets && !baskets.length) { - setDecision({ - title: "첫 모의 운용 포트폴리오를 연결하세요", - description: "안전 기본값을 유지한 채 포트폴리오를 활성화하고 첫 모의 운용 기록을 만들어야 합니다.", - meta: "설정 → 모의 운용 1회 → 첫 자산 기록 확인", - action: "portfolio", - actionLabel: "시작 순서 보기", - }); - return; - } - if (baskets && baskets.length && !latest) { - setDecision({ - title: "첫 모의 운용 기록을 기다리고 있습니다", - description: "모의 운용을 한 번 실행하면 원금, 자산, 운용 수익률을 분리해 볼 수 있습니다.", - meta, - action: "portfolio", - actionLabel: "포트폴리오 확인", - }); - return; - } - if (ageDays != null && ageDays > 4) { - setDecision({ - title: "자산 기록이 오래되었습니다", - description: `마지막 자산 기록이 ${ageDays}일 전입니다. 자동 운용이 중단됐을 수 있으니 상태를 확인하세요.`, - meta, - action: "operations", - actionLabel: "운용 상태 확인", - }); - return; - } - const runtimeAge = runtimeAgeMinutes(); - if (runtimeAge == null || runtimeAge > 720) { - setDecision({ - title: "자동 운용 기록이 오래되었습니다", - description: runtimeAge == null - ? "최근 스케줄러 기록이 없습니다. 오늘 사이클이 실행됐는지 먼저 확인하세요." - : `마지막 자동 운용 기록이 ${formatAgeMinutes(runtimeAge)}입니다. 적립보다 실행 상태를 먼저 확인하세요.`, - meta, - action: "operations", - actionLabel: "운용 기록 확인", + + /* ---------- 적립 기록 ---------- */ + + function updateDepositCopy() { + const d = $("depositDescription"); + if (!d) return; + d.textContent = state.mode === "live" + ? "실제 계좌에 입금이 끝난 뒤 같은 금액을 기록하세요. 주문은 나가지 않고 실전 성과 계산에만 반영됩니다." + : "입금은 수익이 아니므로 수익률 계산에서 분리됩니다. 실제 입금이 끝난 뒤 같은 금액을 기록하세요."; + } + function resetDepositForm() { + state.depositConfirming = false; + state.depositRequestId = null; + el.depositForm.reset(); + el.depositFields.hidden = false; + el.depositConfirm.hidden = true; + el.depositBack.hidden = true; + el.depositSubmit.textContent = "내용 확인"; + el.depositSubmit.disabled = false; + el.depositError.hidden = true; + el.depositError.textContent = ""; + document.querySelectorAll("[data-amount]").forEach((b) => b.setAttribute("aria-pressed", "false")); + } + function openDeposit() { + if (!canRecordDeposit()) { showToast("장부와 거래 상태를 먼저 다시 확인하세요.", "error"); return; } + resetDepositForm(); + const select = $("depBasket"); + select.innerHTML = sortedBaskets().map((b) => ``).join(""); + const primary = primaryBasket(); + if (primary) select.value = primary.basket; + updateDepositCopy(); + if (!el.depositDialog.open) el.depositDialog.showModal(); + window.setTimeout(() => select.focus(), 0); + } + function closeDeposit() { if (el.depositDialog.open) el.depositDialog.close(); } + function showDepositError(message, field = null) { + el.depositError.textContent = message; + el.depositError.hidden = false; + if (field) field.focus(); + } + function depositValues() { + return { basket: $("depBasket").value, amount: Number($("depAmount").value), note: $("depNote").value.trim() }; + } + function showDepositConfirmation(values) { + state.depositRequestId = window.crypto?.randomUUID ? window.crypto.randomUUID() : `deposit-${Date.now()}-${Math.random().toString(16).slice(2)}`; + const selected = $("depBasket").selectedOptions[0]; + $("confirmBasket").textContent = selected ? selected.textContent : values.basket; + $("confirmAmount").textContent = won(values.amount); + $("confirmMode").textContent = state.mode === "live" ? "실전 장부" : "모의투자 장부"; + el.depositFields.hidden = true; + el.depositConfirm.hidden = false; + el.depositBack.hidden = false; + el.depositSubmit.textContent = "기록하기"; + el.depositError.hidden = true; + state.depositConfirming = true; + el.depositBack.focus(); + } + function showDepositFields() { + state.depositConfirming = false; + state.depositRequestId = null; + el.depositFields.hidden = false; + el.depositConfirm.hidden = true; + el.depositBack.hidden = true; + el.depositSubmit.textContent = "내용 확인"; + $("depBasket").focus(); + } + async function submitDeposit(values) { + el.depositSubmit.disabled = true; + el.depositSubmit.textContent = "기록하는 중"; + el.depositError.hidden = true; + try { + const data = await fetchJson("/api/deposit", { + timeout: 15_000, key: "deposit", + options: { method: "POST", headers: { "Content-Type": "application/json", "X-Requested-With": "quant-dashboard", "Idempotency-Key": state.depositRequestId }, body: JSON.stringify(values) }, + }); + if (!data || !data.ok) throw new Error((data && data.error) || "기록에 실패했습니다."); + closeDeposit(); + showToast(`${state.mode === "live" ? "실전 입금" : "모의투자 적립"} ${won(data.amount)}을 기록했습니다.`); + await refreshCore(); + } catch (error) { + showDepositError(`기록하지 못했습니다. ${error.message || "연결을 확인한 뒤 다시 시도하세요."}`); + el.depositSubmit.disabled = false; + el.depositSubmit.textContent = "기록하기"; + } + } + + let toastTimer = null; + function showToast(message, kind = "ok") { + const toast = $("toast"); + window.clearTimeout(toastTimer); + toast.textContent = message; + toast.dataset.kind = kind; + toast.classList.add("show"); + toastTimer = window.setTimeout(() => toast.classList.remove("show"), 5_000); + } + + /* ---------- 갱신 주기 ---------- */ + + // 주기 갱신은 숨은 탭에서 쉬지만, 첫 로드는 탭이 뒤에 있어도 반드시 한 번 채운다 + // (백그라운드로 연 탭이 빈 화면으로 남던 문제). + async function refreshCore(force = false) { + if (!force && document.visibilityState === "hidden") return; + state.coreStatus = "loading"; + updateSyncIndicator(); + const basketTask = fetchJson("/api/baskets", { timeout: 12_000, key: "baskets" }) + .then(async (data) => { + renderBasketTracks(data); + if (!boot.finished) bootStep(); + await Promise.allSettled([refreshFlows(), refreshSeries()]); + if (!boot.finished) { bootStep(); bootStep(); } + renderBasketTracks({ baskets: state.baskets, mode: state.mode, timestamp: state.lastCoreSuccess }); + await refreshChart(); + renderDecision(); + return data; + }); + const legacyTask = fetchJson("/api/portfolio", { timeout: 15_000, key: "legacy" }).then(renderLegacy); + const results = await Promise.allSettled([basketTask, legacyTask]); + if (results[0].status === "rejected") { + state.coreError = results[0].reason || new Error("바스켓 조회 실패"); + state.coreStatus = "error"; + el.basketTracks.setAttribute("aria-busy", "false"); + el.basketTracks.innerHTML = '
    포트폴리오를 불러오지 못했습니다.이전 화면의 숫자는 최신이 아닐 수 있습니다.
    '; + finishBoot(); + } else if (results.some((r) => r.status === "rejected") || state.flowError) { + state.coreStatus = "partial"; + } else { + state.coreStatus = "ready"; + } + updateSyncIndicator(); + renderDecision(); + } + + async function refreshSlow(force = false) { + if (!force && document.visibilityState === "hidden") return; + if (!state.runtime) { state.runtimeStatus = "loading"; updateSyncIndicator(); } + const evalTask = fetchJson("/api/basket_evaluation", { timeout: 30_000, key: "evaluation" }) + .then((data) => renderEvaluations((data && data.evaluations) || [])) + .catch(() => { + el.basketEval.setAttribute("aria-busy", "false"); + el.basketEval.innerHTML = '

    검증 상태를 불러오지 못했습니다. 잠시 후 다시 확인하세요.

    '; + }); + const runtimeTask = fetchJson("/api/runtime", { timeout: 30_000, key: "runtime" }).then(renderRuntime).catch(() => renderRuntime(null)); + await Promise.allSettled([evalTask, runtimeTask]); + } + + async function refreshAll(force = false) { + await Promise.allSettled([refreshCore(force), refreshSlow(force)]); + } + + /* ---------- 이벤트 ---------- */ + + function initScrollSpy() { + if (!("IntersectionObserver" in window)) return; + const links = [...document.querySelectorAll(".nav > a")]; + const targets = links.map((a) => document.querySelector(a.getAttribute("href"))).filter(Boolean); + const active = new Set(); + const spy = new IntersectionObserver((entries) => { + entries.forEach((entry) => { if (entry.isIntersecting) active.add(entry.target.id); else active.delete(entry.target.id); }); + const current = targets.map((t) => t.id).filter((id) => active.has(id)).at(-1) || null; + links.forEach((a) => { + if (current && a.getAttribute("href") === `#${current}`) a.setAttribute("aria-current", "true"); + else a.removeAttribute("aria-current"); + }); + }, { rootMargin: "-30% 0px -55% 0px", threshold: 0 }); + targets.forEach((t) => spy.observe(t)); + } + + function wireEvents() { + el.openDeposit.addEventListener("click", openDeposit); + $("closeDepositButton").addEventListener("click", closeDeposit); + $("depositCancelButton").addEventListener("click", closeDeposit); + el.depositBack.addEventListener("click", showDepositFields); + $("retryButton").addEventListener("click", () => refreshAll(true)); + + el.decisionAction.addEventListener("click", () => { + const action = el.decisionAction.dataset.action; + if (action === "deposit") openDeposit(); + else if (action === "retry") refreshAll(); + else if (action) document.getElementById(action)?.scrollIntoView({ behavior: reducedMotion.matches ? "auto" : "smooth", block: "start" }); }); - return; - } - if (issues.length) { - setDecision({ - title: "확인할 운영 항목이 있습니다", - description: `${issues.length}개 항목을 검토해야 합니다. 실전 전환은 계속 잠긴 상태입니다.`, - meta, - action: "review", - actionLabel: "검토 항목 보기", + + el.chartAccount.addEventListener("change", () => { state.chartAccount = el.chartAccount.value; refreshChart(); }); + $("chartRange").addEventListener("click", (event) => { + const button = event.target.closest("button[data-days]"); + if (!button) return; + state.chartDays = Number(button.dataset.days); + document.querySelectorAll("#chartRange button").forEach((b) => b.setAttribute("aria-pressed", String(b === button))); + refreshChart(); }); - return; - } - if (contributionState === "unknown") { - setDecision({ - title: "적립 기록을 확인할 수 없습니다", - description: "조회 상태가 확인되기 전에는 같은 적립금을 다시 기록하지 마세요.", - meta, - action: "retry", - actionLabel: "적립 기록 다시 확인", + el.chartEquity.addEventListener("pointermove", handleChartPointer); + el.chartEquity.addEventListener("pointerdown", handleChartPointer); + el.chartEquity.addEventListener("pointerleave", hideChartTip); + + document.querySelectorAll("[data-amount]").forEach((button) => { + button.addEventListener("click", () => { + $("depAmount").value = button.dataset.amount; + document.querySelectorAll("[data-amount]").forEach((b) => b.setAttribute("aria-pressed", String(b === button))); + $("depAmount").focus(); + }); }); - return; - } - if (contributionState === "empty") { - const plannedAmount = Number(primary.contribution_plan?.amount || 0); - setDecision({ - title: "이번 달 실제 입금 여부를 확인하세요", - description: `${plannedAmount > 0 ? `운용 기준은 월 ${formatWon(plannedAmount)}입니다. ` : ""}실제 입금 또는 모의 적립이 완료된 경우에만 장부에 기록하세요.`, - meta, - action: "deposit", - actionLabel: "적립금 기록", + + el.depositForm.addEventListener("submit", async (event) => { + event.preventDefault(); + if (!canRecordDeposit()) { showDepositError("장부와 거래 상태를 최신으로 확인한 뒤 다시 시도하세요."); updateDepositAvailability(); return; } + const values = depositValues(); + if (!state.depositConfirming) { + if (!values.basket) { showDepositError("계좌를 선택하세요.", $("depBasket")); return; } + if (!Number.isFinite(values.amount) || values.amount <= 0) { showDepositError("0원보다 큰 금액을 입력하세요.", $("depAmount")); return; } + showDepositConfirmation(values); + return; + } + await submitDeposit(values); }); - return; - } - setDecision({ - title: "오늘은 할 일이 없습니다", - description: "계획을 유지하며 기록을 더 쌓는 중입니다. 매일 시세를 확인하거나 전략을 바꿀 필요가 없습니다.", - meta, - }); -} - -function updateDepositCopy() { - const description = $("depositDescription"); - if (!description) return; - description.textContent = state.mode === "live" - ? "실제 계좌에 입금이 완료된 뒤 같은 금액을 장부에 기록하세요. 주문은 실행되지 않지만 실전 성과 계산에 반영됩니다." - : "모의 적립금은 수익이 아니므로 입출금 제외 수익률 계산에서 분리됩니다. 기록 전 포트폴리오와 금액을 다시 살펴보세요."; -} - -function resetDepositForm() { - state.depositConfirming = false; - state.depositRequestId = null; - elements.depositForm.reset(); - elements.depositFields.hidden = false; - elements.depositConfirm.hidden = true; - elements.depositBack.hidden = true; - elements.depositSubmit.textContent = "내용 확인"; - elements.depositSubmit.disabled = false; - elements.depositError.hidden = true; - elements.depositError.textContent = ""; - document.querySelectorAll("[data-amount]").forEach((button) => button.setAttribute("aria-pressed", "false")); -} - -function openDeposit() { - if (!canRecordDeposit()) { - showToast("장부와 거래 안전 상태를 먼저 다시 확인하세요.", "error"); - return; - } - resetDepositForm(); - const select = $("depBasket"); - const baskets = sortedBaskets(); - select.innerHTML = baskets.map((basket) => ``).join(""); - const primary = primaryBasket(); - if (primary) select.value = primary.basket; - updateDepositCopy(); - if (!elements.depositDialog.open) elements.depositDialog.showModal(); - window.setTimeout(() => select.focus(), 0); -} - -function closeDeposit() { - if (elements.depositDialog.open) elements.depositDialog.close(); -} - -function showDepositError(message, field = null) { - elements.depositError.textContent = message; - elements.depositError.hidden = false; - if (field) field.focus(); -} - -function depositValues() { - const basket = $("depBasket").value; - const amount = Number($("depAmount").value); - const note = $("depNote").value.trim(); - return { basket, amount, note }; -} - -function showDepositConfirmation(values) { - state.depositRequestId = window.crypto?.randomUUID - ? window.crypto.randomUUID() - : `deposit-${Date.now()}-${Math.random().toString(16).slice(2)}`; - const selected = $("depBasket").selectedOptions[0]; - $("confirmBasket").textContent = selected ? selected.textContent : values.basket; - $("confirmAmount").textContent = formatWon(values.amount); - $("confirmMode").textContent = state.mode === "live" ? "실전 장부" : "모의 장부"; - elements.depositFields.hidden = true; - elements.depositConfirm.hidden = false; - elements.depositBack.hidden = false; - elements.depositSubmit.textContent = state.mode === "live" ? "실전 입금 기록" : "모의 적립금 기록"; - elements.depositError.hidden = true; - state.depositConfirming = true; - elements.depositBack.focus(); -} - -function showDepositFields() { - state.depositConfirming = false; - state.depositRequestId = null; - elements.depositFields.hidden = false; - elements.depositConfirm.hidden = true; - elements.depositBack.hidden = true; - elements.depositSubmit.textContent = "내용 확인"; - $("depBasket").focus(); -} - -async function submitDeposit(values) { - elements.depositSubmit.disabled = true; - elements.depositSubmit.textContent = "기록하는 중…"; - elements.depositError.hidden = true; - try { - const data = await fetchJson("/api/deposit", { - timeout: 15_000, - key: "deposit", - options: { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Requested-With": "quant-dashboard", - "Idempotency-Key": state.depositRequestId, - }, - body: JSON.stringify(values), - }, + el.depositDialog.addEventListener("close", resetDepositForm); + el.depositDialog.addEventListener("click", (event) => { + if (event.target !== el.depositDialog) return; + const b = el.depositDialog.getBoundingClientRect(); + const inside = event.clientX >= b.left && event.clientX <= b.right && event.clientY >= b.top && event.clientY <= b.bottom; + if (!inside) closeDeposit(); }); - if (!data || !data.ok) throw new Error((data && data.error) || "기록에 실패했습니다."); - closeDeposit(); - showToast(`${state.mode === "live" ? "실전 입금" : "모의 적립금"} ${formatWon(data.amount)}을 기록했습니다.`); - await refreshCore(); - } catch (error) { - showDepositError(`기록하지 못했습니다. ${error.message || "연결을 확인한 뒤 다시 시도하세요."}`); - elements.depositSubmit.disabled = false; - elements.depositSubmit.textContent = state.mode === "live" ? "실전 입금 기록" : "모의 적립금 기록"; - } -} - -let toastTimer = null; -function showToast(message, kind = "ok") { - const toast = $("toast"); - window.clearTimeout(toastTimer); - toast.textContent = message; - toast.dataset.kind = kind; - toast.classList.add("show"); - toastTimer = window.setTimeout(() => toast.classList.remove("show"), 5_000); -} - -async function refreshCore() { - if (document.visibilityState === "hidden") return; - state.coreStatus = "loading"; - updateSyncIndicator(); - const basketTask = fetchJson("/api/baskets", { timeout: 12_000, key: "baskets" }) - .then(async (data) => { - renderBasketTracks(data); - await Promise.allSettled([refreshFlows(), refreshChart()]); - return data; + + window.addEventListener("online", () => refreshAll()); + window.addEventListener("offline", () => { + state.coreError = new Error("오프라인"); + state.coreStatus = "error"; + updateSyncIndicator(); + renderDecision(); }); - const legacyTask = fetchJson("/api/portfolio", { timeout: 15_000, key: "legacy" }) - .then(renderLegacy); - - const results = await Promise.allSettled([basketTask, legacyTask]); - const basketResult = results[0]; - if (basketResult.status === "rejected") { - state.coreError = basketResult.reason || new Error("바스켓 조회 실패"); - state.coreStatus = "error"; - elements.basketTracks.setAttribute("aria-busy", "false"); - elements.basketTracks.innerHTML = '
    포트폴리오를 불러오지 못했습니다. 이전 데이터를 최신으로 표시하지 않았습니다.
    '; - } else if (results.some((result) => result.status === "rejected") || state.flowError) { - state.coreStatus = "partial"; - } else { - state.coreStatus = "ready"; - } - updateSyncIndicator(); - renderDecision(); -} - -async function refreshSlow() { - if (document.visibilityState === "hidden") return; - if (!state.runtime) { - state.runtimeStatus = "loading"; - updateSyncIndicator(); + document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") refreshAll(); }); } - const evalTask = fetchJson("/api/basket_evaluation", { timeout: 30_000, key: "evaluation" }) - .then((data) => renderEvaluations((data && data.evaluations) || [])) - .catch(() => { - elements.basketEval.setAttribute("aria-busy", "false"); - elements.basketEval.innerHTML = '

    모의 운용 검증 상태를 불러오지 못했습니다. 잠시 후 다시 확인하세요.

    '; - }); - const runtimeTask = fetchJson("/api/runtime", { timeout: 30_000, key: "runtime" }) - .then(renderRuntime) - .catch(() => renderRuntime(null)); - await Promise.allSettled([evalTask, runtimeTask]); -} - -async function refreshAll() { - await Promise.allSettled([refreshCore(), refreshSlow()]); -} - -function wireEvents() { - elements.openDeposit.addEventListener("click", openDeposit); - $("closeDepositButton").addEventListener("click", closeDeposit); - $("depositCancelButton").addEventListener("click", closeDeposit); - elements.depositBack.addEventListener("click", showDepositFields); - $("retryButton").addEventListener("click", refreshAll); - - elements.decisionAction.addEventListener("click", () => { - const action = elements.decisionAction.dataset.action; - if (action === "deposit") openDeposit(); - else if (action === "retry") refreshAll(); - else if (action) document.getElementById(action)?.scrollIntoView({ behavior: "smooth", block: "start" }); - }); - - elements.chartAccount.addEventListener("change", () => { - state.chartAccount = elements.chartAccount.value; - refreshChart(); - }); - - $("chartRange").addEventListener("click", (event) => { - const button = event.target.closest("button[data-days]"); - if (!button) return; - state.chartDays = Number(button.dataset.days); - document.querySelectorAll("#chartRange button").forEach((item) => item.setAttribute("aria-pressed", String(item === button))); - refreshChart(); - }); - - document.querySelectorAll("[data-amount]").forEach((button) => { - button.addEventListener("click", () => { - $("depAmount").value = button.dataset.amount; - document.querySelectorAll("[data-amount]").forEach((item) => item.setAttribute("aria-pressed", String(item === button))); - $("depAmount").focus(); - }); - }); - elements.depositForm.addEventListener("submit", async (event) => { - event.preventDefault(); - if (!canRecordDeposit()) { - showDepositError("장부와 거래 안전 상태를 최신으로 확인한 뒤 다시 시도하세요."); - updateDepositAvailability(); - return; + async function main() { + wireEvents(); + initStory(); + initScrollSpy(); + if ("ResizeObserver" in window) { + new ResizeObserver(() => drawChart()).observe(el.chartBox); } - const values = depositValues(); - if (!state.depositConfirming) { - if (!values.basket) { - showDepositError("포트폴리오를 선택하세요.", $("depBasket")); - return; - } - if (!Number.isFinite(values.amount) || values.amount <= 0) { - showDepositError("0원보다 큰 금액을 입력하세요.", $("depAmount")); - return; - } - showDepositConfirmation(values); - return; - } - await submitDeposit(values); - }); - - elements.depositDialog.addEventListener("close", resetDepositForm); - elements.depositDialog.addEventListener("click", (event) => { - if (event.target !== elements.depositDialog) return; - const bounds = elements.depositDialog.getBoundingClientRect(); - const inside = event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom; - if (!inside) closeDeposit(); - }); - - window.addEventListener("online", refreshAll); - window.addEventListener("offline", () => { - state.coreError = new Error("오프라인"); - state.coreStatus = "error"; - updateSyncIndicator(); - renderDecision(); - }); - - document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible") refreshAll(); - }); -} - -async function boot() { - wireEvents(); - if ("ResizeObserver" in window) { - new ResizeObserver(() => drawChart(state.chartRows)).observe(elements.chartWrap); - } - document.querySelectorAll("#chartRange button").forEach((button) => { - button.setAttribute("aria-pressed", String(Number(button.dataset.days) === state.chartDays)); - }); - await refreshAll(); - window.setInterval(refreshCore, 30_000); - window.setInterval(refreshSlow, 60_000); -} - -boot(); + document.querySelectorAll("#chartRange button").forEach((b) => b.setAttribute("aria-pressed", String(Number(b.dataset.days) === state.chartDays))); + window.setTimeout(finishBoot, 8000); + await refreshAll(true); + finishBoot(); + window.setInterval(() => refreshCore(), 30_000); + window.setInterval(() => refreshSlow(), 60_000); + } + + main(); +})(); diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html index ba270063..2c635584 100644 --- a/monitoring/templates/dashboard.html +++ b/monitoring/templates/dashboard.html @@ -2,309 +2,279 @@ - - + + - 눈금 NUNGUM — 오래 투자하기 위한 기준과 기록 + 눈금 — 오래 투자하기 위한 기준과 기록 - + - + -
    - + + -
    -
    -
    -

    03 · OPERATIONS

    -

    운용 기록

    -

    자주 들여다보지 않아도 되도록 정상 여부와 예외만 간결하게 표시합니다.

    +
    +
    +

    모의투자 검증

    +

    기준: 60거래일 · 기록 누락 없음 · 운영 이슈 없음

    - -
    - -
    -

    +
    +

    검증 상태를 불러오는 중입니다.

    +
    +

    기록이 충분해도 실전 전환은 자동으로 되지 않습니다. 검토를 마친 뒤 직접 전환합니다.

    +
    - +
    +

    + + -
    - - - 시스템이 처리한 오늘의 기록 - 매매 신호와 자동 운용 이력 - - 0건 - -
    -
    - - - - -
    오늘 생성된 자동 운용 신호
    시각종목신호점수출처
    -
    - - + 오늘 생성된 신호 0건 +
    + + + + +
    오늘 생성된 자동매매 신호
    시각종목신호점수시점
    + +
    -
    - - - 고급 진단 - KIS 요청, 웹소켓, 레거시 계정 - - -
    -
    - - - +
    + 연결 상태와 레거시 계정 +
    + + + -

    레거시 기본 계정

    -
    -
    - - - - -
    레거시 기본 계정의 보유 종목
    종목수량평균가현재가평가액수익률
    -
    - +

    레거시 기본 계정

    +
    +
    + + + + +
    레거시 기본 계정의 보유 종목
    종목수량평단가현재가평가금액수익률
    +
    -
    - + -
    - -

    눈금 NUNGUM
    오래 투자하기 위한 기준과 기록.

    - -
    +
    +

    눈금 · 오래 투자하기 위한 기준과 기록

    +

    이 화면은 수익을 보장하지 않습니다. 실전 전환 전에 설정과 증권사 계좌를 직접 확인하세요.

    +
    +
    -
    -
    -

    CASH FLOW

    -

    적립금 기록

    -
    -
    -

    입금은 수익이 아니므로 입출금 제외 수익률 계산에서 분리됩니다. 기록 전 포트폴리오, 금액, 현재 모드를 다시 살펴보세요.

    +

    입금은 수익이 아니므로 수익률 계산에서 분리됩니다. 실제 입금이 끝난 뒤 같은 금액을 기록하세요.

    - +
    -
    - 실제 입금 또는 모의 적립금과 같은 금액을 입력하세요. -
    - 빠른 금액 선택 - - - -
    +
    +

    실제로 입금한 금액과 같게 적습니다.

    +
    + + + +
    - +
    -
    diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py index 2b9c48e4..1412ce07 100644 --- a/monitoring/web_dashboard.py +++ b/monitoring/web_dashboard.py @@ -113,6 +113,12 @@ def get_baskets_json() -> dict: """활성 바스켓별 원금·평가금·배치율·보유 현황을 DB에서만 읽는다.""" from core.basket_deploy import effective_stock_fraction from core.basket_rebalancer import BasketRebalancer, rebalance_live_strategy_id + from core.risk_overlays import ( + applied_stock_fraction, + describe_decision, + load_overlay_state, + parse_overlay_config, + ) from database.models import PortfolioSnapshot, get_session from database.repositories import get_all_positions, get_cash_flow_total @@ -190,6 +196,45 @@ def get_baskets_json() -> dict: "cadence": str(plan_config.get("cadence") or ""), "amount": float(plan_config.get("amount") or 0), } + base_fraction = effective_stock_fraction(basket_config, config.risk_params) + # 리스크 오버레이(추세 필터·낙폭 제어)가 켜진 바스켓은 마지막 실행이 남긴 배수를 + # 곱한 '적용 비중'이 그날의 목표다. 화면의 목표 비중·목표 범위 판정은 적용 비중을 쓴다. + overlay_cfg = parse_overlay_config(basket_config) + overlay_state = load_overlay_state(name) if overlay_cfg.any_enabled else None + design_fraction = ( + applied_stock_fraction(base_fraction, overlay_state) + if overlay_cfg.any_enabled + else base_fraction + ) + overlay = None + if overlay_cfg.any_enabled: + overlay = { + "enabled": True, + "scale": float((overlay_state or {}).get("scale", 1.0) or 1.0) if overlay_state else None, + "summary": describe_decision(overlay_state), + "reasons": list((overlay_state or {}).get("reasons") or []), + "data_issues": list((overlay_state or {}).get("data_issues") or []), + "evaluated_at": (overlay_state or {}).get("evaluated_at"), + "trend_filter": overlay_cfg.trend.enabled, + "drawdown_guard": overlay_cfg.drawdown.enabled, + "volatility_target": overlay_cfg.volatility.enabled, + } + # 종목별 목표 비중(총자산 대비) = 바스켓 내 비중 정규화 × 적용 투자 비중. + # 현재가는 장부에 저장하지 않으므로 화면은 매입금액 기준 비중과 나란히 보여준다. + holdings_cfg = basket_config.get("holdings") or {} + weight_total = sum(float(weight or 0) for weight in holdings_cfg.values()) + target_weights = ( + { + str(symbol): float(weight or 0) / weight_total * design_fraction + for symbol, weight in holdings_cfg.items() + } + if weight_total > 0 + else {} + ) + holdings_cost = float(sum(position["invested"] for position in positions)) + holdings_value = ( + snapshot["total_value"] - snapshot["cash"] if snapshot else None + ) out.append( { "basket": name, @@ -207,9 +252,12 @@ def get_baskets_json() -> dict: snapshot["total_value"] - principal if snapshot else None ), "deployment_ratio": deployment_ratio, - "design_fraction": effective_stock_fraction( - basket_config, config.risk_params - ), + "design_fraction": design_fraction, + "base_stock_fraction": base_fraction, + "overlay": overlay, + "target_weights": target_weights, + "holdings_cost": holdings_cost, + "holdings_value": holdings_value, "positions": positions, } ) diff --git a/reports/research/risk_overlay_backtest.json b/reports/research/risk_overlay_backtest.json new file mode 100644 index 00000000..bcf024e3 --- /dev/null +++ b/reports/research/risk_overlay_backtest.json @@ -0,0 +1,867 @@ +{ + "pocket_ks200": { + "symbol": "KS200", + "start": "2002-01-02", + "end": "2026-09-16", + "results": { + "static": { + "label": "고정 비중(현행)", + "years": 24.7, + "cagr_pct": 7.45, + "vol_pct": 12.15, + "sharpe": 0.42, + "mdd_pct": -28.75, + "calmar": 0.26, + "worst_year_pct": -18.67, + "losing_years": 7, + "total_years": 25, + "negative_months_pct": 40.5, + "avg_exposure_pct": 51.3, + "turnover_per_year_pct": 8.8, + "final_value": 91098365, + "contributed": 29900000, + "profit": 61198365, + "yearly": { + "2002": -4.11, + "2003": 16.82, + "2004": 6.62, + "2005": 26.76, + "2006": 3.86, + "2007": 16.37, + "2008": -18.67, + "2009": 27.33, + "2010": 12.61, + "2011": -4.7, + "2012": 7.0, + "2013": 1.66, + "2014": -2.43, + "2015": 0.83, + "2016": 5.52, + "2017": 13.81, + "2018": -8.75, + "2019": 7.67, + "2020": 19.48, + "2021": 2.13, + "2022": -12.44, + "2023": 12.87, + "2024": -4.49, + "2025": 43.18, + "2026": 39.27 + } + }, + "trend50": { + "label": "추세 필터 · 200일선 아래면 절반", + "years": 24.7, + "cagr_pct": 7.19, + "vol_pct": 10.14, + "sharpe": 0.45, + "mdd_pct": -21.8, + "calmar": 0.33, + "worst_year_pct": -12.27, + "losing_years": 7, + "total_years": 25, + "negative_months_pct": 40.9, + "avg_exposure_pct": 41.1, + "turnover_per_year_pct": 54.2, + "final_value": 88131441, + "contributed": 29900000, + "profit": 58231441, + "yearly": { + "2002": -2.48, + "2003": 16.92, + "2004": 4.39, + "2005": 26.74, + "2006": 1.3, + "2007": 16.36, + "2008": -12.27, + "2009": 21.45, + "2010": 11.1, + "2011": -3.14, + "2012": 0.7, + "2013": 0.59, + "2014": -2.59, + "2015": 0.36, + "2016": 5.08, + "2017": 13.71, + "2018": -4.4, + "2019": 4.49, + "2020": 17.53, + "2021": 3.51, + "2022": -4.95, + "2023": 7.02, + "2024": -5.42, + "2025": 39.81, + "2026": 40.97 + } + }, + "trend0": { + "label": "추세 필터 · 200일선 아래면 0", + "years": 24.7, + "cagr_pct": 6.79, + "vol_pct": 9.43, + "sharpe": 0.44, + "mdd_pct": -21.8, + "calmar": 0.31, + "worst_year_pct": -6.76, + "losing_years": 10, + "total_years": 25, + "negative_months_pct": 29.4, + "avg_exposure_pct": 31.4, + "turnover_per_year_pct": 101.7, + "final_value": 82972040, + "contributed": 29900000, + "profit": 53072040, + "yearly": { + "2002": -1.0, + "2003": 16.68, + "2004": 1.95, + "2005": 26.71, + "2006": -1.34, + "2007": 16.34, + "2008": -4.23, + "2009": 16.06, + "2010": 9.67, + "2011": -1.63, + "2012": -5.64, + "2013": -0.56, + "2014": -2.83, + "2015": -0.19, + "2016": 4.66, + "2017": 13.7, + "2018": -0.04, + "2019": 1.11, + "2020": 15.14, + "2021": 4.41, + "2022": 2.93, + "2023": 1.29, + "2024": -6.76, + "2025": 36.8, + "2026": 40.96 + } + }, + "vol15": { + "label": "변동성 목표 15%", + "years": 24.7, + "cagr_pct": 5.79, + "vol_pct": 8.18, + "sharpe": 0.38, + "mdd_pct": -17.82, + "calmar": 0.33, + "worst_year_pct": -12.33, + "losing_years": 6, + "total_years": 25, + "negative_months_pct": 40.5, + "avg_exposure_pct": 40.9, + "turnover_per_year_pct": 58.3, + "final_value": 64275336, + "contributed": 29900000, + "profit": 34375336, + "yearly": { + "2002": 5.39, + "2003": 10.83, + "2004": 4.96, + "2005": 22.86, + "2006": 2.74, + "2007": 15.53, + "2008": -12.33, + "2009": 16.14, + "2010": 12.03, + "2011": -3.1, + "2012": 3.89, + "2013": 1.22, + "2014": -2.38, + "2015": 0.6, + "2016": 5.27, + "2017": 13.71, + "2018": -8.42, + "2019": 7.28, + "2020": 11.83, + "2021": 1.72, + "2022": -11.03, + "2023": 10.83, + "2024": -3.21, + "2025": 27.35, + "2026": 21.2 + } + }, + "vol20": { + "label": "변동성 목표 20%", + "years": 24.7, + "cagr_pct": 6.32, + "vol_pct": 9.38, + "sharpe": 0.39, + "mdd_pct": -20.86, + "calmar": 0.3, + "worst_year_pct": -14.71, + "losing_years": 6, + "total_years": 25, + "negative_months_pct": 40.2, + "avg_exposure_pct": 46.1, + "turnover_per_year_pct": 30.2, + "final_value": 70835709, + "contributed": 29900000, + "profit": 40935709, + "yearly": { + "2002": 3.41, + "2003": 12.8, + "2004": 6.29, + "2005": 25.89, + "2006": 3.48, + "2007": 15.12, + "2008": -14.71, + "2009": 18.35, + "2010": 12.66, + "2011": -5.3, + "2012": 5.23, + "2013": 1.66, + "2014": -2.41, + "2015": 0.83, + "2016": 5.51, + "2017": 13.72, + "2018": -8.69, + "2019": 7.67, + "2020": 11.74, + "2021": 2.56, + "2022": -12.07, + "2023": 12.86, + "2024": -4.34, + "2025": 37.11, + "2026": 22.46 + } + }, + "dd10": { + "label": "낙폭 제어 · -10%에서 절반", + "years": 24.7, + "cagr_pct": 6.91, + "vol_pct": 10.69, + "sharpe": 0.41, + "mdd_pct": -23.77, + "calmar": 0.29, + "worst_year_pct": -14.64, + "losing_years": 7, + "total_years": 25, + "negative_months_pct": 39.9, + "avg_exposure_pct": 48.2, + "turnover_per_year_pct": 32.4, + "final_value": 80557524, + "contributed": 29900000, + "profit": 50657524, + "yearly": { + "2002": -4.11, + "2003": 16.82, + "2004": 6.62, + "2005": 26.76, + "2006": 3.86, + "2007": 16.37, + "2008": -14.64, + "2009": 23.61, + "2010": 12.58, + "2011": -5.14, + "2012": 6.99, + "2013": 1.66, + "2014": -2.43, + "2015": 0.83, + "2016": 5.52, + "2017": 13.81, + "2018": -8.76, + "2019": 7.67, + "2020": 12.69, + "2021": 2.59, + "2022": -9.97, + "2023": 9.22, + "2024": -4.24, + "2025": 39.54, + "2026": 31.8 + } + }, + "trend50_dd10": { + "label": "추세 절반 + 낙폭 제어", + "years": 24.7, + "cagr_pct": 6.66, + "vol_pct": 9.46, + "sharpe": 0.43, + "mdd_pct": -20.26, + "calmar": 0.33, + "worst_year_pct": -13.06, + "losing_years": 7, + "total_years": 25, + "negative_months_pct": 41.2, + "avg_exposure_pct": 40.6, + "turnover_per_year_pct": 68.2, + "final_value": 79158022, + "contributed": 29900000, + "profit": 49258022, + "yearly": { + "2002": -2.48, + "2003": 16.92, + "2004": 4.39, + "2005": 26.74, + "2006": 1.3, + "2007": 16.36, + "2008": -13.06, + "2009": 20.67, + "2010": 11.1, + "2011": -3.14, + "2012": 0.7, + "2013": 0.59, + "2014": -2.59, + "2015": 0.36, + "2016": 5.08, + "2017": 13.71, + "2018": -4.39, + "2019": 4.49, + "2020": 12.83, + "2021": 3.51, + "2022": -4.95, + "2023": 7.02, + "2024": -5.42, + "2025": 39.81, + "2026": 31.8 + } + }, + "trend50_vol20": { + "label": "추세 절반 + 변동성 20%", + "years": 24.7, + "cagr_pct": 6.22, + "vol_pct": 7.9, + "sharpe": 0.44, + "mdd_pct": -13.79, + "calmar": 0.45, + "worst_year_pct": -8.92, + "losing_years": 7, + "total_years": 25, + "negative_months_pct": 40.5, + "avg_exposure_pct": 37.4, + "turnover_per_year_pct": 68.9, + "final_value": 70273594, + "contributed": 29900000, + "profit": 40373594, + "yearly": { + "2002": 4.41, + "2003": 13.18, + "2004": 3.95, + "2005": 25.88, + "2006": 1.08, + "2007": 15.11, + "2008": -8.92, + "2009": 16.0, + "2010": 11.21, + "2011": -3.43, + "2012": -0.46, + "2013": 0.6, + "2014": -2.59, + "2015": 0.36, + "2016": 5.08, + "2017": 13.7, + "2018": -4.39, + "2019": 4.49, + "2020": 13.0, + "2021": 3.48, + "2022": -4.73, + "2023": 7.06, + "2024": -5.11, + "2025": 34.41, + "2026": 22.46 + } + } + } + }, + "pocket_etf": { + "symbol": "069500", + "start": "2014-06-30", + "end": "2026-09-17", + "results": { + "static": { + "label": "고정 비중(현행)", + "years": 12.22, + "cagr_pct": 9.62, + "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.9, + "turnover_per_year_pct": 16.6, + "final_value": 35522386, + "contributed": 15000000, + "profit": 20522386, + "yearly": { + "2014": -1.57, + "2015": 1.53, + "2016": 6.54, + "2017": 14.43, + "2018": -7.34, + "2019": 8.81, + "2020": 21.17, + "2021": 3.1, + "2022": -11.21, + "2023": 13.89, + "2024": -3.35, + "2025": 44.44, + "2026": 41.78 + } + }, + "trend50": { + "label": "추세 필터 · 200일선 아래면 절반", + "years": 12.22, + "cagr_pct": 9.32, + "vol_pct": 10.85, + "sharpe": 0.62, + "mdd_pct": -21.66, + "calmar": 0.43, + "worst_year_pct": -4.58, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 40.1, + "avg_exposure_pct": 39.5, + "turnover_per_year_pct": 57.4, + "final_value": 34630217, + "contributed": 15000000, + "profit": 19630217, + "yearly": { + "2014": -1.81, + "2015": 0.7, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49, + "2019": 5.34, + "2020": 18.43, + "2021": 3.91, + "2022": -4.28, + "2023": 7.87, + "2024": -4.58, + "2025": 40.76, + "2026": 42.62 + } + }, + "trend0": { + "label": "추세 필터 · 200일선 아래면 0", + "years": 12.22, + "cagr_pct": 8.86, + "vol_pct": 10.41, + "sharpe": 0.6, + "mdd_pct": -21.66, + "calmar": 0.41, + "worst_year_pct": -6.09, + "losing_years": 3, + "total_years": 13, + "negative_months_pct": 25.9, + "avg_exposure_pct": 28.1, + "turnover_per_year_pct": 100.8, + "final_value": 33369808, + "contributed": 15000000, + "profit": 18369808, + "yearly": { + "2014": -2.08, + "2015": -0.23, + "2016": 5.54, + "2017": 14.43, + "2018": 0.31, + "2019": 1.8, + "2020": 15.99, + "2021": 4.47, + "2022": 2.93, + "2023": 2.09, + "2024": -6.09, + "2025": 37.35, + "2026": 42.61 + } + }, + "vol15": { + "label": "변동성 목표 15%", + "years": 12.22, + "cagr_pct": 6.59, + "vol_pct": 8.15, + "sharpe": 0.48, + "mdd_pct": -15.65, + "calmar": 0.42, + "worst_year_pct": -9.97, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 40.8, + "avg_exposure_pct": 43.7, + "turnover_per_year_pct": 69.1, + "final_value": 26260789, + "contributed": 15000000, + "profit": 11260789, + "yearly": { + "2014": -1.57, + "2015": 1.53, + "2016": 6.43, + "2017": 14.43, + "2018": -7.17, + "2019": 8.27, + "2020": 13.44, + "2021": 2.53, + "2022": -9.97, + "2023": 11.56, + "2024": -3.26, + "2025": 29.63, + "2026": 21.71 + } + }, + "vol20": { + "label": "변동성 목표 20%", + "years": 12.22, + "cagr_pct": 7.44, + "vol_pct": 9.14, + "sharpe": 0.52, + "mdd_pct": -17.0, + "calmar": 0.44, + "worst_year_pct": -10.96, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 40.8, + "avg_exposure_pct": 47.6, + "turnover_per_year_pct": 36.0, + "final_value": 28779405, + "contributed": 15000000, + "profit": 13779405, + "yearly": { + "2014": -1.57, + "2015": 1.53, + "2016": 6.54, + "2017": 14.43, + "2018": -7.34, + "2019": 8.81, + "2020": 12.39, + "2021": 3.27, + "2022": -10.96, + "2023": 13.9, + "2024": -3.17, + "2025": 38.27, + "2026": 24.13 + } + }, + "dd10": { + "label": "낙폭 제어 · -10%에서 절반", + "years": 12.22, + "cagr_pct": 8.84, + "vol_pct": 10.68, + "sharpe": 0.59, + "mdd_pct": -17.29, + "calmar": 0.51, + "worst_year_pct": -11.2, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 40.8, + "avg_exposure_pct": 49.9, + "turnover_per_year_pct": 42.9, + "final_value": 33256706, + "contributed": 15000000, + "profit": 18256706, + "yearly": { + "2014": -1.57, + "2015": 1.53, + "2016": 6.54, + "2017": 14.43, + "2018": -7.34, + "2019": 8.81, + "2020": 14.98, + "2021": 3.26, + "2022": -11.2, + "2023": 13.89, + "2024": -3.34, + "2025": 44.4, + "2026": 36.63 + } + }, + "trend50_dd10": { + "label": "추세 절반 + 낙폭 제어", + "years": 12.22, + "cagr_pct": 8.76, + "vol_pct": 9.47, + "sharpe": 0.64, + "mdd_pct": -16.26, + "calmar": 0.54, + "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": 82.6, + "final_value": 32857861, + "contributed": 15000000, + "profit": 17857861, + "yearly": { + "2014": -1.81, + "2015": 0.7, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49, + "2019": 5.34, + "2020": 15.86, + "2021": 3.91, + "2022": -4.27, + "2023": 7.87, + "2024": -4.58, + "2025": 40.75, + "2026": 36.93 + } + }, + "trend50_vol20": { + "label": "추세 절반 + 변동성 20%", + "years": 12.22, + "cagr_pct": 7.45, + "vol_pct": 7.66, + "sharpe": 0.61, + "mdd_pct": -11.9, + "calmar": 0.63, + "worst_year_pct": -4.27, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 39.5, + "avg_exposure_pct": 36.5, + "turnover_per_year_pct": 69.0, + "final_value": 28620391, + "contributed": 15000000, + "profit": 13620391, + "yearly": { + "2014": -1.81, + "2015": 0.7, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49, + "2019": 5.34, + "2020": 13.93, + "2021": 3.88, + "2022": -4.09, + "2023": 7.93, + "2024": -4.27, + "2025": 35.44, + "2026": 24.13 + } + } + } + }, + "basket": { + "symbol": "EW10", + "symbols": [ + "005930", + "000660", + "035420", + "005380", + "051910", + "005490", + "055550", + "035720", + "012330", + "105560" + ], + "start": "2021-12-01", + "end": "2026-09-17", + "results": { + "static": { + "label": "고정 비중(현행)", + "years": 4.79, + "cagr_pct": 18.32, + "vol_pct": 19.98, + "sharpe": 0.82, + "mdd_pct": -26.06, + "calmar": 0.7, + "worst_year_pct": -13.48, + "losing_years": 2, + "total_years": 6, + "negative_months_pct": 40.4, + "avg_exposure_pct": 61.0, + "turnover_per_year_pct": 21.5, + "final_value": 22389785, + "contributed": 10000000, + "profit": 12389785, + "yearly": { + "2021": 0.84, + "2022": -13.48, + "2023": 19.75, + "2024": -2.25, + "2025": 49.54, + "2026": 46.64 + } + }, + "trend50": { + "label": "추세 필터 · 200일선 아래면 절반", + "years": 4.79, + "cagr_pct": 16.36, + "vol_pct": 19.1, + "sharpe": 0.76, + "mdd_pct": -24.85, + "calmar": 0.66, + "worst_year_pct": -12.61, + "losing_years": 2, + "total_years": 6, + "negative_months_pct": 42.1, + "avg_exposure_pct": 52.6, + "turnover_per_year_pct": 85.5, + "final_value": 20669841, + "contributed": 10000000, + "profit": 10669841, + "yearly": { + "2021": 0.84, + "2022": -12.61, + "2023": 11.52, + "2024": -6.32, + "2025": 49.02, + "2026": 50.69 + } + }, + "trend0": { + "label": "추세 필터 · 200일선 아래면 0", + "years": 4.79, + "cagr_pct": 13.92, + "vol_pct": 18.74, + "sharpe": 0.65, + "mdd_pct": -24.85, + "calmar": 0.56, + "worst_year_pct": -11.28, + "losing_years": 2, + "total_years": 6, + "negative_months_pct": 35.1, + "avg_exposure_pct": 44.7, + "turnover_per_year_pct": 150.4, + "final_value": 18676094, + "contributed": 10000000, + "profit": 8676094, + "yearly": { + "2021": 0.84, + "2022": -11.28, + "2023": 4.39, + "2024": -9.88, + "2025": 47.29, + "2026": 50.69 + } + }, + "vol15": { + "label": "변동성 목표 15%", + "years": 4.79, + "cagr_pct": 9.7, + "vol_pct": 11.91, + "sharpe": 0.61, + "mdd_pct": -15.14, + "calmar": 0.64, + "worst_year_pct": -11.83, + "losing_years": 2, + "total_years": 6, + "negative_months_pct": 42.1, + "avg_exposure_pct": 41.0, + "turnover_per_year_pct": 96.7, + "final_value": 15579191, + "contributed": 10000000, + "profit": 5579191, + "yearly": { + "2021": 0.84, + "2022": -11.83, + "2023": 15.12, + "2024": -3.35, + "2025": 26.76, + "2026": 24.28 + } + }, + "vol20": { + "label": "변동성 목표 20%", + "years": 4.79, + "cagr_pct": 12.4, + "vol_pct": 14.17, + "sharpe": 0.71, + "mdd_pct": -17.98, + "calmar": 0.69, + "worst_year_pct": -14.23, + "losing_years": 2, + "total_years": 6, + "negative_months_pct": 42.1, + "avg_exposure_pct": 51.1, + "turnover_per_year_pct": 100.1, + "final_value": 17511837, + "contributed": 10000000, + "profit": 7511837, + "yearly": { + "2021": 0.84, + "2022": -14.23, + "2023": 17.82, + "2024": -3.65, + "2025": 39.19, + "2026": 28.17 + } + }, + "dd10": { + "label": "낙폭 제어 · -10%에서 절반", + "years": 4.79, + "cagr_pct": 15.84, + "vol_pct": 15.71, + "sharpe": 0.85, + "mdd_pct": -16.41, + "calmar": 0.97, + "worst_year_pct": -10.62, + "losing_years": 2, + "total_years": 6, + "negative_months_pct": 38.6, + "avg_exposure_pct": 43.3, + "turnover_per_year_pct": 78.4, + "final_value": 20228356, + "contributed": 10000000, + "profit": 10228356, + "yearly": { + "2021": 0.84, + "2022": -10.62, + "2023": 5.87, + "2024": -3.2, + "2025": 43.9, + "2026": 52.22 + } + }, + "trend50_dd10": { + "label": "추세 절반 + 낙폭 제어", + "years": 4.79, + "cagr_pct": 15.5, + "vol_pct": 15.16, + "sharpe": 0.86, + "mdd_pct": -16.41, + "calmar": 0.95, + "worst_year_pct": -10.02, + "losing_years": 2, + "total_years": 6, + "negative_months_pct": 36.8, + "avg_exposure_pct": 37.7, + "turnover_per_year_pct": 99.5, + "final_value": 19951442, + "contributed": 10000000, + "profit": 9951442, + "yearly": { + "2021": 0.84, + "2022": -10.02, + "2023": 7.19, + "2024": -5.03, + "2025": 41.68, + "2026": 52.49 + } + }, + "trend50_vol20": { + "label": "추세 절반 + 변동성 20%", + "years": 4.79, + "cagr_pct": 10.59, + "vol_pct": 12.88, + "sharpe": 0.64, + "mdd_pct": -16.06, + "calmar": 0.66, + "worst_year_pct": -12.87, + "losing_years": 2, + "total_years": 6, + "negative_months_pct": 40.4, + "avg_exposure_pct": 44.0, + "turnover_per_year_pct": 138.9, + "final_value": 16195395, + "contributed": 10000000, + "profit": 6195395, + "yearly": { + "2021": 0.84, + "2022": -12.87, + "2023": 10.76, + "2024": -5.67, + "2025": 37.68, + "2026": 28.17 + } + } + } + } +} \ No newline at end of file diff --git a/reports/research/risk_overlay_backtest.md b/reports/research/risk_overlay_backtest.md new file mode 100644 index 00000000..c1601664 --- /dev/null +++ b/reports/research/risk_overlay_backtest.md @@ -0,0 +1,46 @@ +# 리스크 오버레이 백테스트 결과 + +### 적립 트랙 (KS200 지수, 2002~) + +- 데이터: KS200 2002-01-02 → 2026-09-16 + +| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 고정 비중(현행) | +7.45% | -28.8% | 0.42 | 0.26 | -18.7% | 7/25 | 51% | 9% | +| 추세 필터 · 200일선 아래면 절반 | +7.19% | -21.8% | 0.45 | 0.33 | -12.3% | 7/25 | 41% | 54% | +| 추세 필터 · 200일선 아래면 0 | +6.79% | -21.8% | 0.44 | 0.31 | -6.8% | 10/25 | 31% | 102% | +| 변동성 목표 15% | +5.79% | -17.8% | 0.38 | 0.33 | -12.3% | 6/25 | 41% | 58% | +| 변동성 목표 20% | +6.32% | -20.9% | 0.39 | 0.3 | -14.7% | 6/25 | 46% | 30% | +| 낙폭 제어 · -10%에서 절반 | +6.91% | -23.8% | 0.41 | 0.29 | -14.6% | 7/25 | 48% | 32% | +| 추세 절반 + 낙폭 제어 | +6.66% | -20.3% | 0.43 | 0.33 | -13.1% | 7/25 | 41% | 68% | +| 추세 절반 + 변동성 20% | +6.22% | -13.8% | 0.44 | 0.45 | -8.9% | 7/25 | 37% | 69% | + +### 적립 트랙 (KODEX 200 ETF, 2014~) + +- 데이터: 069500 2014-06-30 → 2026-09-17 + +| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 고정 비중(현행) | +9.62% | -21.7% | 0.59 | 0.44 | -11.2% | 4/13 | 51% | 17% | +| 추세 필터 · 200일선 아래면 절반 | +9.32% | -21.7% | 0.62 | 0.43 | -4.6% | 4/13 | 40% | 57% | +| 추세 필터 · 200일선 아래면 0 | +8.86% | -21.7% | 0.60 | 0.41 | -6.1% | 3/13 | 28% | 101% | +| 변동성 목표 15% | +6.59% | -15.7% | 0.48 | 0.42 | -10.0% | 4/13 | 44% | 69% | +| 변동성 목표 20% | +7.44% | -17.0% | 0.52 | 0.44 | -11.0% | 4/13 | 48% | 36% | +| 낙폭 제어 · -10%에서 절반 | +8.84% | -17.3% | 0.59 | 0.51 | -11.2% | 4/13 | 50% | 43% | +| 추세 절반 + 낙폭 제어 | +8.76% | -16.3% | 0.64 | 0.54 | -4.6% | 4/13 | 39% | 83% | +| 추세 절반 + 변동성 20% | +7.45% | -11.9% | 0.61 | 0.63 | -4.3% | 4/13 | 36% | 69% | + +### 관찰 트랙 (대형주 10종목, 2021-12~) + +- 데이터: EW10 2021-12-01 → 2026-09-17 + +| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 고정 비중(현행) | +18.32% | -26.1% | 0.82 | 0.7 | -13.5% | 2/6 | 61% | 22% | +| 추세 필터 · 200일선 아래면 절반 | +16.36% | -24.9% | 0.76 | 0.66 | -12.6% | 2/6 | 53% | 86% | +| 추세 필터 · 200일선 아래면 0 | +13.92% | -24.9% | 0.65 | 0.56 | -11.3% | 2/6 | 45% | 150% | +| 변동성 목표 15% | +9.70% | -15.1% | 0.61 | 0.64 | -11.8% | 2/6 | 41% | 97% | +| 변동성 목표 20% | +12.40% | -18.0% | 0.71 | 0.69 | -14.2% | 2/6 | 51% | 100% | +| 낙폭 제어 · -10%에서 절반 | +15.84% | -16.4% | 0.85 | 0.97 | -10.6% | 2/6 | 43% | 78% | +| 추세 절반 + 낙폭 제어 | +15.50% | -16.4% | 0.86 | 0.95 | -10.0% | 2/6 | 38% | 100% | +| 추세 절반 + 변동성 20% | +10.59% | -16.1% | 0.64 | 0.66 | -12.9% | 2/6 | 44% | 139% | diff --git a/tests/conftest.py b/tests/conftest.py index c65fc522..71bdbd39 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,11 @@ def pytest_configure(config): + # 리스크 오버레이 상태 파일(data/overlay_state/*.json)도 운영 파일이다. 테스트가 + # 운영 바스켓의 '추세 아래/낙폭 발동' 상태를 덮어쓰지 않도록 임시 디렉터리로 격리한다. + if not os.environ.get("QUANT_OVERLAY_STATE_DIR"): + os.environ["QUANT_OVERLAY_STATE_DIR"] = tempfile.mkdtemp(prefix="quant_test_overlay_") + # 사용자가 명시적으로 DB 경로를 지정했다면 존중한다. if os.environ.get("QUANT_DB_PATH"): return diff --git a/tests/test_basket_overlay_integration.py b/tests/test_basket_overlay_integration.py new file mode 100644 index 00000000..0ae14c14 --- /dev/null +++ b/tests/test_basket_overlay_integration.py @@ -0,0 +1,145 @@ +"""BasketRebalancer × 리스크 오버레이 통합 — 배수가 목표 비중·배치 진단·상태 파일에 반영되는가. + +계약 + - 오버레이가 꺼진 바스켓은 기존 동작 그대로(배수 1.0, overlay None). + - 추세 필터 발동 시 _stock_fraction = 설계 × off_scale, 배치 진단의 design_fraction도 같은 값. + - 지수 데이터가 부족하면 직전 상태를 유지하고 data_issues를 남긴다. + - 판단은 상태 파일(QUANT_OVERLAY_STATE_DIR)에 저장돼 다음 실행의 히스테리시스 입력이 된다. +""" +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from unittest.mock import MagicMock, patch + +import pandas as pd +import pytest + +from core.basket_rebalancer import BasketRebalancer +from core.risk_overlays import load_overlay_state, save_overlay_state, OverlayDecision + + +class _Collector: + """실제 시그니처(symbol, start_date, end_date)만 받는 지수 fake.""" + + def __init__(self, closes): + self.closes = closes + self.calls = [] + + 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") + return pd.DataFrame({"date": dates, "close": self.closes}) + + +def _basket(overlays=None, target=0.6): + cfg = { + "name": "t", "enabled": True, "initial_capital": 1_000_000, + "target_stock_weight": target, "min_cash_ratio": 0.05, + "holdings": {"005930": 1.0}, + "rebalance": {"trigger": "drift", "drift_threshold": 0.08, "min_trade_amount": 50_000}, + } + if overlays is not None: + cfg["overlays"] = overlays + return cfg + + +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() + rb._nav_frame = frame + return rb + + +@pytest.fixture(autouse=True) +def _isolated_state(tmp_path, monkeypatch): + monkeypatch.setenv("QUANT_OVERLAY_STATE_DIR", str(tmp_path)) + yield tmp_path + + +def _patch_snapshots(rb): + return patch("database.repositories.get_portfolio_snapshots", return_value=rb._nav_frame) + + +class TestOverlayOff: + def test_no_overlay_block_keeps_design(self): + rb = _make(_basket(), closes=[100.0] * 300) + assert rb.overlay_decision() is None + assert rb._stock_fraction() == pytest.approx(0.6) + assert rb.data_collector.calls == [] # 지수 조회조차 하지 않는다 + + +class TestTrendFilter: + overlays = {"trend_filter": {"enabled": True, "index_symbol": "KS200", "ma_days": 200, "band": 0.02, "off_scale": 0.5}} + + def test_below_ma_halves_target_and_saves_state(self, _isolated_state): + closes = [100.0] * 199 + [90.0] # 마지막 종가가 200일선 -10% + rb = _make(_basket(self.overlays), closes) + with _patch_snapshots(rb): + decision = rb.overlay_decision() + 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") + 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 + + def test_above_ma_keeps_design(self): + closes = [100.0] * 199 + [105.0] + rb = _make(_basket(self.overlays), closes) + with _patch_snapshots(rb): + assert rb._stock_fraction() == pytest.approx(0.6) + assert rb.overlay_decision().reasons == [] + + def test_hysteresis_uses_previous_state_file(self): + # 직전 실행이 '아래' 상태를 남겼고, 오늘은 선 +1% (band 2% 안) → 아래 유지 + save_overlay_state("t", OverlayDecision(scale=0.5, trend_below=True)) + closes = [100.0] * 199 + [101.0] + rb = _make(_basket(self.overlays), closes) + with _patch_snapshots(rb): + assert rb._stock_fraction() == pytest.approx(0.3) + + def test_missing_index_data_keeps_previous_state_and_flags(self): + save_overlay_state("t", OverlayDecision(scale=0.5, trend_below=True)) + rb = _make(_basket(self.overlays), closes=None) + with _patch_snapshots(rb): + decision = rb.overlay_decision() + assert decision.scale == pytest.approx(0.5) + assert decision.data_issues and "부족" in decision.data_issues[0] + + def test_today_bar_is_excluded(self): + """오늘 날짜 봉은 전일까지의 정보가 아니므로 제외한다.""" + from datetime import datetime + 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")) + 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 + + +class TestDrawdownGuard: + overlays = {"drawdown_guard": {"enabled": True, "trigger": -0.10, "release": -0.05, "scale": 0.5}} + + def test_deep_drawdown_halves_and_diagnose_reports_both_fractions(self): + rb = _make(_basket(self.overlays), closes=None, cumulative=[0.0, 3.0, -8.0, -12.0]) + rb._fetch_current_prices = MagicMock(return_value={"005930": 70_000.0}) + rb.portfolio_mgr.get_portfolio_summary = MagicMock(return_value={"total_value": 1_000_000.0, "cash": 400_000.0}) + rb.get_current_weights = MagicMock(return_value={"005930": 0.6}) + with _patch_snapshots(rb): + diag = rb.diagnose_deployment() + assert diag["base_stock_fraction"] == pytest.approx(0.6) + assert diag["design_fraction"] == pytest.approx(0.3) + assert diag["overlay"]["drawdown_active"] is True + assert diag["overlay"]["drawdown"] == pytest.approx((1 - 0.12) / 1.03 - 1, abs=1e-4) + + def test_recovery_releases(self): + save_overlay_state("t", OverlayDecision(scale=0.5, drawdown_active=True)) + rb = _make(_basket(self.overlays), closes=None, cumulative=[0.0, 10.0, 5.0, 6.0]) + with _patch_snapshots(rb): + assert rb._stock_fraction() == pytest.approx(0.6) # 낙폭 -3.6% → -5% 안 → 해제 diff --git a/tests/test_dashboard_basket_evaluation.py b/tests/test_dashboard_basket_evaluation.py index 8bfbfc42..36d412a3 100644 --- a/tests/test_dashboard_basket_evaluation.py +++ b/tests/test_dashboard_basket_evaluation.py @@ -108,7 +108,7 @@ async def run(): html = await res.text() finally: await client.close() - assert "basketEval" in html and "모의 운용 검증" in html + assert "basketEval" in html and "모의투자 검증" in html asyncio.run(run()) diff --git a/tests/test_dashboard_baskets.py b/tests/test_dashboard_baskets.py index a53aad94..ed856f6b 100644 --- a/tests/test_dashboard_baskets.py +++ b/tests/test_dashboard_baskets.py @@ -109,6 +109,58 @@ def test_principal_snapshot_deployment_positions(self): "enabled": True, "cadence": "monthly", "amount": 100_000.0, } + def test_target_weights_and_holdings_totals(self): + """종목별 목표 비중은 총자산 기준(바스켓 내 비중 × 설계 투자 비중)이고, + 보유분 매입금액·평가금액 합계가 함께 나와 화면이 비중을 계산하지 않아도 된다.""" + name = "kr_pocket_t_weights" + _seed_pocket(name) + from monitoring import web_dashboard as wd + + with patch( + "core.basket_rebalancer.BasketRebalancer.get_enabled_baskets", + return_value=[name], + ), patch( + "core.basket_rebalancer.BasketRebalancer._load_baskets_config", + return_value=_cfg(name), + ): + b = wd.get_baskets_json()["baskets"][0] + + # holdings {"069500": 1.0} × target_stock_weight 0.5 → 총자산의 50% + assert b["target_weights"] == {"069500": pytest.approx(0.5)} + assert b["holdings_cost"] == pytest.approx(128_135) + # 평가금액(보유분) = 총자산 − 현금 + assert b["holdings_value"] == pytest.approx(400_126 - 171_846) + # 오버레이가 없는 바스켓: 적용 비중 = 설계 비중, overlay는 None + assert b["base_stock_fraction"] == pytest.approx(0.5) + assert b["overlay"] is None + + def test_overlay_state_scales_applied_fraction(self, tmp_path, monkeypatch): + """오버레이가 켜진 바스켓은 마지막 실행이 남긴 배수를 곱한 '적용 비중'이 목표다.""" + from core.risk_overlays import OverlayDecision, save_overlay_state + + monkeypatch.setenv("QUANT_OVERLAY_STATE_DIR", str(tmp_path)) + name = "kr_pocket_t_overlay" + _seed_pocket(name) + cfg = _cfg(name) + cfg[name]["overlays"] = {"trend_filter": {"enabled": True}} + save_overlay_state(name, OverlayDecision(scale=0.5, reasons=["KS200 200일선 아래 → 주식 비중 ×0.5"], trend_below=True)) + from monitoring import web_dashboard as wd + + with patch( + "core.basket_rebalancer.BasketRebalancer.get_enabled_baskets", + return_value=[name], + ), patch( + "core.basket_rebalancer.BasketRebalancer._load_baskets_config", + return_value=cfg, + ): + b = wd.get_baskets_json()["baskets"][0] + + assert b["base_stock_fraction"] == pytest.approx(0.5) + assert b["design_fraction"] == pytest.approx(0.25) + assert b["target_weights"] == {"069500": pytest.approx(0.25)} + assert b["overlay"]["enabled"] is True and b["overlay"]["scale"] == pytest.approx(0.5) + assert "200일선" in b["overlay"]["summary"] + def test_no_snapshot_yet_is_null_not_crash(self): name = "kr_pocket_empty" # 시드 없음 — 운영 전 상태 init_database() diff --git a/tests/test_risk_overlays.py b/tests/test_risk_overlays.py new file mode 100644 index 00000000..7ecf014d --- /dev/null +++ b/tests/test_risk_overlays.py @@ -0,0 +1,159 @@ +"""리스크 오버레이(추세 필터·낙폭 제어·변동성 목표) 순수 함수와 상태 파일 테스트. + +계약: 배수는 설계 비중을 대체하지 않고 곱한다. 히스테리시스는 상태로 이어지고, +데이터가 없으면 새 판단을 지어내지 않고 직전 상태를 유지하며 data_issues에 남긴다. +""" +import sys, os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import math + +import pytest + +from core.risk_overlays import ( + DrawdownGuardConfig, + OverlayConfig, + TrendFilterConfig, + VolatilityTargetConfig, + applied_stock_fraction, + compute_decision, + describe_decision, + drawdown_from_cumulative_returns, + drawdown_guard_active, + load_overlay_state, + parse_overlay_config, + save_overlay_state, + trend_below_ma, + volatility_scale, +) + + +def _closes(level_then_last, ma_days=200): + """ma_days-1개는 100, 마지막 종가만 지정.""" + return [100.0] * (ma_days - 1) + [level_then_last] + + +class TestParse: + def test_missing_block_is_all_off(self): + cfg = parse_overlay_config({}) + assert cfg.any_enabled is False + assert cfg.trend.ma_days == 200 and cfg.drawdown.trigger == -0.10 + + def test_partial_block_and_clamping(self): + cfg = parse_overlay_config({"overlays": { + "trend_filter": {"enabled": True, "off_scale": 1.7, "band": "0.03"}, + "drawdown_guard": {"enabled": "yes", "trigger": 0.2}, + "volatility_target": {"enabled": True, "target": 0.15, "min_scale": -1}, + }}) + assert cfg.trend.enabled and cfg.trend.off_scale == 1.0 and cfg.trend.band == pytest.approx(0.03) + assert cfg.drawdown.enabled and cfg.drawdown.trigger == 0.0 # 양수 trigger는 0으로 고정 + assert cfg.volatility.min_scale == 0.0 + assert cfg.any_enabled + + def test_garbage_block_is_ignored(self): + assert parse_overlay_config({"overlays": "nope"}).any_enabled is False + + +class TestTrend: + cfg = TrendFilterConfig(enabled=True, ma_days=200, band=0.02, off_scale=0.5) + + def test_insufficient_data_keeps_previous_state(self): + assert trend_below_ma([100.0] * 50, self.cfg, True) == (True, None) + assert trend_below_ma([], self.cfg, None) == (None, None) + + def test_first_run_initialises_without_band(self): + below, rel = trend_below_ma(_closes(99.0), self.cfg, None) + assert below is True and rel < 0 + + def test_hysteresis_holds_inside_band(self): + # 위 상태에서 -1%는 band(2%) 안 → 유지 + assert trend_below_ma(_closes(99.0), self.cfg, False)[0] is False + # -3%면 아래로 전환 + assert trend_below_ma(_closes(96.0), self.cfg, False)[0] is True + # 아래 상태에서 +1%는 band 안 → 유지 + assert trend_below_ma(_closes(101.0), self.cfg, True)[0] is True + # +3%면 위로 복귀 + assert trend_below_ma(_closes(103.5), self.cfg, True)[0] is False + + +class TestDrawdown: + cfg = DrawdownGuardConfig(enabled=True, trigger=-0.10, release=-0.05, scale=0.5) + + def test_drawdown_from_cumulative_returns_uses_initial_capital_as_first_peak(self): + assert drawdown_from_cumulative_returns([-0.11, -5.0, -10.71]) == pytest.approx(-0.1071, abs=1e-4) + assert drawdown_from_cumulative_returns([10.0, 21.0, 9.9]) == pytest.approx(1.099 / 1.21 - 1) + assert drawdown_from_cumulative_returns([]) is None + assert drawdown_from_cumulative_returns([None, "x"]) is None + + def test_trigger_and_release(self): + assert drawdown_guard_active(-0.09, self.cfg, False) is False + assert drawdown_guard_active(-0.10, self.cfg, False) is True + assert drawdown_guard_active(-0.07, self.cfg, True) is True # 아직 release 안 + assert drawdown_guard_active(-0.05, self.cfg, True) is False + assert drawdown_guard_active(None, self.cfg, True) is True # 데이터 없음 → 유지 + + +class TestVolatility: + cfg = VolatilityTargetConfig(enabled=True, target=0.20, lookback_days=20, min_scale=0.5, max_scale=1.0, step=0.1) + + def test_scale_quantised_and_capped(self): + calm = [0.001, -0.001] * 10 # 연 ~2% → 배수 상한 1.0 + wild = [0.03, -0.03] * 10 # 연 ~48% → 0.2/0.48≈0.42 → 하한 0.5 + assert volatility_scale(calm, self.cfg)[0] == 1.0 + assert volatility_scale(wild, self.cfg)[0] == 0.5 + assert volatility_scale([0.01] * 5, self.cfg) == (None, None) + + def test_scale_rounds_to_step(self): + mid = [0.018, -0.018] * 10 # 연 ~29% → 0.69 → 0.7 + scale, vol = volatility_scale(mid, self.cfg) + assert scale == pytest.approx(0.7) + assert 0.25 < vol < 0.35 + + +class TestDecision: + def test_all_off_is_identity(self): + d = compute_decision(OverlayConfig()) + assert d.scale == 1.0 and d.reasons == [] and d.data_issues == [] + + def test_trend_and_drawdown_multiply(self): + cfg = parse_overlay_config({"overlays": { + "trend_filter": {"enabled": True, "ma_days": 200}, + "drawdown_guard": {"enabled": True}, + }}) + d = compute_decision(cfg, index_closes=_closes(95.0), cumulative_returns_pct=[0, -12.0], prev_state=None) + assert d.trend_below is True and d.drawdown_active is True + assert d.scale == pytest.approx(0.25) + assert len(d.reasons) == 2 and "200일선 아래" in d.reasons[0] + + def test_missing_data_keeps_previous_state_and_flags_issue(self): + cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True}}}) + prev = {"trend_below": True} + d = compute_decision(cfg, index_closes=[100.0] * 10, prev_state=prev) + assert d.trend_below is True and d.scale == 0.5 + assert d.data_issues and "부족" in d.data_issues[0] + + def test_describe_is_korean_one_liner(self): + cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True}}}) + d = compute_decision(cfg, index_closes=_closes(103.0), prev_state=None) + assert describe_decision(d).startswith("위험 조절 발동 없음") + assert "첫 실행 대기" in describe_decision(None) + + +class TestState: + 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) + path = save_overlay_state("kr_pocket", d, tmp_path) + assert path.parent == tmp_path and path.name == "kr_pocket.json" + state = load_overlay_state("kr_pocket", tmp_path) + assert state["scale"] == 0.5 and state["basket"] == "kr_pocket" and state["trend_below"] is True + assert applied_stock_fraction(0.95, state) == pytest.approx(0.475) + assert applied_stock_fraction(0.95, None) == pytest.approx(0.95) + assert load_overlay_state("missing", tmp_path) is None + + def test_env_dir_is_respected(self, tmp_path, monkeypatch): + monkeypatch.setenv("QUANT_OVERLAY_STATE_DIR", str(tmp_path / "env")) + cfg = parse_overlay_config({"overlays": {"drawdown_guard": {"enabled": True}}}) + d = compute_decision(cfg, cumulative_returns_pct=[0.0, -1.0]) + save_overlay_state("x", d) + assert (tmp_path / "env" / "x.json").exists() diff --git a/tools/risk_overlay_backtest.py b/tools/risk_overlay_backtest.py new file mode 100644 index 00000000..6ffeaaa0 --- /dev/null +++ b/tools/risk_overlay_backtest.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +"""리스크 오버레이 정직 백테스트 — 추세 필터·변동성 목표·낙폭 제어가 손실을 줄이는가. + +이 저장소의 확정 결론(docs/PROFITABILITY_FINDINGS.md)은 "종목 선택 알파는 없고, 베타를 싸게 +담는 것이 답"이다. 그래서 여기서는 종목을 고르지 않는다. 대신 이미 운용 중인 두 트랙의 +**주식 비중을 언제 줄이고 언제 되돌리는가**만 바꿔 보고, 같은 비용·같은 적립 규칙 아래에서 +낙폭(MDD)·손실 연도·샤프가 어떻게 달라지는지 잰다. + +트랙 1 (kr_pocket 형) : 지수 ETF + 금리 파킹, 월 10만원 적립, 목표 주식 비중 50% +트랙 2 (kr_diversified_hold 형) : 대형주 10종목 동일비중, 목표 주식 비중 60% + +정책 + static : 목표 비중 고정 (현재 운용과 동일) + trend : 지수가 200일선 아래면 주식 비중을 off_scale 배로 축소 (Faber 10개월 SMA 계열) + vol : 실현 변동성이 목표(연 15%)를 넘으면 목표/실현 비율만큼 축소 (하한 0.5, 상한 1.0) + trend+vol : 두 배수의 곱 + dd : 시간가중 NAV가 고점 대비 -10% 아래면 0.5배, -5% 안으로 회복하면 복귀 + +비용: ETF 편도 수수료 0.015% + 슬리피지 0.05%, 증권거래세 없음(국내 주식형 ETF 비과세). + 개별 주식은 매도 시 0.20% 세금 추가. 파킹 사채·현금은 연 3% 일할 가정. +데이터: FinanceDataReader. KS200 지수는 2002년부터(배당 제외 가격지수 — 보수적), + 069500은 2014년부터. 결과는 reports/research/ 와 docs/images/ 에 남긴다. + +Usage: + python tools/risk_overlay_backtest.py # 전체 + python tools/risk_overlay_backtest.py --track pocket # 트랙 1만 +""" +from __future__ import annotations + +import argparse +import json +import math +import sys +from dataclasses import dataclass, field +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_ROOT)) + +BASKET_SYMBOLS = ["005930", "000660", "035420", "005380", "051910", + "005490", "055550", "035720", "012330", "105560"] +COMMISSION = 0.00015 +SLIPPAGE = 0.0005 +STOCK_TAX = 0.0020 +RF_ANNUAL = 0.03 +TRADING_DAYS = 252 + + +@dataclass +class Policy: + name: str + label: str + trend: bool = False + trend_ma_days: int = 200 + trend_off_scale: float = 0.5 + trend_band: float = 0.02 # 200일선 ±2% 히스테리시스 — 선 근처 왕복 매매를 막는다 + vol: bool = False + vol_target: float = 0.15 + vol_lookback: int = 60 + vol_min_scale: float = 0.5 + vol_max_scale: float = 1.0 + vol_step: float = 0.1 # 배수를 0.1 단위로 양자화 — 매일 미세 조정하지 않는다 + dd: bool = False + dd_trigger: float = -0.10 + dd_release: float = -0.05 + dd_scale: float = 0.5 + notes: list[str] = field(default_factory=list) + + +POLICIES = [ + Policy("static", "고정 비중(현행)"), + Policy("trend50", "추세 필터 · 200일선 아래면 절반", trend=True, trend_off_scale=0.5), + Policy("trend0", "추세 필터 · 200일선 아래면 0", trend=True, trend_off_scale=0.0), + Policy("vol15", "변동성 목표 15%", vol=True), + Policy("vol20", "변동성 목표 20%", vol=True, vol_target=0.20), + Policy("dd10", "낙폭 제어 · -10%에서 절반", dd=True), + Policy("trend50_dd10", "추세 절반 + 낙폭 제어", trend=True, trend_off_scale=0.5, dd=True), + Policy("trend50_vol20", "추세 절반 + 변동성 20%", trend=True, trend_off_scale=0.5, vol=True, vol_target=0.20), +] + + +def _fdr(symbol: str, start: str): + import FinanceDataReader as fdr + df = fdr.DataReader(symbol, start) + df = df.rename(columns={c: c.lower() for c in df.columns}) + df.index = df.index.tz_localize(None) if getattr(df.index, "tz", None) is not None else df.index + return df["close"].astype(float).dropna() + + +def _scale_series(closes, nav_twr, policy: Policy, index_closes=None): + """날짜별 주식 비중 배수(0~1). 모든 계산은 전일까지의 정보만 쓴다(shift 1).""" + import numpy as np + import pandas as pd + + scale = pd.Series(1.0, index=closes.index) + if policy.trend: + ref = index_closes if index_closes is not None else closes + ma = ref.rolling(policy.trend_ma_days).mean() + rel = (ref / ma - 1).reindex(closes.index).ffill().shift(1) + # 히스테리시스: 선 아래로 band만큼 내려가야 '하락', 위로 band만큼 올라와야 '상승'. + below = [] + state = False + for v in rel.to_numpy(): + if v != v: # NaN + below.append(False) + continue + if state and v > policy.trend_band: + state = False + elif (not state) and v < -policy.trend_band: + state = True + below.append(state) + below = pd.Series(below, index=closes.index) + scale = scale.where(~below, policy.trend_off_scale) + if policy.vol: + rets = closes.pct_change() + realized = rets.rolling(policy.vol_lookback).std() * math.sqrt(TRADING_DAYS) + ratio = (policy.vol_target / realized).shift(1) + vol_scale = ratio.clip(lower=policy.vol_min_scale, upper=policy.vol_max_scale).fillna(1.0) + if policy.vol_step > 0: + vol_scale = (vol_scale / policy.vol_step).round() * policy.vol_step + scale = scale * vol_scale + return scale.clip(0.0, 1.0) + + +def simulate(closes, policy: Policy, *, index_closes=None, target_stock=0.5, + initial=300_000.0, monthly=100_000.0, tax_rate=0.0, + drift_band=0.08, rf_annual=RF_ANNUAL): + """단일 위험자산 + 현금성 자산의 DCA 포트폴리오. 위험자산은 소수 주 허용(연구용). + + - 매월 첫 거래일에 monthly 적립 → 현금으로 들어와 다음 리밸런싱에서 배분. + - 리밸런싱: 목표 비중(= target_stock × 정책 배수)과 실제 비중의 차이가 drift_band를 + 넘거나, 정책 배수가 바뀐 날 실행. 체결은 다음날 시가가 없으므로 당일 종가에 + 슬리피지를 얹어 보수적으로 근사한다. + - 시간가중수익률(TWR)로 성과를 재고, 적립은 수익에서 분리한다. + """ + import numpy as np + import pandas as pd + + rf_daily = (1 + rf_annual) ** (1 / TRADING_DAYS) - 1 + dates = closes.index + scale = _scale_series(closes, None, policy, index_closes) + + shares = 0.0 + cash = initial + peak_index = 1.0 + twr_index = 1.0 + dd_active = False + prev_scale = None + prev_month = None + contributed = initial + turnover_value = 0.0 + exposure_sum = 0.0 + rows = [] + prev_total_after_flow = None + + for i, day in enumerate(dates): + price = float(closes.iloc[i]) + # 1) 적립 (월 첫 거래일) + flow = 0.0 + if prev_month is not None and day.month != prev_month and monthly > 0: + cash += monthly + contributed += monthly + flow = monthly + prev_month = day.month + # 2) 현금 이자 + cash *= (1 + rf_daily) + total_before = shares * price + cash + # 3) 낙폭 제어 상태 (전일까지의 TWR 기준) + s = float(scale.iloc[i]) + if policy.dd: + dd_now = twr_index / peak_index - 1 + if dd_active and dd_now >= policy.dd_release: + dd_active = False + elif not dd_active and dd_now <= policy.dd_trigger: + dd_active = True + if dd_active: + s *= policy.dd_scale + target_w = target_stock * s + cur_w = shares * price / total_before if total_before > 0 else 0.0 + need = (prev_scale is None) or (abs(cur_w - target_w) > drift_band) or (abs(s - (prev_scale or 0)) > 1e-9 and abs(cur_w - target_w) > 0.005) + if flow > 0 and cur_w < target_w - 0.005: + need = True + if need: + target_value = total_before * target_w + diff_value = target_value - shares * price + if abs(diff_value) > 1_000: + fill = price * (1 + SLIPPAGE) if diff_value > 0 else price * (1 - SLIPPAGE) + qty = diff_value / fill + cost = abs(diff_value) * COMMISSION + (abs(diff_value) * tax_rate if diff_value < 0 else 0.0) + cash -= qty * fill + cost + shares += qty + turnover_value += abs(diff_value) + prev_scale = s + total = shares * price + cash + # 4) TWR: 적립 직후 가치 대비 당일 종가 가치 + if prev_total_after_flow is not None and prev_total_after_flow > 0: + twr_index *= (total / (prev_total_after_flow)) + peak_index = max(peak_index, twr_index) + prev_total_after_flow = total + # 다음 날 적립이 들어오면 분모를 적립 포함으로 갱신해야 하므로 flow는 다음 루프의 total_before에 반영됨 + exposure_sum += (shares * price / total) if total > 0 else 0.0 + rows.append({"date": day, "total": total, "twr": twr_index, "contributed": contributed, + "stock_w": shares * price / total if total > 0 else 0.0, "scale": s}) + # 적립이 들어오는 날 TWR 분모 보정: 다음 루프에서 total_before에 flow가 포함되므로 + # prev_total_after_flow에 flow를 더해 둔다 (적립은 수익이 아니다). + frame = pd.DataFrame(rows).set_index("date") + # TWR 재계산(적립을 분모에 더하는 방식으로 정확히): 일별 수익률 = total_t / (total_{t-1} + flow_t) + flows = frame["contributed"].diff().fillna(0.0) + daily = frame["total"] / (frame["total"].shift(1) + flows) - 1 + daily.iloc[0] = 0.0 + twr = (1 + daily).cumprod() + frame["twr"] = twr + frame["drawdown"] = twr / twr.cummax() - 1 + return frame, {"turnover_value": turnover_value, "avg_exposure": exposure_sum / len(frame)} + + +def metrics(frame, extra, years_hint=None): + import numpy as np + twr = frame["twr"] + daily = twr.pct_change().dropna() + n_years = (frame.index[-1] - frame.index[0]).days / 365.25 + cagr = twr.iloc[-1] ** (1 / n_years) - 1 if n_years > 0 else 0.0 + vol = daily.std() * math.sqrt(TRADING_DAYS) + sharpe = (daily.mean() * TRADING_DAYS - RF_ANNUAL) / vol if vol > 0 else 0.0 + mdd = frame["drawdown"].min() + calmar = cagr / abs(mdd) if mdd < 0 else float("nan") + yearly = twr.resample("YE").last().pct_change() + first_year = twr.resample("YE").last().iloc[0] / twr.iloc[0] - 1 + yearly.iloc[0] = first_year + monthly = twr.resample("ME").last().pct_change().dropna() + avg_total = frame["total"].mean() + turnover_per_year = extra["turnover_value"] / max(avg_total, 1) / n_years if n_years > 0 else 0.0 + final = frame["total"].iloc[-1] + contributed = frame["contributed"].iloc[-1] + return { + "years": round(n_years, 2), + "cagr_pct": round(cagr * 100, 2), + "vol_pct": round(vol * 100, 2), + "sharpe": round(float(sharpe), 2), + "mdd_pct": round(float(mdd) * 100, 2), + "calmar": round(float(calmar), 2) if calmar == calmar else None, + "worst_year_pct": round(float(yearly.min()) * 100, 2), + "losing_years": int((yearly < 0).sum()), + "total_years": int(len(yearly)), + "negative_months_pct": round(float((monthly < 0).mean()) * 100, 1), + "avg_exposure_pct": round(extra["avg_exposure"] * 100, 1), + "turnover_per_year_pct": round(turnover_per_year * 100, 1), + "final_value": round(float(final)), + "contributed": round(float(contributed)), + "profit": round(float(final - contributed)), + "yearly": {str(k.year): round(float(v) * 100, 2) for k, v in yearly.items()}, + } + + +def run_pocket(start="2002-01-01", use_etf=False): + """트랙 1: 지수 위험자산(KS200 지수 또는 069500) + 현금성(연 3%).""" + symbol = "069500" if use_etf else "KS200" + closes = _fdr(symbol, start) + index = _fdr("KS200", start) + results = {} + frames = {} + for policy in POLICIES: + frame, extra = simulate(closes, policy, index_closes=index, target_stock=0.5, + initial=300_000, monthly=100_000, tax_rate=0.0) + results[policy.name] = {"label": policy.label, **metrics(frame, extra)} + frames[policy.name] = frame + return {"symbol": symbol, "start": str(closes.index[0].date()), "end": str(closes.index[-1].date()), + "results": results}, frames + + +def run_basket(start="2021-12-01"): + """트랙 2: 대형주 10종목 동일비중을 하나의 위험자산(EW 지수)으로 묶고, 주식 비중 60%.""" + import pandas as pd + panel = pd.DataFrame({s: _fdr(s, start) for s in BASKET_SYMBOLS}).dropna(how="any") + ew = (panel / panel.iloc[0]).mean(axis=1) # 동일비중 지수 (일별 리밸런싱 근사 없음: 첫날 동일비중 보유) + # 첫날 동일비중 매수 후 보유한 가치가 정확히 위 식이다 (수량 고정, 가격만 변동). + index = _fdr("KS200", start) + results = {} + frames = {} + for policy in POLICIES: + frame, extra = simulate(ew, policy, index_closes=index, target_stock=0.6, + initial=10_000_000, monthly=0.0, tax_rate=STOCK_TAX) + results[policy.name] = {"label": policy.label, **metrics(frame, extra)} + frames[policy.name] = frame + return {"symbol": "EW10", "symbols": BASKET_SYMBOLS, "start": str(ew.index[0].date()), + "end": str(ew.index[-1].date()), "results": results}, frames + + +def _plot(track_name, summary, frames, out_png, title): + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + from matplotlib import font_manager + + for name in ["Malgun Gothic", "NanumGothic", "Noto Sans KR", "Apple SD Gothic Neo"]: + if any(f.name == name for f in font_manager.fontManager.ttflist): + plt.rcParams["font.family"] = name + break + plt.rcParams["axes.unicode_minus"] = False + paper, ink, faint, up, down = "#f2f0ec", "#0d0c0b", "#8c8983", "#c33d2b", "#2557c9" + order = ["static", "trend50", "dd10", "trend50_dd10", "vol20", "trend0"] + colors = {"static": faint, "trend50": ink, "dd10": up, "trend50_dd10": down, "vol20": "#b06d12", "trend0": "#6b5d9e"} + fig, axes = plt.subplots(2, 1, figsize=(11, 7.2), sharex=True, gridspec_kw={"height_ratios": [2.4, 1]}) + fig.patch.set_facecolor(paper) + for ax in axes: + ax.set_facecolor(paper) + for spine in ["top", "right"]: + ax.spines[spine].set_visible(False) + ax.grid(axis="y", color=ink, alpha=0.08, linewidth=0.8) + ax.tick_params(colors=ink, labelsize=9) + for name in order: + if name not in frames: + continue + f = frames[name] + lw = 1.6 if name in ("static", "trend50", "dd10", "trend50_dd10") else 1.0 + axes[0].plot(f.index, f["twr"] * 100, color=colors[name], linewidth=lw, label=f"{summary['results'][name]['label']} · 연 {summary['results'][name]['cagr_pct']:.1f}% · 낙폭 {summary['results'][name]['mdd_pct']:.0f}%") + axes[1].fill_between(f.index, f["drawdown"] * 100, 0, color=colors[name], alpha=0.12 if name != "static" else 0.25, linewidth=0) + axes[1].plot(f.index, f["drawdown"] * 100, color=colors[name], linewidth=0.9) + axes[0].set_title(title, loc="left", fontsize=13, color=ink, pad=12) + axes[0].set_ylabel("시간가중 지수 (시작=100)", fontsize=9, color=ink) + axes[0].legend(loc="upper left", fontsize=8.5, frameon=False) + axes[1].set_ylabel("고점 대비 낙폭 (%)", fontsize=9, color=ink) + fig.text(0.01, 0.01, "비용 반영(수수료·슬리피지·주식 거래세) · 적립은 시간가중으로 분리 · 모든 신호는 전일 정보만 사용", fontsize=8, color=faint) + fig.tight_layout(rect=(0, 0.03, 1, 1)) + out_png.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(out_png, dpi=150, facecolor=paper) + plt.close(fig) + + +def _markdown(track_title, summary): + lines = [f"### {track_title}", "", f"- 데이터: {summary['symbol']} {summary['start']} → {summary['end']}", "", + "| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|"] + for name, r in summary["results"].items(): + lines.append(f"| {r['label']} | {r['cagr_pct']:+.2f}% | {r['mdd_pct']:.1f}% | {r['sharpe']:.2f} | {r['calmar'] if r['calmar'] is not None else '—'} | {r['worst_year_pct']:+.1f}% | {r['losing_years']}/{r['total_years']} | {r['avg_exposure_pct']:.0f}% | {r['turnover_per_year_pct']:.0f}% |") + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--track", choices=["all", "pocket", "pocket_etf", "basket"], default="all") + parser.add_argument("--out-dir", default=str(_ROOT / "reports" / "research")) + parser.add_argument("--image-dir", default=str(_ROOT / "docs" / "images")) + args = parser.parse_args() + out_dir = Path(args.out_dir) + image_dir = Path(args.image_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + report = {} + md = ["# 리스크 오버레이 백테스트 결과", ""] + if args.track in ("all", "pocket"): + summary, frames = run_pocket("2002-01-01", use_etf=False) + report["pocket_ks200"] = summary + _plot("pocket", summary, frames, image_dir / "overlay-pocket-ks200.png", + "적립 트랙 · 코스피200 지수 50% + 현금성 50% · 2002년부터 월 10만원 적립") + md += [_markdown("적립 트랙 (KS200 지수, 2002~)", summary), ""] + if args.track in ("all", "pocket_etf"): + summary, frames = run_pocket("2014-01-01", use_etf=True) + report["pocket_etf"] = summary + _plot("pocket_etf", summary, frames, image_dir / "overlay-pocket-etf.png", + "적립 트랙 · KODEX 200 실제 ETF 50% + 현금성 50% · 2014년부터 월 10만원 적립") + md += [_markdown("적립 트랙 (KODEX 200 ETF, 2014~)", summary), ""] + if args.track in ("all", "basket"): + summary, frames = run_basket("2021-12-01") + report["basket"] = summary + _plot("basket", summary, frames, image_dir / "overlay-basket.png", + "관찰 트랙 · 대형주 10종목 동일비중 60% + 현금 40% · 2022년 약세장 포함") + md += [_markdown("관찰 트랙 (대형주 10종목, 2021-12~)", summary), ""] + (out_dir / "risk_overlay_backtest.json").write_text(json.dumps(report, ensure_ascii=False, indent=1), encoding="utf-8") + (out_dir / "risk_overlay_backtest.md").write_text("\n".join(md), encoding="utf-8") + print("\n".join(md)) + + +if __name__ == "__main__": + main()