diff --git a/.gitignore b/.gitignore index 16a3649..ab2815e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,8 @@ reports/validation_walkforward_*.txt # --- 테스트/에이전트 임시 산출물 --- pytest_tmp*/ .claude/worktrees/ +output/ +.playwright-mcp/ # --- IDE / OS --- .idea/ diff --git a/README.md b/README.md index fa61146..f747306 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,114 @@ -눈금 심볼 + -# 눈금 — 오래 투자하기 위한 기준과 기록 +# 눈금 -한국 주식 ETF·대형주 바스켓을 매일 자동으로 굴리고, 그 결과를 숫자로 남기고, 정한 기준을 지키게 만드는 개인용 퀀트 시스템입니다. 주가를 맞히는 프로그램이 아닙니다. 종목을 고르는 알파는 없다는 걸 검증으로 확인했고, 그 대신 **비용을 최소로 시장 수익(베타)을 담고, 떨어질 때 덜 잃는 것**에 집중합니다. +**국내 주식·ETF 자동매매와 계좌 관리** -![스크롤하면 자산 곡선이 그려지는 첫 화면](docs/images/dashboard-story.png) +정해 둔 비중에 맞춰 주식과 ETF를 사고팔고, 원금·수익률·보유 종목을 한 화면에서 확인하는 개인용 투자 도구입니다. 과거 데이터로 운용 규칙을 비교하고, 모의투자로 실제 동작을 점검할 수 있습니다. -## 한눈에 보기 +![눈금의 계좌 현황 화면 — 총자산, 투자원금, 수익률과 자산 추이](docs/images/dashboard-account-20260917.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 | +*2026년 9월 17일 모의투자 기록을 불러온 실제 화면입니다. 아래 백테스트와는 별도의 계좌 기록입니다.* -## 돈이 어떻게 굴러가나 +## 할 수 있는 일 -두 트랙 모두 `config/baskets.yaml`에 선언된 목표 비중을 향해 큰 이탈이 생길 때만 사고팝니다. 매일 아침 스케줄러가 시세를 받아 리밸런싱이 필요한지 보고, 그날의 총자산을 장부에 남깁니다. 적립금은 수익이 아니므로 시간가중수익률(TWR)로 분리해서 계산합니다. +- **계좌 확인** — 총자산, 투자원금, 현금, 보유 종목과 목표 비중을 확인합니다. +- **성과 확인** — 적립금을 수익에서 제외한 수익률, 자산 추이, 고점 대비 하락률을 봅니다. +- **자동 리밸런싱** — 실제 비중이 설정 범위를 벗어나면 조정합니다. 최소 주문금액과 1주 단위를 반영합니다. +- **모의투자 검증** — 운영 기록, 누락된 자산 기록, 거래비용과 미해결 주문을 확인합니다. +- **운영 점검** — 자동매매 실행 여부, 거래 중지 상태, 증권사 연결과 데이터 갱신 상태를 봅니다. -| 트랙 | 구성 | 목표 주식 비중 | 리밸런싱 | 손절 | -|---|---|---|---|---| -| `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일 | +Python·aiohttp·SQLite·한국투자증권 KIS API를 사용합니다. 화면은 HTML/CSS/JavaScript로 만들었으며 프런트엔드 설치나 빌드 과정이 없습니다. -정직한 기대치는 이렇습니다. 2022년 약세장을 포함한 검증에서 대형주 동일비중 보유는 연 13% 안팎, 주식 50%짜리 적립 트랙은 연 7~9%였습니다. 강세장 한 번의 숫자(연 30%대)는 상한이지 약속이 아닙니다. 자세한 근거는 [docs/PROFITABILITY_FINDINGS.md](docs/PROFITABILITY_FINDINGS.md)에 있습니다. +## 화면 -## 2026년 9월 업데이트 — 손실을 줄이는 두 가지 규칙 +### 보유 종목과 비중 -![적립 트랙 오버레이 백테스트](docs/images/overlay-pocket-etf.png) +매입금액과 목표 비중을 나란히 보여 줍니다. 계좌를 바꾸면 요약, 종목표, 차트, 적립금 입력 대상이 함께 바뀝니다. -"수익률을 올리는 신호"는 여러 번 시도해서 전부 시장 보유에 졌습니다. 그래서 이번에는 **주식 비중을 언제 줄이는지**만 바꿨습니다. 2002년부터의 코스피200, 2014년부터의 KODEX 200 실제 ETF, 2021년 말부터의 대형주 10종목으로 같은 비용·같은 적립 규칙 아래 비교했습니다. +![대형주 분산 투자 계좌의 보유 종목](docs/images/dashboard-holdings-20260917.png) -| 규칙 | 무엇을 하나 | 적립 트랙(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 깎아 **기본 꺼 둠** | +### 모바일과 운영 상태 -주력 적립 트랙에는 추세 필터와 낙폭 제어를 켰습니다. 관찰 트랙은 60거래일 검증을 끝내고 실전 전환 승인을 기다리는 중이라 권장값만 적어 두고 껐습니다. 전체 표와 읽는 법은 [docs/RISK_OVERLAY_FINDINGS.md](docs/RISK_OVERLAY_FINDINGS.md), 재현은 `python tools/risk_overlay_backtest.py`입니다. +작은 화면에서도 잔액과 수익률을 먼저 보여 줍니다. 적립금은 계좌·금액·모의/실전 구분을 확인한 뒤 기록합니다. 모의투자 적립은 가상 계좌에만 반영됩니다. -## 화면 +
+모바일 화면 보기 -대시보드는 매일 아침 한 번 보는 용도로 만들었습니다. 첫 화면은 스크롤에 따라 자산 곡선이 그려지면서 총자산, 적립을 뺀 수익률, 최대 낙폭을 차례로 보여줍니다. +모바일 계좌 현황 — 잔액, 현금, 원금, 수익률 -![고점 대비 낙폭 구간이 음영으로 드러나는 첫 화면](docs/images/dashboard-story-drawdown.png) +
-**오늘** — 확인할 일이 있으면 그것만 먼저 말합니다. 적립을 아직 기록하지 않았는지, 자동매매가 멈췄는지, 거래가 중지됐는지. 없으면 "오늘은 손댈 것이 없습니다"라고 씁니다. +
+자동매매 상태 화면 보기 -![오늘 할 일과 운영 상태 요약](docs/images/dashboard-today.png) +![거래 안전, 장 상태, 자동매매와 데이터 갱신 확인](docs/images/dashboard-operations-20260917.png) -**포트폴리오** — 트랙별 총자산·투자원금·평가손익·시간가중수익률·최대낙폭·현금 비중, 투자 비중과 목표, 종목별 매입 비중과 목표 비중, 그리고 오늘 위험 조절 규칙이 무엇을 판단했는지. +
-![트랙별 지표와 종목표](docs/images/dashboard-portfolio.png) +## 기본 운용 구성 -**성과** — 평가금액(점선은 투자원금, 세로 눈금은 적립 시점), 고점 대비 낙폭, 월별 시간가중수익률. 차트 위에 커서를 올리면 날짜별 값이 보입니다. +| 계좌 | 구성과 역할 | 현재 상태 | +|---|---|---| +| ETF 적립 | KODEX 200 + TIGER CD금리 ETF. 시작 30만원, 매월 10만원 적립 | 변경한 위험 관리 규칙을 모의투자로 검증 중 | +| 대형주 분산 투자 | 국내 대형주 9종목. 주식 60%·현금 40%, 큰 비중 이탈 시 조정 | 기존 규칙으로 모의투자 운용 | -![평가금액 · 낙폭 · 월별 수익률](docs/images/dashboard-performance.png) +ETF 적립의 기본 목표는 주식 ETF 47.5%·CD금리 ETF 47.5%·현금 5%입니다. 방어 조건에 들어가면 주식 ETF 목표를 23.75%로 낮추고, 줄인 만큼을 CD금리 ETF에 배분합니다. 소액 계좌는 1주 가격과 최소 주문금액 때문에 실제 비중이 목표와 다를 수 있습니다. CD금리 ETF도 원금 보장 상품은 아닙니다. -**검증·운영** — 60거래일 모의투자 검증 진행률, 거래 안전·장 상태·자동매매·증권사 연결·데이터 갱신 상태, 오늘 생성된 신호, 웹소켓 연결 이력. +설정은 [config/baskets.yaml](config/baskets.yaml)에 있습니다. 실전 주문에는 별도의 활성화와 검증이 필요하며, ETF 적립 계좌는 `paper_only: true`로 실전 전환을 제한하고 있습니다. -![운영 상태표](docs/images/dashboard-operations.png) +## 위험 관리 검증 -## 실행하기 +적립금이 들어오면 낙폭이 작아 보이던 백테스트 계산을 고쳤습니다. 이어서 추세·낙폭 조건이 겹칠 때 비중을 중복해서 줄이지 않고, 주식 축소분을 기존 CD금리 ETF로 옮기도록 바꿨습니다. -Python 3.11 또는 3.12가 필요합니다. +![동일 기간·비용으로 비교한 세 가지 운용 방식의 수익과 낙폭](docs/images/risk-review-20260917.png) -```bash -git clone https://github.com/easygap/quant_trader.git -cd quant_trader -python -m venv .venv -.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 -``` +**2020-07-07~2026-09-16, 실제 ETF 종가·1주 단위·월 10만원 적립 비교** -기본 바인드는 http://127.0.0.1:8080 입니다. 대시보드는 인증 없이 계좌 숫자를 보여주므로 이 PC에서만 열리고, 외부 공개는 설정으로도 권장하지 않습니다. +| 방식 | 연환산 수익률 | 최대 낙폭 | 샤프² | +|---|---:|---:|---:| +| 고정 비중 | 13.54% | -21.72% | 0.80 | +| 기존 위험 관리¹ | 10.74% | -16.02% | 0.74 | +| 변경한 위험 관리 | 12.97% | -15.74% | 0.90 | -처음 실행해 화면에 기록이 없다면 모의투자 사이클을 한 번 돌립니다. +¹ 기존 방식도 적립금 계산 오류를 수정한 뒤 같은 조건으로 다시 계산했습니다. ² 샤프 계산의 기준금리는 연 3%로 고정했습니다. -```bash -python main.py --mode rebalance --basket kr_pocket --dry-run # 주문 미리보기 -python main.py --mode rebalance --basket kr_pocket # 모의투자 실행 + 자산 기록 -``` +변경한 방식은 이 전체 기간에서 기존 방식보다 수익률이 높고 최대 낙폭이 작았습니다. **고정 비중보다 수익률은 낮았고, 2023~2025년에는 기존 방식보다 낙폭이 컸습니다.** 수수료·슬리피지와 CD ETF의 보수적 세금 근사를 반영했지만, ETF 분배금·실시간 호가·미체결은 재현하지 못했습니다. 이미 살펴본 과거 자료로 비교한 결과이며 향후 수익을 보장하지 않습니다. -매일 자동으로 돌리려면 `python main.py --mode schedule`을 작업 스케줄러나 systemd에 등록합니다. 적립금은 대시보드의 **적립 기록** 버튼이나 `python tools/record_deposit.py --basket kr_pocket --amount 100000`으로 남깁니다. KIS 모의투자나 알림을 쓸 때만 `.env`를 채우고, `.env`와 계좌 정보는 Git에 올리지 않습니다. +기간별 결과, 비용 3배 조건, 2014년부터의 보조 실험, 2026년 9월까지 확인한 자료와 코드 변경 근거는 [위험 관리 검증 보고서](docs/RISK_REVIEW_20260917.md)에 정리했습니다. -## 안전장치 +## 시작하기 -실전 주문은 다음이 모두 맞아야만 나갑니다. 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건인데 설계대로 안 돌던 경우들)에서 뽑은 원칙입니다. +Python 3.11 또는 3.12를 사용합니다. 아래는 Windows PowerShell 기준입니다. -## 백테스트와 연구 - -```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 # 주식·현금 비중별 낙폭 +```powershell +git clone https://github.com/easygap/quant_trader.git +cd quant_trader +python -m venv .venv +.\.venv\Scripts\python.exe -m pip install -r requirements.txt +Copy-Item config/settings.yaml.example config/settings.yaml +Copy-Item .env.example .env +.\.venv\Scripts\python.exe main.py --mode dashboard ``` -신호형 전략(`scoring` 등)은 정직 평가에서 동일비중 보유 대비 −140%p 뒤져 `paper_only`로 강등돼 있습니다. 백테스트 결과가 나쁘게 보이는 건 버그가 아니라 그 결론 자체입니다. 왜 그런지, 무엇을 시도했는지는 [docs/RESEARCH_LOG.md](docs/RESEARCH_LOG.md)에 날짜별로 남겨 두었습니다. +브라우저에서 [127.0.0.1:8080](http://127.0.0.1:8080)을 엽니다. 새로 설치한 계좌에는 기록이 없습니다. 설정의 `trading.mode`가 `paper`인지 확인한 뒤 아래 순서로 모의투자를 실행할 수 있습니다. + +```powershell +# 주문 계획만 확인 +.\.venv\Scripts\python.exe main.py --mode rebalance --basket kr_pocket --dry-run + +# 모의투자 실행과 자산 기록 +.\.venv\Scripts\python.exe main.py --mode rebalance --basket kr_pocket + +# 같은 조건으로 백테스트 재현 +.\.venv\Scripts\python.exe tools/risk_review.py --as-of 2026-09-17 +``` -## 문서 +매일 자동 실행하려면 `main.py --mode schedule`을 사용합니다. KIS API 키 등 개인 설정은 `.env`에 넣고 Git에는 올리지 않습니다. 대시보드는 계좌 정보를 표시하므로 기본 설정대로 이 PC에서만 접속해 사용합니다. -- [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) — 미래 정보 누출·생존 편향·비용 반영 내역 +## 자세히 보기 -이 저장소는 수익과 원금을 보장하지 않습니다. 실전 전환 전에 설정과 증권사 계좌를 직접 확인하세요. +- [위험 관리 검증과 한계](docs/RISK_REVIEW_20260917.md) +- [화면 설계·한글 글꼴·성능 측정](docs/DASHBOARD_REVIEW_20260917.md) +- [모의투자 평가 기준](docs/BASKET_PAPER_EVALUATION.md) · [실전 전환 절차](docs/PAPER_TO_LIVE_RUNBOOK.md) +- [거래 안전장치](docs/SAFETY_MODEL.md) · [프로젝트 구조](docs/PROJECT_GUIDE.md) diff --git a/config/baskets.yaml b/config/baskets.yaml index ae0b239..59a9485 100644 --- a/config/baskets.yaml +++ b/config/baskets.yaml @@ -29,25 +29,12 @@ baskets: "006400": 0.10 # 삼성SDI 10% # 합계 = 1.00 (현금 비중 = diversification.min_cash_ratio로 별도 관리) - # --- 분산 대형주 buy&hold (수익성 검증 결론 반영) --- - # docs/PROFITABILITY_FINDINGS.md 결론: 대형주에서 능동 alpha는 없고, 분산 보유(베타)가 - # 현실적 고수익 경로다(실행 기준 ~22% CAGR, Sharpe ~1.0, MDD ~-20%). 능동 리밸런싱은 - # 비용이라 수익을 깎으므로 회전을 최소화한다 — 그래서 분기(quarterly) + 넓은 드리프트 - # 임계값(8%p) + 낮은 회전 상한으로 "사서 들고 있기"에 가깝게 운용한다. - # 2026-06-10 paper 운영 개시: trading.mode=paper에서 트랙레코드(60영업일) 축적 목적. - # paper에서는 실주문이 없고, live 전환은 별도로 ENABLE_LIVE_TRADING + --confirm-live + - # 바스켓별 readiness gate(basket_rebalance:kr_diversified_hold)를 모두 통과해야 한다. - # 2026-08-07 튜닝: 운영 2개월 실측을 반영해 두 가지를 명시로 바꿨다. - # (1) target_stock_weight 0.60 — 종전에는 '주식 80%(=1-min_cash_ratio)'가 설계였는데 - # 실효 배치율은 59%였다(하이닉스 슬롯 미충족 + 정수주 절사 + 추가매수 영구 거부 - # 버그). 그 결과 6/10~8/07 KOSPI -18.6% 구간에서 총자산은 -5.0%로 막혔다 — - # 현금 완충이 방어의 주원인이었다. 그 완충을 '버그의 부작용'으로 두지 않고 - # 의도한 정책으로 고정한다. 상승 전환 시 80% 대비 수익은 덜 먹는 대가다. - # (2) risk 블록 — 종전에는 전역 단타 기준(-3% 손절/+8% 익절/-5% 트레일링)이 진입 - # 시 기록되기만 하고 일일 사이클이 평가하지 않아, 손절선을 뚫은 포지션이 그대로 - # 방치됐다. 트랙 성격에 맞는 폭으로 다시 잡고 실제로 평가한다(core/basket_risk.py). + # --- 대형주 분산 투자 --- + # 주식 60%·현금 40%, 비중 8%p 이탈 시 조정한다. 기존 운용 규칙을 유지한다. + # 특정 과거 구간의 높은 수익률을 앞으로의 기대수익률로 사용하지 않는다. + # 기존 매입 이후 기록은 보존하며, 새로운 위험 관리 검증은 docs/RISK_REVIEW_20260917.md 참고. kr_diversified_hold: - name: "분산 대형주 buy&hold (저회전)" + name: "대형주 분산 투자" enabled: true primary: false purpose: "장기 관찰용" @@ -70,10 +57,8 @@ 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. + # 이 계좌의 위험 조절 규칙은 기존대로 꺼 둔다. + # ETF 적립 계좌에서의 개선 결과를 개별 주식 계좌에 그대로 적용하지 않는다. overlays: drawdown_guard: enabled: false @@ -167,10 +152,14 @@ baskets: # 월 적립은 웹 대시보드 입금 버튼 또는 tools/record_deposit.py --basket kr_pocket # --amount 100000 으로 기록(입금은 TWR이 중화 — 수익률 왜곡 없음). 다음 사이클이 흡수한다. kr_pocket: - name: "소액 적립 (지수+CD금리 파킹)" + name: "ETF 적립" enabled: true primary: true purpose: "월 적립 중심" + promotion: + # 이전 규칙으로 쌓은 운영 일수만으로 새 규칙을 실전에 적용하지 않는다. + paper_only: true + review_note: "9월 17일 변경한 위험 관리 규칙을 모의투자로 검증하고 있습니다." contribution_plan: enabled: true cadence: "monthly" @@ -184,20 +173,13 @@ 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 — 다음 사이클부터 설계 비중으로 되돌아간다. + # 2026-09-17 재검증: 주식과 CD ETF를 함께 줄이던 동작을 수정한다. + # 추세·낙폭 규칙이 겹치면 더 낮은 비중을 한 번만 적용한다. + # 기본: 주식 47.5 / CD 47.5 / 현금 5 → 방어: 주식 23.75 / CD 71.25 / 현금 5. + # 실전 자동 전환 없음. 기간별·비용별 검증과 한계: docs/RISK_REVIEW_20260917.md overlays: + combination: minimum + defensive_symbol: "357870" trend_filter: enabled: true index_symbol: "KS200" diff --git a/core/basket_evaluation.py b/core/basket_evaluation.py index 59f812a..99a02f8 100644 --- a/core/basket_evaluation.py +++ b/core/basket_evaluation.py @@ -139,7 +139,7 @@ def format_evaluation_report(result: dict[str, Any], basket_name: str = "") -> s f" 비용 드래그: 누적 {result['cost_drag_cum']:.4%} | 연환산 {result['cost_drag_annualized']:.4%}" + ( " (기간 미충족 — 초기 매입 일회성 비용이 과장되므로 판정 미적용)" - if result["verdict"] == "WAIT" else "" + if result["progress_days"] < result["min_trading_days"] else "" ), ] if m.get("nav_return_pct") is not None: @@ -540,4 +540,14 @@ def _d(v): result["metrics"]["composition_gap_pct"] = gaps["composition_gap_pct"] result["metrics"]["total_gap_pct"] = gaps["total_gap_pct"] + promotion = basket_cfg.get("promotion") or {} + if promotion.get("paper_only", False): + # 운영 일수는 남겨 두되, 이전 규칙의 기록으로 새 정책이 통과하지 않게 한다. + result["operation_verdict"] = result["verdict"] + result["paper_only"] = True + if result["verdict"] == "PASS_CANDIDATE": + result["verdict"] = "WAIT" + result["issues"].append(str(promotion.get("review_note") or + "변경한 운용 규칙을 모의투자로 검증하고 있습니다. 실전 전환은 제한됩니다.")) + return result, basket_name diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index c3a0027..756486a 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -169,7 +169,11 @@ def _stock_fraction(self) -> float: 배치 진단·트리거·주문 계획이 전부 이 값을 쓰므로, 오버레이가 비중을 줄인 날 '현금 래칫' 감시가 설계 비중을 향해 되사는 일은 생기지 않는다. """ - return self.base_stock_fraction() * self._overlay_scale() + from core.risk_overlays import overlay_target_weights + return sum(overlay_target_weights( + self.holdings, self.base_stock_fraction(), self._overlay_scale(), + (self.basket.get("overlays") or {}).get("defensive_symbol"), + ).values()) def _overlay_scale(self) -> float: decision = self.overlay_decision() @@ -220,7 +224,7 @@ def overlay_decision(self): if decision.reasons: logger.info("바스켓 '{}' 리스크 오버레이 발동: {} (배수 {})", self.basket_name, " · ".join(decision.reasons), decision.scale) else: - logger.info("바스켓 '{}' 리스크 오버레이 발동 없음 (배수 1.0)", self.basket_name) + logger.info("바스켓 '{}' 리스크 오버레이 계산 완료 (배수 {})", self.basket_name, decision.scale) self._overlay_decision = decision return decision @@ -245,11 +249,21 @@ def _fetch_index_closes(self, symbol: str, ma_days: int) -> list[float] | 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] + mask = [d < today for d in parsed] + frame = frame[mask].copy() + frame["_overlay_date"] = [d for d, keep in zip(parsed, mask) if keep] + frame = frame.sort_values("_overlay_date") + if frame["_overlay_date"].duplicated().any(): + return None except Exception: - pass - closes = [float(c) for c in frame["close"].dropna().tolist()] + return None + else: + return None + # 누락 봉을 삭제하면 이동평균 창이 과거로 밀려 잘못 복귀할 수 있다. + try: + closes = [float(c) if c is not None else float("nan") for c in frame["close"].tolist()] + except (TypeError, ValueError): + return None return closes or None def _nav_series_for_overlay(self) -> tuple[list[float] | None, list[float] | None]: @@ -264,9 +278,17 @@ def _nav_series_for_overlay(self) -> tuple[list[float] | None, list[float] | Non return None, None if snaps is None or getattr(snaps, "empty", True) or "cumulative_return" not in snaps.columns: return None, None - cumulative = [float(v) for v in snaps["cumulative_return"].tolist() if v is not None] + if "date" in snaps.columns: + import pandas as pd + snaps = snaps.copy() + snaps["date"] = pd.to_datetime(snaps["date"], errors="coerce") + snaps = snaps[snaps["date"].dt.date < datetime.now(_KST).date()].sort_values("date") + try: + cumulative = [float(v) if v is not None else float("nan") for v in snaps["cumulative_return"].tolist()] + except (TypeError, ValueError): + return None, 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] + daily = [index[i] / index[i - 1] - 1.0 if index[i - 1] > 0 else float("nan") for i in range(1, len(index))] return cumulative, daily def _is_live(self) -> bool: @@ -374,7 +396,13 @@ def get_target_weights(self) -> dict[str, float]: if total > 0 and abs(total - 1.0) > 0.001: base = {s: w / total for s, w in base.items()} - return base + from core.risk_overlays import overlay_target_weights + targets = overlay_target_weights( + base, self.base_stock_fraction(), self._overlay_scale(), + (self.basket.get("overlays") or {}).get("defensive_symbol"), + ) + invested = sum(targets.values()) + return {s: w / invested for s, w in targets.items()} if invested > 0 else base def get_current_weights(self, prices: dict[str, float] = None) -> dict[str, float]: """ diff --git a/core/live_readiness.py b/core/live_readiness.py index c89f24f..85f5643 100644 --- a/core/live_readiness.py +++ b/core/live_readiness.py @@ -42,6 +42,11 @@ def check_basket_live_readiness(config, strategy_name: str) -> list[str]: issues.append( f"바스켓 '{basket_name}'이 enabled=false — paper 운영(트랙레코드)부터 시작하세요." ) + if (basket.get("promotion") or {}).get("paper_only", False): + issues.append( + f"바스켓 '{basket_name}'은 모의투자 전용(paper_only)입니다. " + "변경한 운용 규칙을 검증하기 전에는 실전으로 전환할 수 없습니다." + ) holdings = basket.get("holdings", {}) or {} total_w = sum(float(w) for w in holdings.values()) if abs(total_w - 1.0) > 0.001: diff --git a/core/risk_overlays.py b/core/risk_overlays.py index 47fea32..99a5e5e 100644 --- a/core/risk_overlays.py +++ b/core/risk_overlays.py @@ -1,22 +1,10 @@ -"""바스켓 주식 비중 리스크 오버레이 — 추세 필터·낙폭 제어·변동성 목표. - -이 저장소의 확정 결론은 "종목 선택으로 시장을 이길 알파는 없다"이고, 손익을 가르는 것은 -주식 비중이다. 그래서 여기서는 종목을 고르지 않고, **설계 주식 비중에 곱할 배수(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건인데 설계대로 안 돈다"를 막기 위해). +"""추세·낙폭·변동성에 따라 주식 목표 비중을 조절한다. + +전일까지의 자료와 직전 상태로 위험 배수(0~1)를 계산한다. product는 기존처럼 +배수를 곱하고, minimum은 가장 낮은 배수 하나를 사용한다. 방어 자산이 지정돼 +있으면 줄인 주식 비중을 해당 자산에 배분한다. 자료가 부족하면 확대를 보류한다. + +검증 근거와 한계: docs/RISK_REVIEW_20260917.md. """ from __future__ import annotations @@ -64,6 +52,7 @@ class OverlayConfig: trend: TrendFilterConfig = TrendFilterConfig() drawdown: DrawdownGuardConfig = DrawdownGuardConfig() volatility: VolatilityTargetConfig = VolatilityTargetConfig() + combination: str = "product" @property def any_enabled(self) -> bool: @@ -89,7 +78,8 @@ def to_dict(self) -> dict[str, Any]: def _f(value: Any, default: float) -> float: try: - return float(value) + number = float(value) + return number if math.isfinite(number) else float(default) except (TypeError, ValueError): return float(default) @@ -123,7 +113,10 @@ def parse_overlay_config(basket_cfg: dict[str, Any] | None) -> OverlayConfig: 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) + combination = str(raw.get("combination", "product")) + if combination not in {"product", "minimum"}: + raise ValueError("overlays.combination은 product 또는 minimum이어야 합니다") + return OverlayConfig(trend=trend, drawdown=drawdown, volatility=volatility, combination=combination) # ------------------------------------------------------------------ @@ -138,7 +131,14 @@ def trend_below_ma( 반환 (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))] + closes = list(index_closes) + if len(closes) >= cfg.ma_days: + try: + closes = [float(c) for c in closes[-cfg.ma_days:]] + except (TypeError, ValueError): + return prev_below, None + if any(not math.isfinite(c) or c <= 0 for c in closes): + return prev_below, None if len(closes) < cfg.ma_days: return prev_below, None window = closes[-cfg.ma_days:] @@ -161,13 +161,13 @@ def drawdown_from_cumulative_returns(cumulative_returns_pct: Sequence[float]) -> last = None for cr in cumulative_returns_pct: if cr is None: - continue + return None try: idx = 1.0 + float(cr) / 100.0 except (TypeError, ValueError): - continue - if math.isnan(idx): - continue + return None + if not math.isfinite(idx) or idx <= 0: + return None peak = max(peak, idx) last = idx if last is None: @@ -189,7 +189,12 @@ def drawdown_guard_active(drawdown: float | None, cfg: DrawdownGuardConfig, prev 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))] + try: + rets = [float(r) for r in list(daily_returns)[-lookback_days:]] + except (TypeError, ValueError): + return None + if any(not math.isfinite(r) or r <= -1 for r in rets): + return None if len(rets) < lookback_days: return None window = rets[-lookback_days:] @@ -228,6 +233,7 @@ def compute_decision( prev = prev_state or {} decision = OverlayDecision(evaluated_at=(now or datetime.now()).isoformat(timespec="seconds")) scale = 1.0 + scales = [] if cfg.trend.enabled: below, rel = trend_below_ma(index_closes or [], cfg.trend, prev.get("trend_below")) @@ -239,6 +245,7 @@ def compute_decision( decision.trend_below = below if below: scale *= cfg.trend.off_scale + scales.append(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 "") @@ -254,6 +261,7 @@ def compute_decision( decision.drawdown_active = active if active: scale *= cfg.drawdown.scale + scales.append(cfg.drawdown.scale) decision.reasons.append( f"고점 대비 낙폭 {dd:+.1%}" if dd is not None else "낙폭 제어 발동 중" ) @@ -263,17 +271,28 @@ def compute_decision( 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") + decision.data_issues.append("변동성 계산에 필요한 일간 수익률이 부족해 비중 확대를 보류했습니다") 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 + scales.append(vs) decision.reasons.append( f"실현 변동성 {vol:.0%} > 목표 {cfg.volatility.target:.0%} → 주식 비중 ×{vs:g}" if vol is not None else f"변동성 목표 배수 ×{vs:g}" ) + if cfg.combination == "minimum": + scale = min(scales, default=1.0) + if decision.data_issues: + # 자료가 없다는 이유로 직전보다 투자 위험을 늘리지 않는다. + previous_scale = _f(prev.get("scale"), min( + [1.0] + ([cfg.trend.off_scale] if cfg.trend.enabled else []) + + ([cfg.drawdown.scale] if cfg.drawdown.enabled else []) + + ([cfg.volatility.min_scale] if cfg.volatility.enabled else []) + )) + scale = min(scale, previous_scale) decision.scale = round(min(1.0, max(0.0, scale)), 4) return decision @@ -281,18 +300,46 @@ def compute_decision( def describe_decision(decision: OverlayDecision | dict[str, Any] | None, cfg: OverlayConfig | None = None) -> str: """대시보드·헬스용 한 줄 설명.""" if decision is None: - return "위험 조절 상태 없음 — 첫 실행 대기" + 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 = "위험 조절 발동 없음 · 설계 비중 그대로" + text = "자료 확인 전 비중 확대 보류" if issues else "기본 투자 비중 유지" else: text = " · ".join(parts) if issues: text += " · 확인 필요: " + "; ".join(issues) - return f"{text} (배수 {scale:g})" + return f"{text}. 주식 목표 비중은 기본 설정의 {scale:.0%}입니다" + + +def overlay_target_weights( + weights: dict[str, float], design_fraction: float, scale: float, + defensive_symbol: str | None = None, +) -> dict[str, float]: + """총자산 기준 목표 비중. 방어 자산이 있으면 주식 축소분을 그 자산에 배분한다. + + 예: 주식 47.5%, CD ETF 47.5%, 현금 5%에서 위험 배수 0.5는 + 주식 23.75%, CD ETF 71.25%, 현금 5%다. CD ETF를 주식처럼 매도하지 않는다. + 방어 자산 미지정 시에는 기존처럼 모든 보유 비중을 줄여 현금으로 둔다. + """ + clean = {str(s): float(w) for s, w in weights.items()} + if any(not math.isfinite(w) or w < 0 for w in clean.values()): + raise ValueError("목표 비중은 유한한 0 이상의 값이어야 합니다") + total = sum(clean.values()) + if total <= 0: + return {} + if not math.isfinite(design_fraction) or not 0 <= design_fraction <= 1: + raise ValueError("투자 비중은 0~1 사이여야 합니다") + if not math.isfinite(scale) or not 0 <= scale <= 1: + raise ValueError("위험 배수는 0~1 사이여야 합니다") + if defensive_symbol and clean.get(defensive_symbol, 0) <= 0: + raise ValueError("방어 자산은 보유 비중에 포함된 종목이어야 합니다") + target = {s: w / total * design_fraction * scale for s, w in clean.items()} + if defensive_symbol: + target[defensive_symbol] += design_fraction * (1.0 - scale) + return target # ------------------------------------------------------------------ diff --git a/docs/DASHBOARD_REVIEW_20260917.md b/docs/DASHBOARD_REVIEW_20260917.md new file mode 100644 index 0000000..77bad71 --- /dev/null +++ b/docs/DASHBOARD_REVIEW_20260917.md @@ -0,0 +1,93 @@ +# 계좌 화면 설계와 확인 결과 + +기준일: 2026-09-17 + +요청한 방향은 계좌 화면을 중심으로 다시 구성하고, 제시한 영상 스크롤 예시에서는 글꼴의 위계·여백·짧은 전환만 참고하는 것이었습니다. 기존 Python·aiohttp·SQLite와 순수 HTML/CSS/JavaScript 구조를 유지했습니다. + +## 화면 구성 + +첫 화면에서 계좌를 고르고 총자산·투자원금·누적 수익률·최대 낙폭을 확인합니다. 아래에는 자산 추이, 보유 종목, 모의투자 검증, 자동매매 상태가 이어집니다. 데스크톱 오른쪽에는 확인할 일과 자산 구성을 두고, 모바일에는 중요한 안내와 현금 잔액을 먼저 보여 줍니다. + +![계좌 현황](images/dashboard-account-20260917.png) + +![종목별 매입금액과 목표 비중](images/dashboard-holdings-20260917.png) + +
+모바일 화면 + +390px 모바일 계좌 현황 + +
+ +사진처럼 만든 예시 화면이나 가공한 수익률 대신 실제 모의투자 API 응답을 캡처했습니다. 촬영 중 주문이나 적립금 기록을 전송하지 않았습니다. + +## 글꼴·색·문구 + +| 항목 | 선택 | +|---|---| +| 글꼴 | Pretendard 1.3.9 기반 로컬 서브셋. 배포 이름 Nungum UI, 굵기 400~700 | +| 크기 | 본문 14px, 제목 23~26px, 주요 잔액 29~40px, 계좌 핵심 수치 19~27px, 보조 표기 최소 11px | +| 숫자 | `tabular-nums`, 금액 열 오른쪽 정렬, 원화·주·% 단위 명시 | +| 바탕 | 연한 회색 `#f5f6f8`, 콘텐츠 면은 흰색 | +| 본문·선택 | 본문 `#202b3d`, 선택·행동 `#3157a4` | +| 손익 | 수익 `#c4383c`, 손실 `#2563c7`. 색과 함께 +/− 부호를 표시 | +| 한글 | `word-break: keep-all`, 긴 항목은 필요한 곳에서 줄바꿈 | +| 움직임 | 버튼 색·상태 전환 정도만 사용. 모션 줄이기 설정 존중 | + +“계좌 현황”, “보유 종목”, “적립금 기록”, “고점 대비 하락률”처럼 화면에서 실제로 하는 일을 적었습니다. 모의투자 적립과 실제 입금은 설명을 나누고, 확인 창에는 계좌·금액·구분을 명시했습니다. 수익률을 부풀리는 홍보 문구는 사용하지 않았습니다. + +폰트는 약 431KiB이며 외부 글꼴 서버를 호출하지 않습니다. [글꼴 출처와 수정 범위](../monitoring/static/fonts/README.md), [OFL 라이선스](../monitoring/static/fonts/OFL.txt)를 함께 제공합니다. + +## 2026년 9월까지 확인한 자료 + +- [토스의 라이팅 원칙](https://toss.tech/article/8-writing-principles-of-toss), 2022-11-15: 짧고 자연스러운 한국어와 행동이 분명한 문구를 참고했습니다. 이를 2026년에 새로 나온 원칙으로 소개하지 않습니다. +- [토스 브랜드 디자인](https://toss.tech/article/43061), 2025: 금융 화면의 색과 글꼴을 일관되게 사용하는 사례를 살폈습니다. 토스 전용 글꼴이나 로고를 사용하지 않았습니다. +- [Pretendard 공식 저장소](https://github.com/orioncactus/pretendard): 한글과 라틴 숫자를 함께 다루는 글꼴과 배포 조건을 확인했습니다. +- [Web Platform Baseline](https://web.dev/baseline), [2026년 7월 Baseline 정리](https://web.developers.google.cn/blog/baseline-digest-jul-2026): 새 기능의 지원 범위를 확인했습니다. 이 화면에는 새 스크롤 타임라인이나 추가 프레임워크가 필요하지 않았습니다. +- [web.dev 애니메이션 가이드](https://web.dev/articles/animations-guide), [웹폰트 로드](https://web.dev/articles/optimize-webfont-loading): 불필요한 레이아웃 변경과 외부 요청을 줄이는 근거로 사용했습니다. + +유행하는 시각 효과를 넣는 것보다 계좌 정보를 정확히 읽고 조작할 수 있는 구성을 우선했습니다. 배경 영상·WebGL·3D 라이브러리·스크롤 제어 라이브러리는 추가하지 않았습니다. + +## 성능 측정 + +Chrome 153, 390×844 뷰포트, CPU 4배 감속, 다운로드 1.6Mbps, 지연 150ms, 브라우저 캐시를 끈 상태에서 로컬 서버에 접속했습니다. Playwright로 브라우저를 조작하고 Chrome DevTools Protocol과 PerformanceObserver로 측정했습니다. Chrome DevTools MCP는 기존 프로필과 충돌해 실행되지 않아, 이미 연결된 검증용 브라우저에서 같은 브라우저 API를 사용했습니다. + +| 항목 | 마지막 측정 | +|---|---:| +| 첫 콘텐츠 표시(FCP) | 0.628초 | +| 가장 큰 콘텐츠 표시(LCP) | 0.628초 | +| 계좌·차트 데이터 표시 | 1.64초 | +| 레이아웃 이동량(CLS) | 0.034 | +| 관찰한 주요 조작의 Event Timing | 40~56ms | +| 50ms 초과 긴 작업의 초과분 합계 | 118ms | +| 로드 후 1.2초 유휴 구간의 rAF 예약 | 0회 | +| HTML·정적 파일·초기 API 응답 전송 합계 | 약 528KiB | +| CSS + JS 전송 본문 | 약 26.6KiB | +| 외부 출처 리소스 요청 | 0건 | + +LCP는 계좌 데이터가 모두 준비되는 시간과 다릅니다. 한글 폰트는 이 조건에서 약 2.8초가 걸렸으며, 그동안 시스템 글꼴로 먼저 표시합니다. 글꼴 적용 과정에 긴 작업도 관찰됐습니다. CSS·JS·SVG 압축과 정적 파일 재검증을 적용했고, 계좌 API의 `no-store`는 유지했습니다. + +이 숫자는 **한 PC에서 수행한 실험실 측정**입니다. Event Timing 표본은 실제 사용자 전체의 INP가 아니며, 긴 작업 초과분도 Lighthouse가 계산한 TBT 점수와 동일하지 않습니다. [Web Vitals 공식 설명](https://web.dev/articles/vitals)처럼 실험실 결과와 실제 기기·네트워크의 체감은 구분해야 합니다. 모든 기기에서 지연이 0이라고 보장하지 않습니다. + +[원본 측정 JSON](../reports/research/dashboard_performance_20260917.json)에 환경, 요청별 전송량, 긴 작업과 입력 시간 표본을 남겼습니다. 화면에는 성능 수집 코드를 추가하지 않았습니다. + +## 화면에서 확인한 항목 + +- 320·390·768·1024·1440px와 844×390 가로 화면에서 문서 가로 넘침 없음 +- 주요 버튼 높이 36px 이상, 보조 텍스트 11px 이상 +- 계좌를 바꾸면 잔액·차트·보유 종목·확인할 일·적립금 입력 대상이 함께 변경 +- 3개월에서 1개월로 바꿔도 같은 달의 월 수익률은 같음 +- 조회 기간 바깥의 고점도 낙폭 계산에 반영 +- 적립금을 과거 복원 행의 저장 시각으로 당겨 반영하지 않음 +- 숨긴 계좌 선택기가 키보드 포커스를 받지 않음 +- 대화상자 취소·Escape 동작, 닫은 뒤 실행 버튼으로 포커스 복귀 +- 320px에서 적립금 입력 창이 화면 안에 표시됨 +- API 실패 시 오류 안내와 적립금 기록 제한, 폰트 실패 시 대체 글꼴 표시 +- `prefers-reduced-motion`에서 버튼 전환 제거 +- 실제 데이터로 다시 로드한 화면의 JavaScript 오류·경고 0건 + +iOS Safari 실기기와 다양한 Android 기기의 현장 성능은 이번 로컬 검사에 포함하지 않았습니다. 안전 영역과 터치 크기·입력 동작은 CSS 및 브라우저 뷰포트 검사로 확인했습니다. + +## 유지한 구조 + +`monitoring/templates/dashboard.html`, `monitoring/static/dashboard.css`, `monitoring/static/dashboard.js`와 기존 aiohttp 서버를 사용합니다. JS는 한 IIFE로 실행하며 새 프런트엔드 런타임 의존성이나 빌드 단계를 추가하지 않았습니다. 차트는 Canvas, 자산 구성은 CSS로 표시합니다. 유휴 상태에서 계속 도는 애니메이션 루프는 없습니다. diff --git a/docs/PROFITABILITY_FINDINGS.md b/docs/PROFITABILITY_FINDINGS.md index 0faf25f..060ff5b 100644 --- a/docs/PROFITABILITY_FINDINGS.md +++ b/docs/PROFITABILITY_FINDINGS.md @@ -1,17 +1,10 @@ -# 수익성 정직 점검 결과 (먼저 읽을 것) +# 종목 선택 전략 비교 — 2026년 6월 실험 기록 -> 목적: 이 시스템으로 "안정적 고수익"이 가능한지, 지금까지의 정직한 검증 결과를 한 곳에 -> 모은다. 헤드라인 백테스트 수치만 보고 실거래에 들어가는 것을 막기 위한 문서다. -> 최종 갱신: 2026-06-01. +> 아래 수치는 당시 데이터와 실험 조건에서 나온 과거 결과입니다. "안정적 고수익이 가능하다", "알파는 없다"는 기존의 일반화는 철회합니다. 일부 전략이 비교군에 뒤졌다는 사실만으로 미래 수익성이나 모든 능동 전략의 한계를 단정할 수 없습니다. 2026년 9월 변경과 새 검증은 [위험 관리 검증 보고서](RISK_REVIEW_20260917.md)를 보세요. -## 한 줄 결론 (2026-06-01 최종) +## 이 실험에서 확인한 것 -**"안정적 고수익"은 가능하다 — 단, 능동 전략이 아니라 분산된 대형주 단순 보유(buy&hold)로.** -동일비중 대형주 30종목 buy&hold는 2023~25년 +123.8%(≈30% CAGR), Sharpe 1.24였다. 이걸 -**이기려는** 모든 능동 시도(모멘텀 40여 변형, 저변동성 선택, risk-parity 비중, SMA risk-off)는 -공정한 넓은 유니버스에서 전부 이걸 **깎았다**(초과수익 음수, 또는 비용 때문에 절대수익 하락). -즉 **알파는 없지만 베타(시장 보유)는 충분히 좋은 수익**이다. 능동 운용으로 그걸 넘으려 하지 말고, -저비용으로 그 베타를 안정적으로 담는 게 이 시스템의 현실적 "고수익" 경로다. +2023~2025년 비교에서 대형주 30종목 동일비중 보유는 +123.8%를 기록했습니다. 여기서 시험한 모멘텀·저변동성·위험 균형·이동평균 규칙은 해당 비교군을 넘지 못했습니다. 아래의 비용·기간·유니버스 조건을 함께 읽어야 하는 결과입니다. > 중요(실행 가능성): 위 +123.8%는 **무비용·매수후보유(frictionless buy-once-hold)** 벤치마크다. > 같은 동일비중을 월 1회 리밸런싱 + 익일 시가 체결 + 수수료/세금/슬리피지로 **실제 실행**하면 @@ -165,8 +158,7 @@ buy&hold 바스켓은 능동 알파 전략의 60일 evidence 승격 절차를 ** ## 그래서 다음에 뭘 해야 하나 -데이터의 일관된 메시지: **알파는 없지만 베타(분산 대형주 보유)는 충분히 좋은 수익이다.** -그래서 "고수익"의 현실적 경로는 능동 운용이 아니라 **저비용 분산 보유**다. +이 표본에서는 저회전 분산 보유의 성과가 더 좋았습니다. 다른 기간과 자산에서도 같은 결과가 나오는지는 별도의 검증이 필요합니다. **권장(배포 가능한 경로):** 1. **분산 대형주 buy&hold를 기본 운용으로**: 30종목 동일비중 보유 자체가 ~22% CAGR(실행 기준), diff --git a/docs/RISK_OVERLAY_FINDINGS.md b/docs/RISK_OVERLAY_FINDINGS.md index 4ee5b8a..d763df3 100644 --- a/docs/RISK_OVERLAY_FINDINGS.md +++ b/docs/RISK_OVERLAY_FINDINGS.md @@ -1,109 +1,9 @@ -# 리스크 오버레이 검증 — 주식 비중을 언제 줄이면 손실이 얼마나 줄어드는가 +# 위험 관리 검증 자료 안내 -> 작성 2026-09-17. 재현: `python tools/risk_overlay_backtest.py` → `reports/research/risk_overlay_backtest.{md,json}`, 그림은 `docs/images/overlay-*.png`. +2026년 9월 17일 재검증 과정에서 이전 연구용 백테스트의 적립금·낙폭 처리 오류를 확인했습니다. 기존 표에 있던 낙폭과 위험 조절 결과는 현재 정책의 근거로 사용하지 않습니다. -## 왜 이 실험인가 +최신 자료는 [ETF 적립 계좌 위험 관리 검증](RISK_REVIEW_20260917.md)입니다. 실제 ETF 1주 단위, 같은 적립·비용 조건에서 고정 비중·기존 방식·변경한 방식을 비교했고, 불리했던 기간도 함께 공개했습니다. -이 저장소의 확정 결론([PROFITABILITY_FINDINGS.md](PROFITABILITY_FINDINGS.md))은 두 줄이다. +![실제 ETF 비교](images/risk-review-20260917.png) -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` 한 줄이다. 다음 사이클부터 설계 비중으로 돌아간다. +원본 수치는 [결과 JSON](../reports/research/risk_review_20260917.json), 재현 코드는 [tools/risk_review.py](../tools/risk_review.py)에 있습니다. 이전 문서는 Git 이력에서 확인할 수 있습니다. diff --git a/docs/RISK_REVIEW_20260917.md b/docs/RISK_REVIEW_20260917.md new file mode 100644 index 0000000..1550169 --- /dev/null +++ b/docs/RISK_REVIEW_20260917.md @@ -0,0 +1,125 @@ +# ETF 적립 계좌 위험 관리 검증 + +기준일: **2026-09-17** · 마지막 확정 종가: **2026-09-16** + +이번 변경은 적립금 처리와 목표 비중 계산을 바로잡고, 같은 조건에서 운용 방식을 비교한 결과를 반영한 것입니다. 새 규칙이 앞으로도 이익을 낸다는 검증은 아직 없습니다. ETF 적립 계좌는 모의투자 전용으로 두었습니다. + +## 먼저 확인한 문제 + +1. **적립금이 낙폭 판단에 섞였습니다.** 기존 연구용 백테스트는 매매 판단 중에는 원화 평가금액을 사용하고, 모든 거래가 끝난 뒤 수익률을 보정했습니다. 따라서 입금 후에도 남아 있어야 할 손실이 사라진 것처럼 판단할 수 있었습니다. 매일 시간가중수익률(TWR)을 계산하고 그 값을 다음 날 판단에 사용하도록 수정했습니다. +2. **두 방어 조건이 같은 위험을 두 번 줄였습니다.** 추세와 낙폭 조건이 함께 켜지면 0.5 × 0.5 = 0.25를 적용했습니다. 새 규칙은 둘 중 더 낮은 배수인 0.5를 한 번 적용합니다. 이것이 항상 더 안전하다는 뜻은 아닙니다. 주식 비중을 덜 줄이는 만큼 반등 참여와 하락 노출이 모두 커집니다. +3. **CD금리 ETF도 주식과 함께 줄였습니다.** 주식과 방어 자산에 같은 배수를 곱해 현금이 과도하게 늘어날 수 있었습니다. 방어 자산을 명시하고 주식 축소분만 그 자산에 배분하도록 바꿨습니다. +4. **체결 제약을 더 엄격하게 적용했습니다.** 연구용 모형에서 수수료를 포함한 매수 가능 금액, 매도 가능한 수량을 제한했습니다. 별도 비교에서는 소수 주 대신 실제 ETF 1주 단위와 최소 주문금액을 사용했습니다. +5. **당일 미완성 데이터는 신호에서 제외했습니다.** 실행 코드와 연구 모두 전일까지의 자료로 판단합니다. 시세·수익률 자료가 빠지거나 유효하지 않으면 위험 비중을 확대하지 않습니다. + +입금 시점이 하루 시작일 때 일 수익률은 `당일 평가금액 / (전일 평가금액 + 당일 적립금) - 1`이고, 이 수익률을 복리로 연결합니다. 입금 시점이 다른 실거래에서는 해당 현금 흐름 시점의 평가가 추가로 필요합니다. 이 연구의 월 적립은 월 첫 거래일 시작에 이루어진다고 가정합니다. 시간가중수익률 원칙은 [GIPS 기준 해설](https://www.gipsstandards.org/standards/gips-standards-for-firms/gips-standards-handbook-for-firms/)을 참고했습니다. + +## 운용 규칙 + +| 항목 | 값 | +|---|---| +| 기본 구성 | KODEX 200 `069500` 47.5% · TIGER CD금리 ETF `357870` 47.5% · 현금 5% | +| 추세 조건 | 코스피200의 200일 평균 대비 −2% 아래에서 축소, +2% 위에서 복귀 | +| 낙폭 조건 | TWR 고점 대비 −10%에서 축소, −5% 안으로 회복하면 복귀 | +| 축소 배수 | 0.5. 두 조건이 겹쳐도 0.5를 한 번 적용 | +| 방어 시 목표 | 주식 ETF 23.75% · CD금리 ETF 71.25% · 현금 5% | +| 리밸런싱 | 투자 자산 내부 종목 비중 8%p 또는 총 투자 비중 3%p 이탈 | +| 주문 제약 | 1주 단위, 최소 5만원, 한 번의 총 매매금액은 총자산의 60% 이내 | +| 실전 전환 | `promotion.paper_only: true`. 기존 운영 일수로 새 규칙이 자동 통과하지 않음 | + +이전 상태가 없는 첫 추세 판단은 200일선 위·아래로 초기화하고, 이후부터 ±2% 복귀 조건을 적용합니다. 200일·2%·−10%·−5% 등 기존 임계값은 유지했습니다. 이 보고서의 수익률을 높이려고 임계값 조합을 탐색하지 않았습니다. 대형주 분산 계좌의 위험 조절 규칙은 계속 꺼 두었습니다. + +코드의 `minimum`은 위험 배수 중 최솟값, `defensive_symbol`은 주식 축소분을 받을 자산입니다. `product`는 기존 동작과의 호환을 위해 남겨 두었습니다. 주문 계획과 화면의 목표 비중은 같은 `overlay_target_weights()` 함수를 사용합니다. + +## 실제 ETF·정수 주 비교 + +기간은 CD ETF의 데이터가 시작하는 **2020-07-07~2026-09-16**입니다. 30만원에서 시작해 다음 달부터 매월 첫 거래일에 10만원을 더했습니다. 총 적립 원금은 770만원입니다. 각 방식의 매매는 독립된 가상 계좌에서 계산했습니다. + +| 방식 | 연환산 수익률 | 최대 낙폭 | 샤프 | +|---|---:|---:|---:| +| 고정 비중 | 13.54% | -21.72% | 0.80 | +| 기존 위험 관리¹ | 10.74% | -16.02% | 0.74 | +| 변경한 위험 관리 | 12.97% | -15.74% | 0.90 | + +![시간가중 지수와 고점 대비 하락률](images/risk-review-20260917.png) + +기존 위험 관리는 TWR 오류를 수정한 후의 `product` 방식입니다. 새 방식과 비교할 때도 데이터·적립·비용·정수 주·최소 거래금액 조건은 같습니다. 고정 비중은 위험 조건에 따른 축소만 하지 않는 비교군입니다. + +최종 평가금액은 고정 비중 13,876,896원, 기존 방식 12,256,469원, 변경한 방식 13,322,718원입니다. 원금 유입이 있는 계좌이므로 이 원화 금액의 증가율과 TWR 연환산 수익률은 서로 다릅니다. + +### 기간을 나눠 보면 + +| 기간 | 방식 | 기간 수익률(TWR) | 최대 낙폭 | +|---|---|---:|---:| +| 2020.07~2022 | 고정 비중 | 5.16% | -16.25% | +| 2020.07~2022 | 기존 위험 관리¹ | 10.90% | -10.05% | +| 2020.07~2022 | 변경한 위험 관리 | 12.88% | -9.21% | +| 2023~2025 | 고정 비중 | 54.60% | -9.51% | +| 2023~2025 | 기존 위험 관리¹ | 27.16% | -7.86% | +| 2023~2025 | 변경한 위험 관리 | 41.05% | -8.80% | +| 2026.01~09.16 | 고정 비중 | 35.03% | -21.72% | +| 2026.01~09.16 | 기존 위험 관리¹ | 33.40% | -16.02% | +| 2026.01~09.16 | 변경한 위험 관리 | 33.66% | -15.74% | + +부분 기간은 연환산하지 않은 누적 수익률입니다. 각 구간의 시작을 100으로 다시 놓아 낙폭을 계산했습니다. 구간 시작에 계좌를 새로 만들거나 규칙을 재학습한 실험은 아닙니다. + +**반례도 있습니다.** 2023~2025년 새 방식의 최대 낙폭은 −8.80%로, 기존 방식의 −7.86%보다 컸습니다. 전체 기간 수익률은 고정 비중이 가장 높았습니다. 새 방식은 언제나 수익률과 낙폭이 함께 좋아지는 규칙이 아닙니다. + +### 비용을 3배로 높이면 + +수수료와 슬리피지만 각각 3배로 높였습니다. 세율은 그대로입니다. + +| 방식 | 연환산 수익률 | 최대 낙폭 | +|---|---:|---:| +| 고정 비중 | 13.42% | -21.75% | +| 기존 위험 관리¹ | 10.27% | -16.11% | +| 변경한 위험 관리 | 12.68% | -15.73% | + +## 2014년부터의 보조 비교 + +CD ETF 상장 이전까지 확인하기 위해 KODEX 200 한 종목과 가정 현금을 비교했습니다. **이 표는 소수 주 매매를 허용한 연구용 모형이며 실제 ETF 두 종목 계좌와 다릅니다.** 주식 목표는 50%, 현금금리는 연 3%로 가정했습니다. 새 규칙 항목에서는 중복 축소 방지만 비교하며 CD ETF로 이동하는 효과는 없습니다. + +| 방식 | 연 3% 현금 가정 CAGR | 현금 이자 0% CAGR | 연 3% 현금 가정 최대 낙폭 | +|---|---:|---:|---:| +| 고정 비중 | 9.59% | 8.06% | -21.73% | +| 기존 위험 관리¹ | 8.65% | 5.90% | -16.42% | +| 변경한 위험 관리 | 9.08% | 6.39% | -16.42% | + +기간은 2014-06-30~2026-09-16입니다. 장기 비교에서도 고정 비중의 수익률이 더 높았습니다. 현금금리를 0%로 두면 방어 비중이 큰 방식의 수익률이 더 많이 낮아졌습니다. + +## 비용·데이터와 한계 + +- FinanceDataReader로 받은 종가를 사용했습니다. 데이터 끝은 9월 16일로 고정하고, 다운로드한 파일은 `data/research_prices/`에 캐시합니다. 사용한 시계열의 첫날·마지막 날·행 수·SHA-256은 [결과 JSON](../reports/research/risk_review_20260917.json)에 있습니다. 원천 공급자의 과거 가격 보정이 바뀌면 다시 받은 자료의 해시가 달라질 수 있습니다. +- 실제 ETF 비교는 매매 수수료 0.015%, 슬리피지 0.05%를 편도에 반영했습니다. CD ETF 매도 시 양의 매매차익에 15.4%를 차감했습니다. 과표기준가를 확보하지 못해 사용한 **상한 근사**이며 실제 세금과 다를 수 있습니다. [전국투자자교육협의회 ETF 세금 안내](https://www.kcie.or.kr/mobile/guide/3/18/web_view?series_idx=&content_idx=900)를 참고했습니다. +- KODEX 200 분배금, 호가 차이, 거래 시점별 괴리율, 장중 가격 경로, 체결 지연·미체결을 재현하지 못했습니다. 실제 주문 엔진을 그대로 재생한 결과가 아닙니다. +- 비교 샤프의 기준금리는 연 3% 고정입니다. 실제 기간별 무위험금리를 사용한 값이 아닙니다. +- 2014년 보조 실험은 소수 주·가정 금리를 사용합니다. 정수 주 ETF 비교와 수익률 숫자를 직접 이어 붙이면 안 됩니다. +- 이 자료는 이미 살펴본 기간을 다시 비교한 **사후 검증**입니다. 기간을 나눴다는 이유만으로 독립적인 미사용 표본 검증이나 워크포워드 검증이라고 부르지 않습니다. 새로운 규칙의 실시간 모의투자 성과는 앞으로 따로 확인해야 합니다. + +## 2026년 자료를 어떻게 반영했나 + +| 확인한 자료 | 확인한 내용 | 이 프로젝트에서의 판단 | +|---|---|---| +| [AQR, Inflation Redux?](https://www.aqr.com/Insights/Research/Alternative-Thinking/Inflation-Redux), 2026-09-15 | 인플레이션 불확실성과 주식·채권의 동반 손실 위험, 분산 전략의 구현 차이 | 주식과 방어 자산의 역할을 구분하고 비용과 기간별 결과를 함께 비교. 해당 다자산 선물 전략의 성과를 국내 주식 추세 필터의 근거로 옮기지 않음 | +| [자본시장연구원, 주식시장 변동성 상승의 배경 검토](https://www.kcmi.re.kr/report/report_view?report_no=2315), 2026-07-27 | 국내 시장의 지수 집중도와 주문 불균형 등 변동성 요인 | 수익률만으로 선택하지 않고 최대 낙폭·정수 주 제약·매매비용을 확인 | +| [Vanguard, How to invest your next dollar](https://corporate.vanguard.com/content/corporatesite/us/en/corp/vemo/how-to-invest-your-next-dollar.html), 2026 전망 | 불확실한 시장에서 자산 배분과 분산의 중요성 | 새 테마나 특정 종목 예측을 추가하기보다 기존 주식·CD·현금 구성의 배분 오류를 수정 | +| [금융위원회 ETF·ETN 제도 안내](https://fsc.go.kr/po010101/87512), 2026-08-12 | 레버리지·인버스 상품의 괴리율과 투자자 보호 조치 | 과거 수익률을 높이기 위한 레버리지·인버스 확대는 이번 변경에 포함하지 않음 | + +시장 전망을 곧바로 매수·매도 신호로 바꾸지는 않았습니다. 위 자료는 검증 항목과 위험 구분의 참고 자료이고, 구체적인 비중 변경은 이 저장소의 비교 결과를 근거로 선택했습니다. + +## 재현과 적용 파일 + +```powershell +.\.venv\Scripts\python.exe tools/risk_review.py --as-of 2026-09-17 +.\.venv\Scripts\python.exe tools/risk_overlay_backtest.py --as-of 2026-09-17 +.\.venv\Scripts\python.exe -m pytest tests/test_risk_overlay_backtest.py tests/test_risk_overlays.py tests/test_basket_overlay_integration.py tests/test_basket_live_gate.py +``` + +- [`core/risk_overlays.py`](../core/risk_overlays.py): 위험 조건 결합, 자료 누락 처리, 총자산 기준 목표 비중 +- [`core/basket_rebalancer.py`](../core/basket_rebalancer.py): 전일 데이터 선택과 실행 목표 비중 +- [`config/baskets.yaml`](../config/baskets.yaml): ETF 적립에 새 규칙과 모의투자 전용 설정 적용 +- [`tools/risk_review.py`](../tools/risk_review.py): 실제 ETF·1주 단위·기간별·비용별 비교 +- [`tools/risk_overlay_backtest.py`](../tools/risk_overlay_backtest.py): 입금 중립 TWR, 체결 제약과 장기 보조 비교 +- [`reports/research/risk_review_20260917.json`](../reports/research/risk_review_20260917.json): 전체 수치와 데이터 식별 정보 + +실제 주문을 전송하거나 기존 계좌의 자산·입금·거래 기록을 수정하지 않고 검증했습니다. 모의투자 전용 설정을 해제하려면 변경 이후의 체결, 비중 이탈, 비용, 누락 기록을 별도로 검토해야 합니다. 60거래일을 채웠다는 사실만으로 새 규칙의 수익성이 입증되지는 않습니다. diff --git a/docs/images/dashboard-account-20260917.png b/docs/images/dashboard-account-20260917.png new file mode 100644 index 0000000..413d20f Binary files /dev/null and b/docs/images/dashboard-account-20260917.png differ diff --git a/docs/images/dashboard-holdings-20260917.png b/docs/images/dashboard-holdings-20260917.png new file mode 100644 index 0000000..a9beee7 Binary files /dev/null and b/docs/images/dashboard-holdings-20260917.png differ diff --git a/docs/images/dashboard-mobile-20260917.png b/docs/images/dashboard-mobile-20260917.png new file mode 100644 index 0000000..768dbbb Binary files /dev/null and b/docs/images/dashboard-mobile-20260917.png differ diff --git a/docs/images/dashboard-operations-20260917.png b/docs/images/dashboard-operations-20260917.png new file mode 100644 index 0000000..3161985 Binary files /dev/null and b/docs/images/dashboard-operations-20260917.png differ diff --git a/docs/images/risk-review-20260917.png b/docs/images/risk-review-20260917.png new file mode 100644 index 0000000..bbf27f0 Binary files /dev/null and b/docs/images/risk-review-20260917.png differ diff --git a/monitoring/static/dashboard.css b/monitoring/static/dashboard.css index 0e87111..81d2162 100644 --- a/monitoring/static/dashboard.css +++ b/monitoring/static/dashboard.css @@ -1,921 +1,1654 @@ -/* 눈금 대시보드 — 종이·잉크 두 색, 손익 색만 한국 관례(상승 빨강·하락 파랑). - 글꼴 두 가족(Inter Tight 숫자/라틴 + Noto Sans KR 한글), 굵기 400·500만 사용. - 장식은 헤어라인 규칙과 여백으로만 만든다. 카드·유리·그림자·그라데이션 없음. */ - +/* 한글·숫자는 같은 로컬 글꼴로 표시한다. 차트는 입력과 데이터 변경 때만 그린다. */ +@font-face { + font-family: "Nungum UI"; + src: url("/static/fonts/nungum-ui.woff2") format("woff2"); + font-weight: 400 700; + font-display: swap; +} :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; + --paper: #f5f6f8; + --surface: #fff; + --ink: #202b3d; + --ink-soft: #596579; + --ink-faint: #667185; + --rule: #e8ebf0; + --rule-strong: #cbd1dd; + --accent: #3157a4; + --accent-bg: #edf2fb; + --up: #c4383c; + --down: #2563c7; + --warn: #916018; + --stop: #b83232; + --pill-bg: #263d64; + --pill-fg: #fff; + --font: "Nungum UI", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif; + --rail: 204px; +} +* { + box-sizing: border-box; } - -* { box-sizing: border-box; } - html { -webkit-text-size-adjust: 100%; - background: var(--paper); - scrollbar-color: var(--rule-strong) var(--paper); + scroll-padding-top: 88px; } - body { margin: 0; - min-width: 320px; background: var(--paper); color: var(--ink); - font-family: var(--font); - font-weight: 400; - font-size: 15.5px; - line-height: 1.55; - letter-spacing: -0.008em; + font: 400 14px/1.55 var(--font); + letter-spacing: -0.015em; word-break: keep-all; overflow-wrap: anywhere; -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - overflow-x: hidden; - font-variant-numeric: tabular-nums; } - -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; } - +h1, +h2, +h3, +h4, +p, +dl, +dd, +figure { + margin: 0; +} +button, +input, +select { + font: inherit; + letter-spacing: inherit; + color: inherit; +} +button, +a, +input, +select, +summary { + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; +} +button { + cursor: pointer; +} +a { + color: inherit; +} +button:disabled { + cursor: not-allowed; + opacity: 0.45; +} +[hidden] { + display: none !important; +} +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 4px; +} .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; + clip-path: inset(50%); overflow: hidden; - clip: rect(0, 0, 0, 0); white-space: nowrap; - border: 0; -} - -:focus-visible { - outline: 2px solid var(--ink); - outline-offset: 2px; } - .skip-link { position: fixed; - top: 12px; - left: 12px; z-index: 100; - padding: 8px 14px; - border-radius: 999px; + left: 12px; + top: 12px; + padding: 10px 16px; + color: white; background: var(--pill-bg); - color: var(--pill-fg); - font-size: 13px; - text-decoration: none; transform: translateY(-200%); + border-radius: 8px; +} +.skip-link:focus { + transform: none; +} +.up { + color: var(--up); +} +.down { + color: var(--down); +} +.warn { + color: var(--warn); +} +.error, +.stop { + color: var(--stop); +} +.flat { + color: var(--ink); +} +.num { + text-align: right; + font-variant-numeric: tabular-nums; } -.skip-link:focus { transform: none; } - -/* ---------- 프리로더 ---------- */ -.boot { +/* 주 메뉴 */ +.rail { position: fixed; - inset: 0; - z-index: 90; + inset: 0 auto 0 0; + width: var(--rail); + z-index: 40; + background: var(--surface); + border-right: 1px solid var(--rule); + padding: 31px 18px 22px; display: flex; flex-direction: column; +} +.brand { + display: inline-flex; align-items: center; - justify-content: center; - gap: 16px; - background: var(--paper); - transition: opacity 0.7s var(--ease), visibility 0.7s; + gap: 10px; + padding-left: 14px; + font-size: 25px; + font-weight: 700; + letter-spacing: -0.065em; + text-decoration: none; } -.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 { +.brand img { display: block; - height: 100%; - width: 100%; - background: var(--ink); - transform: scaleX(0); - transform-origin: 0 50%; - transition: transform 0.25s var(--ease); } - -/* ---------- 스크롤 진행 헤어라인 ---------- */ - -.meter { - position: fixed; - top: 0; - left: 0; - z-index: 50; - 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; } - -/* ---------- 상단 크롬 ---------- */ - -.chrome { - position: fixed; - top: 0; - right: 0; - left: 0; - z-index: 40; +.nav { + display: flex; + flex-direction: column; + gap: 7px; + margin-top: 51px; +} +.nav > a { display: flex; align-items: center; - justify-content: space-between; + min-height: 46px; 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); + padding: 10px 14px; + text-decoration: none; + border-radius: 8px; + color: var(--ink-soft); + font-weight: 500; +} +.nav > a > svg { + width: 19px; + height: 19px; + flex-shrink: 0; + fill: none; + stroke: currentColor; + stroke-width: 1.5; + stroke-linecap: round; + stroke-linejoin: round; +} +.nav > a:hover { + background: #f6f8fb; +} +.nav > a[aria-current] { + color: var(--accent); + background: var(--accent-bg); + font-weight: 650; +} +.rail-bottom { + margin-top: auto; + padding: 20px 14px 0; + border-top: 1px solid var(--rule); + font-size: 12px; + color: var(--ink-soft); } -.chrome.solid { - background: rgba(242, 240, 236, 0.96); - box-shadow: 0 1px 0 var(--rule); +.rail-caption { + display: block; + color: var(--ink-faint); + margin-bottom: 7px; } - -.mark { +.rail-bottom a { + text-decoration: none; display: flex; + justify-content: space-between; + min-height: 32px; 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; } - -.nav { +.chrome { + position: sticky; + top: 0; + margin-left: var(--rail); + z-index: 30; + min-height: 72px; + background: var(--surface); + border-bottom: 1px solid var(--rule); + padding: max(16px, env(safe-area-inset-top)) 36px 16px; display: flex; align-items: center; - gap: clamp(14px, 2.2vw, 26px); - flex-shrink: 0; + justify-content: space-between; + gap: 16px; } -.nav > a { - font-size: 14.5px; - text-decoration: none; - opacity: 0.88; - transition: opacity 0.3s var(--ease); +.location { + color: var(--ink-faint); + font-size: 12px; + white-space: nowrap; +} +.location strong { + color: var(--ink-soft); + font-weight: 500; + margin-left: 6px; +} +.header-actions { + display: flex; + gap: 18px; + align-items: center; } -.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; } - .state { - display: inline-flex; + display: flex; align-items: center; - gap: 7px; - font-size: 12.5px; + gap: 6px; color: var(--ink-soft); - white-space: nowrap; + font-size: 11px; +} +.state time { + margin-left: 3px; + color: var(--ink-faint); } -.state time { color: var(--ink-faint); } - .dot { display: inline-block; - width: 7px; - height: 7px; + width: 6px; + height: 6px; border-radius: 50%; - background: var(--ink); - flex: 0 0 auto; -} -.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; } } - + background: #718097; + flex-shrink: 0; +} +.dot[data-state="ok"], +.dot[data-state="ready"] { + background: #32705d; +} +.dot[data-state="warning"], +.dot[data-state="partial"] { + background: #b67c29; +} +.dot[data-state="error"] { + background: var(--stop); +} .mode { - font-size: 12.5px; - color: var(--ink-soft); + font-size: 11px; + font-weight: 600; + color: #506075; + background: #f0f3f7; + padding: 4px 8px; + border-radius: 5px; white-space: nowrap; } -.mode[data-mode="live"] { color: var(--up); font-weight: 500; } - +.mode[data-mode="live"] { + color: var(--stop); + background: #fff0ef; +} .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; + min-height: 38px; + padding: 0 17px; 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; + border: 1px solid transparent; + border-radius: 7px; + font-weight: 600; + font-size: 13px; 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); + transition: background 0.16s; +} +.pill:hover:not(:disabled) { + background: #182d4e; } -.pill:hover:not(:disabled), -.pill:focus-visible { transform: translateY(-2px); background: #000; } -.pill:disabled { cursor: not-allowed; opacity: 0.38; } .pill-line { - background: transparent; + background: white; 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); } - +.pill-line:hover:not(:disabled) { + background: var(--paper); +} .link { - padding: 0; + display: inline-flex; + align-items: center; + min-height: 36px; border: 0; background: transparent; - color: var(--ink); - font-size: 14.5px; - font-weight: 500; + padding: 0; + color: var(--accent); + text-decoration: none; + font-size: 12px; + font-weight: 600; +} +.link:hover { text-decoration: underline; - text-underline-offset: 5px; - text-decoration-thickness: 1px; - min-height: 36px; + text-underline-offset: 4px; } -.link:hover { text-decoration-thickness: 2px; } - -/* ---------- 스크롤 스크럽 스테이지 ---------- */ -.story { - position: relative; - height: 380vh; - min-height: 2400px; +/* 계좌 요약 */ +.page { + max-width: 1640px; + margin-left: var(--rail); + padding: 32px 36px 0; } - -.stage { - position: sticky; - top: 0; - height: 100vh; - height: 100svh; - overflow: hidden; - background: var(--paper); +.page-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 26px; } - -.stage canvas { - position: absolute; - inset: 0; - width: 100%; - height: 100%; +h1 { + font-size: 26px; + line-height: 1.3; + font-weight: 700; + letter-spacing: -0.04em; } - -.grain { - position: absolute; - inset: -50%; - opacity: 0.13; - mix-blend-mode: multiply; - pointer-events: none; - background-image: url("data:image/svg+xml;utf8,"); +.page-heading p { + margin-top: 7px; + color: var(--ink-soft); + font-size: 14px; } - -.panels { - position: absolute; - inset: 0; - pointer-events: none; +.page-heading time { + color: var(--ink-faint); + font-size: 12px; } - -.panel { - position: absolute; - inset: 0; +.account-tabs { 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; + gap: 10px; + margin-bottom: 19px; } - -.eyebrow { +.account-tabs > button { + flex: 1; + max-width: 330px; + min-width: 0; + display: flex; + gap: 12px; + text-align: left; + align-items: center; + background: #fff; + border: 1px solid var(--rule); + border-radius: 10px; + padding: 13px 16px; + font-weight: 600; font-size: 13px; - color: var(--ink-soft); - margin-bottom: clamp(14px, 2vw, 22px); - max-width: min(52ch, 100%); } - -.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; -} -.display .unit { - font-size: 0.42em; +.account-tabs > button[aria-pressed="true"] { + border-color: #718fca; + box-shadow: 0 0 0 1px #718fca; +} +.account-tabs small { + display: block; + font-size: 11px; + margin-top: 2px; font-weight: 400; - letter-spacing: -0.01em; + color: var(--ink-faint); +} +.account-icon { + flex: 0 0 34px; + height: 34px; + display: grid; + place-items: center; + font-size: 11px; + border-radius: 10px; + color: #53627b; + background: #f0f3f7; +} +.account-tabs button[aria-pressed="true"] .account-icon { + background: var(--accent-bg); + color: var(--accent); +} +.account-check { + margin-left: auto; + color: var(--accent); + visibility: hidden; +} +.account-tabs button[aria-pressed="true"] .account-check { + visibility: visible; +} +.overview { + display: flex; + align-items: center; + background: var(--surface); + border: 1px solid var(--rule); + border-radius: 14px; + padding: 26px 30px; + gap: 30px; + margin-bottom: 22px; + min-height: 160px; +} +.overview-total { + flex: 1.25; + min-width: 210px; +} +.overview-total > p { color: var(--ink-soft); - margin-left: 0.12em; + font-size: 13px; } -.display .small { +.overview-cash { + display: none; + margin-top: 8px; +} +.overview-total > strong { display: block; - margin-top: 0.34em; - font-size: clamp(13px, 1.1vw, 15px); - font-weight: 400; - letter-spacing: -0.008em; - line-height: 1.4; + font-size: clamp(29px, 2.7vw, 40px); + font-weight: 600; + line-height: 1.45; + letter-spacing: -0.045em; + font-variant-numeric: tabular-nums; +} +.overview-total > span { + font-size: 12px; + display: block; + font-variant-numeric: tabular-nums; +} +.overview-stats { + flex: 2.5; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 26px; + padding-left: 32px; + border-left: 1px solid var(--rule); +} +.overview-stats dt { color: var(--ink-soft); + font-size: 13px; } - -.sub { - margin-top: clamp(16px, 2.2vw, 26px); - font-size: clamp(15px, 1.28vw, 18px); - line-height: 1.55; +.overview-stats abbr { + font-size: 11px; + background: var(--paper); color: var(--ink-soft); - max-width: min(46ch, 100%); - text-wrap: pretty; + padding: 2px 4px; + text-decoration: none; + border-radius: 3px; + cursor: help; } - -.cta { - margin-top: clamp(24px, 3vw, 36px); - pointer-events: auto; +.overview-stats dd { + font-size: clamp(20px, 1.85vw, 27px); + font-weight: 600; + margin-top: 6px; + line-height: 1.4; + letter-spacing: -0.035em; + font-variant-numeric: tabular-nums; + white-space: nowrap; } -.cta .pill { height: 44px; padding: 0 24px; } - -.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; +.overview-stats small { + display: block; + font-size: 11px; color: var(--ink-faint); - transition: opacity 0.4s var(--ease); -} -.story-hint i { - width: 28px; - height: 1px; - background: var(--ink-faint); - position: relative; - overflow: hidden; + margin-top: 6px; } -.story-hint i::after { - content: ""; - position: absolute; - inset: 0 auto 0 0; - width: 10px; - background: var(--ink); - animation: hint 1.6s var(--ease) infinite; +.workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 256px; + gap: 22px; + align-items: start; } -@keyframes hint { from { transform: translateX(-12px); } to { transform: translateX(32px); } } - -/* ---------- 본문 ---------- */ - -.page { - max-width: calc(var(--content) + var(--gutter) * 2); +.main-column, +.side-column { min-width: 0; - margin: 0 auto; - padding: 24px var(--gutter) 40px; } -.sec, .tracks, .track, .track > * { min-width: 0; } - .sec { - padding: 64px 0 24px; - border-top: 1px solid var(--rule); - scroll-margin-top: var(--chrome-h); + background: white; + border: 1px solid var(--rule); + border-radius: 14px; + padding: 23px 25px; + margin-bottom: 22px; + scroll-margin-top: 20px; + min-width: 0; } -.sec:first-child { border-top: 0; padding-top: 40px; } - .sec-head { display: flex; align-items: baseline; justify-content: space-between; - gap: 16px; - margin-bottom: 28px; + flex-wrap: wrap; + gap: 5px 16px; + margin-bottom: 18px; } -.sec-label { - font-size: 13px; - font-weight: 500; - letter-spacing: 0; +.sec h2 { + font-size: 16px; + font-weight: 650; + letter-spacing: -0.025em; } .sec-meta { - font-size: 12.5px; color: var(--ink-faint); - text-align: right; + font-size: 11px; } - .loading, +.empty { + color: var(--ink-soft); + font-size: 13px; + padding: 18px 0; +} +.empty > strong, +.empty > span { + display: block; +} +.empty > span { + margin-top: 5px; +} .fine { - font-size: 12.5px; - line-height: 1.6; + font-size: 11px; color: var(--ink-faint); - max-width: 72ch; + line-height: 1.65; + margin-top: 13px; } -.fine { margin-top: 14px; } -.fine.error { color: var(--stop); } -.loading { padding: 12px 0; } -.empty { - padding: 48px 0; - border-top: 1px solid var(--rule); - border-bottom: 1px solid var(--rule); - font-size: 14px; +/* 차트 */ +#performance .sec-head { + margin-bottom: 13px; +} +#chartSummary { + width: 100%; + font-size: 11px; +} +.chart-controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 15px; +} +.seg { + display: flex; + gap: 3px; + padding: 3px; + background: #f4f6f9; + border-radius: 6px; +} +.seg button { + border: 0; + background: transparent; + min-height: 36px; + min-width: 45px; + padding: 0 11px; color: var(--ink-soft); + border-radius: 4px; + font-size: 11px; } -.empty strong { display: block; color: var(--ink); font-weight: 500; margin-bottom: 4px; } - -/* 오늘 */ - -.today-title { - font-size: clamp(26px, 3.4vw, 44px); - font-weight: 500; - line-height: 1.12; - letter-spacing: -0.024em; - max-width: 24ch; - text-wrap: balance; +.seg button[aria-pressed="true"] { + background: white; + color: var(--ink); + font-weight: 650; + box-shadow: 0 1px 3px #25344c14; } -.today-desc { - margin-top: 14px; - max-width: 58ch; - font-size: 16px; +.chart-maturity { + font-size: 11px; + color: var(--ink-faint); + max-width: 28ch; +} +.chart-title { + display: flex; + align-items: center; + gap: 15px; + font-size: 11px; color: var(--ink-soft); - text-wrap: pretty; + margin-bottom: 8px; } -.today-act { margin-top: 8px; } - -.status-row { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 0 24px; - margin: 36px 0 0; - padding: 0; - list-style: none; +.chart-title > span { + font-size: 11px; + color: var(--ink-faint); +} +.legend-value::before, +.legend-principal::before { + content: ""; + display: inline-block; + width: 14px; + border-top: 2px solid var(--accent); + margin-right: 6px; + vertical-align: middle; +} +.legend-principal::before { + border-top: 1px dashed #8290a4; +} +.chart-box { + position: relative; + height: 238px; + width: 100%; +} +.chart-box canvas { + display: block; + width: 100%; + height: 100%; +} +.chart-box-small { + height: 76px; +} +.chart + .chart { + margin-top: 24px; + padding-top: 16px; border-top: 1px solid var(--rule); } -.status-row li { - display: grid; - grid-template-columns: auto 1fr; - grid-template-rows: auto auto; - column-gap: 8px; +.tip { + position: absolute; + top: 0; + left: 0; + padding: 10px 12px; + background: #fff; + border: 1px solid var(--rule); + border-radius: 8px; + box-shadow: 0 5px 20px #16294514; + max-width: 230px; + width: max-content; + pointer-events: none; + z-index: 4; + font-size: 11px; +} +.tip time, +.tip strong, +.tip span { + display: block; +} +.tip strong { + margin: 3px 0; + font-size: 16px; +} +.tip span { + max-width: 26ch; + font-size: 11px; +} +.months { + display: flex; + flex-wrap: wrap; + gap: 9px 22px; + margin-top: 17px; + padding-top: 15px; + border-top: 1px solid var(--rule); +} +.months > div { + display: flex; align-items: center; - padding: 14px 0 12px; + gap: 8px; + font-size: 11px; } -.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; } - -/* 포트폴리오 */ - -.tracks { display: grid; } -.track { - padding: 30px 0 36px; +.months b { + font-weight: 400; + color: var(--ink-faint); +} +.months span { + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.disclosure { + margin-top: 18px; border-top: 1px solid var(--rule); } -.track:first-child { border-top: 0; padding-top: 0; } +.disclosure > summary { + cursor: pointer; + min-height: 44px; + display: flex; + align-items: center; + justify-content: space-between; + font-size: 12px; + font-weight: 500; + gap: 12px; + list-style: none; +} +.disclosure > summary::-webkit-details-marker { + display: none; +} +.disclosure > summary::after { + content: "+"; + color: var(--ink-faint); + font-size: 18px; +} +.disclosure[open] > summary::after { + content: "−"; +} +.count { + font-size: 11px; + color: var(--ink-faint); + margin-left: auto; +} +.disclosure > .table-scroll { + max-height: 390px; +} +/* 보유 종목 */ .track-head { display: flex; - align-items: flex-end; + align-items: center; justify-content: space-between; - gap: 24px; + gap: 15px; + margin-bottom: 18px; } -.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); +.track-kind { + font-size: 11px; + color: var(--ink-faint); +} +.track-kind b { + color: var(--accent); font-weight: 500; - line-height: 1.2; - 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; } - -.kpis { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 0 20px; - margin-top: 26px; - border-top: 1px solid var(--rule); +.track-name { + font-size: 14px; + font-weight: 600; + margin-top: 4px; } -.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; +.spark { + width: 112px; + height: 36px; + opacity: 0.65; +} +.alloc { + padding: 14px 16px; + border-radius: 8px; + background: #f7f9fc; + margin-bottom: 20px; } -.kpis dd small { font-size: 0.6em; font-weight: 400; color: var(--ink-soft); margin-left: 2px; } - -.up { color: var(--up); } -.down { color: var(--down); } -.flat { color: var(--ink-soft); } - -.alloc { margin-top: 30px; max-width: 620px; } .alloc-line { display: flex; align-items: baseline; - gap: 10px; - font-size: 13px; + gap: 9px; + font-size: 11px; +} +.alloc-line b { + font-size: 16px; + font-weight: 600; +} +.alloc-line .target { + margin-left: auto; color: var(--ink-soft); } -.alloc-line b { font-size: 15px; font-weight: 500; color: var(--ink); } -.alloc-line .target { margin-left: auto; } .bar { + height: 5px; + border-radius: 3px; + background: #e6ebf3; position: relative; - height: 6px; - margin-top: 10px; - background: var(--paper-2); + margin: 9px 0; } -.bar i { +.bar > i { display: block; height: 100%; - width: 0; - background: var(--ink); - transition: width 0.9s var(--ease); + max-width: 100%; + border-radius: 3px; + background: #5475ae; } -.bar em { +.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 .table-scroll { margin-top: 28px; } -.holdings { - width: 100%; - min-width: 640px; + height: 11px; + width: 2px; + top: -3px; + background: var(--ink-soft); +} +.note, +.overlay { + font-size: 11px; + color: var(--ink-soft); + margin-top: 9px; +} +.overlay b { + color: var(--ink); + margin-right: 5px; + font-weight: 500; +} +.overlay.warn { + color: var(--warn); +} +.table-scroll { + max-width: 100%; + overflow: auto; + overscroll-behavior-inline: contain; +} +table { border-collapse: collapse; - font-size: 13.5px; + width: 100%; + min-width: 510px; + font-size: 13px; } -.holdings th, -.holdings td { - padding: 10px 12px 10px 0; - border-bottom: 1px solid var(--rule); +caption { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); +} +th, +td { + padding: 13px 8px; text-align: left; - vertical-align: middle; + border-bottom: 1px solid var(--rule); 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; } - -.flow { margin-top: 18px; font-size: 12.5px; color: var(--ink-soft); } -.flow.error { color: var(--stop); } - -/* 성과 */ - -.chart-controls { +th { + color: var(--ink-faint); + font-size: 11px; + font-weight: 500; + padding-top: 0; +} +td:first-child, +th:first-child { + padding-left: 0; +} +td:last-child, +th:last-child { + padding-right: 0; +} +tbody tr:last-child td { + border-bottom: 0; +} +tfoot td { + color: var(--ink-soft); + font-size: 11px; + padding-top: 16px; + border-top: 1px solid var(--rule); + border-bottom: 0; + white-space: normal; +} +.sym { + display: block; + font-size: 11px; + color: var(--ink-faint); + font-variant-numeric: tabular-nums; +} +.wbar { display: flex; - align-items: center; - flex-wrap: wrap; - gap: 12px 14px; - margin-bottom: 22px; + flex-direction: column; + gap: 5px; + font-size: 11px; + min-width: 58px; } -.chart-maturity { margin-left: auto; font-size: 12.5px; color: var(--ink-faint); } - -select { - 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%; +.wbar > i { + display: block; + height: 3px; + background: #6a88ba; + border-radius: 2px; } - -.seg { - display: inline-flex; - padding: 2px; - border: 1px solid var(--rule-strong); - border-radius: 999px; +.wbar > i.t { + background: #c6cedb; } -.seg button { - height: 30px; - padding: 0 13px; - border: 0; - border-radius: 999px; - background: transparent; +.flow { + font-size: 11px; color: var(--ink-soft); - font-size: 13px; - transition: background 0.25s var(--ease), color 0.25s var(--ease); + padding-top: 15px; } -.seg button:hover { color: var(--ink); } -.seg button[aria-pressed="true"] { background: var(--ink); color: var(--pill-fg); } - -.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; } -.tip { - position: absolute; - top: 0; - left: 0; - z-index: 2; +/* 확인할 일 */ +.side-column .sec { + padding: 20px; +} +.side-column h2 { + font-size: 13px; +} +.attention { + background: #eef2f9; + border-color: #e6ebf4; +} +.attention .sec-head { + align-items: center; + margin-bottom: 17px; +} +.notice-symbol { + height: 20px; + width: 20px; + border: 1px solid #a8b7ce; + border-radius: 50%; display: grid; - gap: 1px; - min-width: 132px; - padding: 8px 10px; - border-radius: 6px; - background: var(--ink); - color: var(--paper); + place-items: center; + font-size: 11px; + color: var(--accent); +} +.attention h3 { + font-size: 16px; + line-height: 1.65; + font-weight: 600; + letter-spacing: -0.025em; +} +.attention #decisionDescription { + color: var(--ink-soft); + font-size: 13px; + line-height: 1.75; + margin-top: 9px; +} +.attention .link { + margin-top: 10px; +} +.attention .sec-meta { + margin-top: 13px; + padding-top: 12px; + border-top: 1px solid #dce3ef; + font-size: 11px; +} +.allocation-ring { + --invested: 0%; + margin: 22px auto; + width: 144px; + height: 144px; + border-radius: 50%; + background: conic-gradient( + #5777ae 0 var(--invested), + #e7ebf2 var(--invested) 100% + ); + padding: 15px; +} +.allocation-ring > span { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + height: 100%; + background: white; + border-radius: 50%; +} +.allocation-ring small { + font-size: 11px; + color: var(--ink-soft); +} +.allocation-ring strong { + font-size: 24px; + font-weight: 600; + letter-spacing: -0.025em; +} +.allocation dl > div { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 7px 0; font-size: 12px; - 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); } - -.months { +.allocation dt { + color: var(--ink-soft); display: flex; - flex-wrap: wrap; - gap: 1px; - width: max-content; - max-width: 100%; - margin-top: 6px; - border: 1px solid var(--rule); - background: var(--rule); + align-items: center; + gap: 7px; +} +.allocation dd { + font-variant-numeric: tabular-nums; + font-weight: 500; +} +.asset-dot, +.cash-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 2px; + background: #5777ae; +} +.cash-dot { + background: #d2d9e5; +} +.allocation .fine { + font-size: 11px; +} +.status-row { + list-style: none; + margin: 14px 0 8px; + padding: 0; +} +.status-row li { + display: flex; + align-items: center; + gap: 7px; + font-size: 11px; + padding: 8px 0; +} +.status-row .k { + color: var(--ink-soft); +} +.status-row .v { + margin-left: auto; + font-weight: 500; } -.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; } - -/* 검증 */ +/* 검증 및 자동매매 */ .review-item { display: grid; - grid-template-columns: minmax(0, 1.1fr) minmax(0, 1.6fr) auto; - gap: 12px 28px; + grid-template-columns: minmax(100px, 1fr) minmax(130px, 1.2fr) auto; + gap: 16px; align-items: center; - padding: 22px 0; + padding: 17px 0; +} +.review-item + .review-item { 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); } +.review-name { + font-size: 12px; + font-weight: 500; +} +.review-progress p { + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + justify-content: space-between; + font-size: 11px; + color: var(--ink-soft); +} +.verdict { + font-size: 11px; + border-radius: 5px; + padding: 3px 6px; + background: var(--paper); +} +.review-issues { + grid-column: 1/-1; + font-size: 11px; + color: var(--warn); + margin: 0; + padding-left: 18px; +} .status-item { display: grid; - grid-template-columns: 168px minmax(0, 1fr); - gap: 6px 20px; - align-items: baseline; + grid-template-columns: 112px minmax(0, 1fr); + gap: 14px; 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); } - -.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 { +.status-item dt { display: flex; align-items: center; - justify-content: space-between; - gap: 16px; - min-height: 54px; - font-size: 14px; + gap: 8px; + font-size: 12px; + color: var(--ink-soft); +} +.status-item dd b { + font-size: 12px; font-weight: 500; - list-style: none; - cursor: pointer; } -.disclosure > summary::-webkit-details-marker { display: none; } -.disclosure > summary::after { - content: "+"; - font-size: 18px; - font-weight: 400; - color: var(--ink-soft); - transition: transform 0.3s var(--ease); +.status-item dd span { + display: block; + font-size: 11px; + color: var(--ink-faint); + margin-top: 3px; +} +.halt { + background: #fff5f3; + padding: 18px; + border-radius: 8px; + margin-top: 20px; +} +.halt h3 { + font-size: 14px; +} +.halt p, +.halt li, +.halt a { + font-size: 12px; +} +.halt p { + margin-top: 8px; +} +.halt ol { + padding-left: 18px; +} +.sub-label { + font-size: 12px; + margin: 20px 0 12px; } -.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; } - -.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; } - .foot { display: flex; 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; + align-items: center; + gap: 20px; + padding: 12px 0 max(28px, env(safe-area-inset-bottom)); + font-size: 11px; color: var(--ink-faint); } -.foot p { display: flex; align-items: center; gap: 8px; } -.foot p + p { max-width: 48ch; text-align: right; } - -/* ---------- 적립 기록 ---------- */ +.foot p:first-child { + font-weight: 600; + color: var(--ink-soft); +} +.foot span { + margin-left: 12px; + font-weight: 400; + font-size: 11px; +} +/* 적립금 기록 */ .deposit-dialog { - width: min(460px, calc(100vw - 32px)); - max-height: min(720px, calc(100dvh - 32px)); - margin: auto; + width: min(440px, calc(100% - 32px)); + border: 1px solid var(--rule); padding: 0; - overflow: auto; - border: 1px solid var(--rule-strong); - border-radius: 8px; - background: var(--paper); + border-radius: 16px; color: var(--ink); + background: white; + max-height: calc(100dvh - 32px); + overflow: auto; + box-shadow: 0 20px 80px #0d203333; +} +.deposit-dialog::backdrop { + background: #15263e66; +} +.deposit-dialog form { + padding: 25px; +} +.dialog-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.dialog-head h2 { + font-size: 21px; + font-weight: 650; } -.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 { + display: grid; + place-items: center; width: 36px; height: 36px; - display: inline-grid; - place-items: center; - padding: 0; - border: 1px solid var(--rule-strong); - border-radius: 999px; + border: 0; background: transparent; + border-radius: 6px; +} +.icon-button:hover { + background: var(--paper); +} +.dialog-desc { + color: var(--ink-soft); + font-size: 13px; + line-height: 1.7; + margin: 14px 0 23px; +} +.field { + margin: 16px 0; +} +.field > label { + display: block; + font-size: 12px; + margin-bottom: 7px; + font-weight: 500; +} +.field > label span { + margin-left: 4px; + color: var(--ink-faint); + font-weight: 400; } -.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%; - height: 44px; - padding: 0 12px; + min-height: 44px; + padding: 10px 12px; border: 1px solid var(--rule-strong); - border-radius: 6px; - background: #fbfaf8; + border-radius: 7px; + background: white; font-size: 16px; } -.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; } +.money { + position: relative; +} +.money input { + padding-right: 36px; +} +.money > span { + position: absolute; + right: 14px; + top: 11px; + color: var(--ink-soft); +} +.help { + color: var(--ink-faint); + font-size: 11px; + margin-top: 7px; +} +.quick { + display: flex; + gap: 7px; + margin-top: 10px; +} .quick button { - height: 32px; - padding: 0 14px; - border: 1px solid var(--rule-strong); - border-radius: 999px; - background: transparent; + flex: 1; + min-height: 36px; + border: 1px solid var(--rule); + border-radius: 6px; + background: var(--paper); + font-size: 12px; +} +.quick button[aria-pressed="true"] { + background: var(--accent-bg); + color: var(--accent); + border-color: #718fca; +} +.confirm { + padding: 16px 0; + border-top: 1px solid var(--rule); +} +.confirm > p { font-size: 13px; + margin-bottom: 10px; } -.quick button[aria-pressed="true"], -.quick button:hover { background: var(--ink); color: var(--pill-fg); border-color: var(--ink); } - -.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; } - -.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(20px, env(safe-area-inset-bottom, 0px)); - z-index: 200; - max-width: min(520px, calc(100vw - 32px)); - padding: 11px 16px; - border-radius: 999px; - background: var(--ink); - color: var(--paper); - font-size: 13.5px; - opacity: 0; - transform: translate(-50%, 12px); - pointer-events: none; - transition: opacity 0.25s var(--ease), transform 0.25s var(--ease); +.confirm dl > div { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 10px 0; + font-size: 13px; } -.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; } +.confirm dt { + color: var(--ink-soft); } - -@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; } +.confirm dd { + font-weight: 600; } - -@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; } +.form-error { + font-size: 12px; + color: var(--stop); + margin-top: 14px; } - -@media (max-width: 420px) { - .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 { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 24px; } - -@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; } +.toast { + position: fixed; + bottom: max(24px, env(safe-area-inset-bottom)); + left: 50%; + transform: translate(-50%, 12px); + opacity: 0; + background: #263d64; + color: white; + border-radius: 9px; + padding: 12px 20px; + font-size: 13px; + max-width: calc(100% - 32px); + z-index: 100; + pointer-events: none; + transition: + opacity 0.18s, + transform 0.18s; +} +.toast.show { + opacity: 1; + transform: translate(-50%, 0); +} + +@media (min-width: 1021px) and (min-height: 960px) { + .side-column { + position: sticky; + top: 92px; + } +} +@media (max-width: 1240px) { + :root { + --rail: 178px; + } + .page { + padding: 27px 24px 0; + } + .chrome { + padding-inline: 24px; + } + .workspace { + grid-template-columns: minmax(0, 1fr) 232px; + gap: 18px; + } + .overview { + gap: 18px; + padding: 24px; + } + .overview-stats { + padding-left: 22px; + gap: 18px; + } + .overview-total { + min-width: 180px; + } + .overview-stats small { + max-width: 15ch; + } + .rail { + padding-inline: 12px; + } + .nav > a { + gap: 10px; + font-size: 13px; + } + .sec { + padding: 21px; + } + .chart-maturity { + display: none; + } + .state time { + display: none; + } +} +@media (max-width: 1020px) { + :root { + --rail: 72px; + } + .rail { + padding: 27px 11px; + } + .brand { + padding: 0; + justify-content: center; + } + .brand > span, + .nav > a > span, + .rail-bottom { + position: absolute; + width: 1px; + height: 1px; + clip-path: inset(50%); + overflow: hidden; + } + .nav > a { + padding: 13px; + justify-content: center; + } + .nav > a > svg { + width: 21px; + height: 21px; + } + .workspace { + grid-template-columns: minmax(0, 1fr) 218px; + } + .overview-stats { + gap: 15px; + padding-left: 20px; + } + .overview { + gap: 20px; + } + .overview-total { + min-width: 165px; + } + .overview-stats dd { + font-size: 21px; + } + .sec { + padding: 20px; + } + .header-actions { + gap: 13px; + } + .side-column .sec { + padding: 18px; + } + .page-heading time { + font-size: 11px; + } +} +@media (max-width: 800px) { + .overview-cash { + display: block; + } + .workspace { + grid-template-columns: minmax(0, 1fr); + } + .side-column { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; + grid-row: 1; + } + .side-column .sec { + margin-bottom: 0; + } + .side-column .attention { + grid-column: 1/-1; + } + .attention #decisionDescription { + margin-top: 4px; + } + .attention .sec-head { + margin-bottom: 6px; + } + .attention .sec-meta, + .side-column .allocation, + .side-column .system-summary { + display: none; + } + .main-column { + margin-top: 0; + } + .overview { + flex-direction: column; + align-items: stretch; + gap: 21px; + } + .overview-stats { + padding: 20px 0 0; + border-left: 0; + border-top: 1px solid var(--rule); + } + .overview-total > strong { + font-size: 36px; + } + .overview-stats small { + max-width: none; + } + .overview-stats dd { + font-size: 23px; + } + .state { + display: none; + } + .page-heading { + align-items: flex-start; + } + .page-heading time { + margin-top: 5px; + } + .attention .link { + margin-top: 4px; + } +} +@media (max-width: 560px) { + :root { + --rail: 0px; + } + html { + scroll-padding-top: calc(112px + env(safe-area-inset-top, 0px)); + } + .rail { + top: calc(57px + env(safe-area-inset-top, 0px)); + bottom: auto; + width: 100%; + height: 50px; + padding: 0 12px; + border-right: 0; + border-bottom: 1px solid var(--rule); + } + .brand { + display: none; + } + .nav { + flex-direction: row; + margin: 0; + gap: 0; + height: 100%; + justify-content: space-between; + } + .nav > a { + flex: 1; + min-width: 0; + min-height: 44px; + padding: 0 6px; + gap: 0; + border-radius: 0; + font-size: 11px; + border-bottom: 2px solid transparent; + white-space: nowrap; + } + .nav > a > svg { + display: none; + } + .nav > a > span { + display: inline; + position: static; + width: auto; + height: auto; + clip-path: none; + overflow: visible; + } + .nav > a[aria-current] { + background: transparent; + border-bottom-color: var(--accent); + } + .chrome { + height: calc(57px + env(safe-area-inset-top, 0px)); + min-height: 57px; + padding: calc(10px + env(safe-area-inset-top, 0px)) + max(16px, env(safe-area-inset-right)) 10px + max(16px, env(safe-area-inset-left)); + } + .location { + font-size: 0; + } + .location::before { + content: "눈금"; + font-size: 22px; + font-weight: 700; + letter-spacing: -0.065em; + color: var(--ink); + } + .location strong { + display: none; + } + .header-actions { + gap: 10px; + } + .pill { + min-height: 36px; + font-size: 12px; + padding-inline: 12px; + } + .page { + padding: 73px 16px 0; + } + .page-heading { + margin-bottom: 23px; + flex-wrap: wrap; + gap: 9px; + } + .page-heading time { + font-size: 11px; + width: 100%; + } + h1 { + font-size: 23px; + } + .page-heading p { + font-size: 12px; + } + .account-tabs { + gap: 8px; + } + .account-tabs > button { + padding: 12px 10px; + gap: 7px; + font-size: 11px; + } + .account-tabs small { + font-size: 11px; + } + .account-icon, + .account-check { + display: none; + } + .overview { + padding: 20px; + margin-bottom: 16px; + } + .overview-total > strong { + font-size: 34px; + } + .overview-stats { + gap: 11px; + } + .overview-stats dd { + font-size: 19px; + } + .overview-stats dt { + font-size: 11px; + } + .overview-stats small { + font-size: 11px; + } + .overview-stats abbr { + display: none; + } + .overview-total > span { + font-size: 11px; + } + .workspace { + gap: 16px; + } + .sec { + padding: 20px 16px; + margin-bottom: 16px; + border-radius: 12px; + } + .sec-head { + gap: 5px; + } + .sec h2 { + font-size: 15px; + } + .sec-meta { + font-size: 11px; + } + .chart-box { + height: 215px; + } + .chart-box-small { + height: 66px; + } + .chart-title { + gap: 12px; + } + .review-item { + grid-template-columns: minmax(0, 1fr) auto; + } + .review-progress { + grid-row: 2; + grid-column: 1/-1; + } + .status-item { + grid-template-columns: 88px minmax(0, 1fr); + gap: 10px; + } + .foot { + flex-direction: column; + align-items: flex-start; + gap: 6px; + font-size: 11px; + } + .track-head .spark { + display: none; + } + .track-name { + font-size: 13px; + } + .fine { + font-size: 11px; + } +} +@media (max-width: 360px) { + .overview { + padding: 17px; + } + .overview-stats { + gap: 8px; + } + .overview-stats dd { + font-size: 17px; + } + .overview-stats small { + font-size: 11px; + } + .page { + padding-inline: 12px; + } + .nav > a { + font-size: 11px; + padding-inline: 4px; + } + .chart-box { + height: 200px; + } + .attention h3 { + font-size: 14px; + } } - @media (prefers-reduced-motion: reduce) { - *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; } - .story-hint i::after { animation: none; } + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition: none !important; + animation: none !important; + } } diff --git a/monitoring/static/dashboard.js b/monitoring/static/dashboard.js index 5bfbc7d..1f12b18 100644 --- a/monitoring/static/dashboard.js +++ b/monitoring/static/dashboard.js @@ -1,12 +1,14 @@ -"use strict"; - (function () { + "use strict"; 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 compact = new Intl.NumberFormat("ko-KR", { + notation: "compact", + maximumFractionDigits: 1, + }); const MINUS = "−"; // null·undefined·빈 문자열은 0이 아니라 "값 없음"으로 다룬다. @@ -47,14 +49,18 @@ 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); + : /^\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()}일` : "—"; + return d + ? `${d.getFullYear()}년 ${d.getMonth() + 1}월 ${d.getDate()}일` + : "—"; } function fmtMD(value) { const d = parseDate(value); @@ -62,7 +68,9 @@ } function fmtDT(value) { const d = parseDate(value); - return d ? `${d.getMonth() + 1}월 ${d.getDate()}일 ${pad2(d.getHours())}:${pad2(d.getMinutes())}` : "—"; + return d + ? `${d.getMonth() + 1}월 ${d.getDate()}일 ${pad2(d.getHours())}:${pad2(d.getMinutes())}` + : "—"; } function fmtHM(d) { return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`; @@ -82,27 +90,32 @@ if (!parsed) return null; const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const target = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate()); + const 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("'", "'"); + .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 FONT = + '"Nungum UI", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif'; + const COLORS = { + ink: "#3157a4", + up: "#c4383c", + down: "#2563c7", + warn: "#976019", + }; + const inkAlpha = (a) => `rgba(48, 60, 78, ${a})`; /* ---------- 상태 ---------- */ @@ -131,85 +144,78 @@ 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"), + chrome: $("chrome"), + syncMark: $("syncMark"), + syncStatus: $("syncStatus"), + lastUpdate: $("lastUpdate"), + modeBadge: $("modeBadge"), + openDeposit: $("openDepositButton"), + depositAvailability: $("depositAvailability"), + 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 } = {}) { + 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 }); + 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}`); + 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); - } - } - - /* ---------- 프리로더 ---------- */ - - 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; - } - 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); + if (state.activeRequests.get(key) === controller) + state.activeRequests.delete(key); } } @@ -220,10 +226,13 @@ 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", "일부 데이터 지연"); + 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(); } @@ -237,26 +246,45 @@ } function setMode(mode) { const normalized = String(mode || "unknown").toLowerCase(); - state.mode = ["paper", "live"].includes(normalized) ? normalized : "unknown"; + state.mode = ["paper", "live"].includes(normalized) + ? normalized + : "unknown"; el.modeBadge.dataset.mode = state.mode; - el.modeBadge.textContent = state.mode === "paper" ? "모의투자" : state.mode === "live" ? "실전 · 실계좌 주문" : "모드 확인 불가"; + el.modeBadge.textContent = + state.mode === "paper" + ? "모의투자" + : state.mode === "live" + ? "실전 · 실계좌 주문" + : "모드 확인 불가"; updateDepositCopy(); } - const modeLabel = () => (state.mode === "live" ? "실전" : state.mode === "paper" ? "모의투자" : "모드 확인 불가"); + 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; + 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 ? "적립을 기록할 수 있습니다." : "장부와 거래 상태를 확인한 뒤 기록할 수 있습니다."; + el.depositAvailability.textContent = available + ? "적립을 기록할 수 있습니다." + : "계좌 기록과 거래 상태를 확인한 뒤 기록할 수 있습니다."; } /* ---------- 바스켓 정렬 ---------- */ @@ -267,48 +295,87 @@ return d || String(a.basket).localeCompare(String(b.basket)); }); } - const primaryBasket = () => sortedBaskets().find((b) => b.is_primary) || sortedBaskets().find((b) => b.snapshot) || null; + 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; + const created = parseDate(row.created_at); + // 과거 날짜를 나중에 복원한 행의 저장 시각으로 미래 입금을 당겨 넣지 않는다. + if (created && localIsoDate(created) === localIsoDate(d)) return created; 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 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 || "" })) + .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)); - // 장부와 같은 규칙: 시작 자본을 첫 고점으로 두고 시간가중 자산으로 낙폭을 잰다. + 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; } + if (v > peak) { + peak = v; + peakIdx = i; + } const d = peak > 0 ? v / peak - 1 : 0; - if (d < maxDD) { maxDD = d; maxDDIdx = i; maxDDPeakIdx = peakIdx; } + 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) })) + .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 }; + return { + rows: sorted, + dates, + value, + cr, + twr, + principal, + dd, + maxDD, + maxDDIdx, + maxDDPeakIdx, + markers, + initialCapital, + }; } function monthlyReturns(series) { @@ -319,7 +386,12 @@ 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 = { + key, + label: `${d.getFullYear() !== new Date().getFullYear() ? `${String(d.getFullYear()).slice(2)}년 ` : ""}${d.getMonth() + 1}월`, + startIndex: prevIndex, + endIndex: i, + }; } current.endIndex = i; prevIndex = i; @@ -327,27 +399,42 @@ if (current) out.push(current); // 월 수익률 = 전월 말 시간가중 지수 대비. 첫 달은 시작 자본(지수 1) 대비. return out.map((m) => { - const start = m.startIndex == null ? 1 : 1 + series.cr[m.startIndex] / 100; + 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 }; + return { + key: m.key, + 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; } + 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) }; + 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)) { + 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); } @@ -359,376 +446,97 @@ function niceTicks(min, max, count) { const ticks = []; - for (let i = 0; i <= count; i += 1) ticks.push(max - ((max - min) * i) / count); + 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], - ]; - 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; + /* 선택한 계좌와 차트·종목표는 같은 데이터를 표시한다. */ + function selectedBasket() { + return ( + (state.baskets || []).find((b) => b.account_key === state.chartAccount) || + primaryBasket() + ); + } + function updateOverview() { + const baskets = sortedBaskets(); + const b = selectedBasket(); + const tabs = $("accountTabs"); + const signature = JSON.stringify( + baskets.map((item) => [ + item.account_key, + item.display_name, + item.is_primary, + ]), + ); + if (tabs.dataset.signature !== signature) { + tabs.dataset.signature = signature; + tabs.innerHTML = + baskets + .map( + (item) => + ``, + ) + .join("") || '

등록된 계좌가 없습니다.

'; } - renderStoryPanels(); - story.dirty = true; - wakeStory(); + tabs + .querySelectorAll("button") + .forEach((button) => + button.setAttribute( + "aria-pressed", + String(button.dataset.account === b?.account_key), + ), + ); + $("overview").setAttribute("aria-busy", String(!state.baskets)); + const snap = b?.snapshot; + $("overviewDate").textContent = snap + ? `${fmtLong(snap.date)} 기준` + : "자산 기록 없음"; + $("overviewDate").dateTime = snap?.date || ""; + $("overviewTotal").textContent = won(snap?.total_value); + $("overviewCash").textContent = `현금 ${won(snap?.cash)}`; + const pnl = snap ? Number(snap.total_value) - Number(b.principal) : null; + $("overviewPnl").textContent = snap + ? `원금 대비 ${signedWon(pnl)} (${pct(b.principal > 0 ? (pnl / b.principal) * 100 : null)})` + : "모의투자를 실행하면 자산 기록이 표시됩니다."; + $("overviewPnl").className = tone(pnl); + $("overviewPrincipal").textContent = won(b?.principal); + $("overviewDeposits").textContent = b?.deposits_total + ? `적립금 ${won(b.deposits_total)} 포함` + : "초기 투자금"; + $("overviewReturn").textContent = pct(snap?.cumulative_return); + $("overviewReturn").className = tone(snap?.cumulative_return); + const rows = state.series.get(b?.account_key) || []; + const dd = snap + ? Math.max( + Math.abs(Number(snap.mdd) || 0), + ...rows.map((r) => Math.abs(Number(r.mdd) || 0)), + ) + : null; + $("overviewDrawdown").textContent = pct(dd == null ? null : -dd); + $("overviewDrawdown").className = dd ? "down" : "flat"; + const cashPct = + snap && snap.total_value > 0 + ? clamp((snap.cash / snap.total_value) * 100, 0, 100) + : null; + $("allocationCash").textContent = + cashPct == null ? "—" : `${cashPct.toFixed(1)}%`; + $("allocationRing").style.setProperty( + "--invested", + `${cashPct == null ? 0 : 100 - cashPct}%`, + ); + $("allocationRing").setAttribute( + "aria-label", + cashPct == null + ? "자산 구성 기록 없음" + : `보유 자산 ${(100 - cashPct).toFixed(1)}%, 현금 ${cashPct.toFixed(1)}%`, + ); + $("allocationList").innerHTML = + `
보유 자산
${won(snap ? snap.total_value - snap.cash : null)}
현금
${won(snap?.cash)}
`; } - /* ---------- 오늘 ---------- */ - function latestSnapshotDate() { - const b = primaryBasket(); + const b = selectedBasket(); return b && b.snapshot ? b.snapshot.date : null; } function currentMonthContributionState(basket) { @@ -737,15 +545,28 @@ 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 ( + 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 = "확인하기" }) { + 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; @@ -757,64 +578,141 @@ 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 primary = selectedBasket(); + const issues = (state.evaluations || []) + .filter((item) => item.basket === primary?.basket) + .flatMap((item) => item.issues || []); const contribution = currentMonthContributionState(primary); el.decisionMeta.textContent = `${modeLabel()}${latest ? ` · 마지막 자산 기록 ${fmtLong(latest)}` : " · 자산 기록 없음"}`; if (halt && halt.halted) { - setDecision({ title: "거래가 중지된 상태입니다", description: halt.reason || "체결과 장부를 대조하기 전까지 신규 주문이 막혀 있습니다.", action: "operations", actionLabel: "운영 상태 보기" }); + setDecision({ + title: "거래가 중지된 상태입니다", + description: + halt.reason || + "체결과 계좌 기록을 대조하기 전까지 신규 주문이 막혀 있습니다.", + action: "operations", + actionLabel: "운영 상태 보기", + }); return; } if (state.coreError) { - setDecision({ title: "장부를 읽지 못했습니다", description: "화면의 숫자는 마지막으로 성공한 조회 결과입니다. 연결을 확인한 뒤 다시 시도하세요.", action: "retry", actionLabel: "다시 확인" }); + setDecision({ + title: "계좌 기록을 읽지 못했습니다", + description: + "화면의 숫자는 마지막으로 성공한 조회 결과입니다. 연결을 확인한 뒤 다시 시도하세요.", + action: "retry", + actionLabel: "다시 확인", + }); return; } if (state.runtimeStatus === "loading") { - setDecision({ title: "운영 상태를 확인하고 있습니다", description: "거래 중지 여부와 자동매매 기록을 확인한 뒤 오늘 할 일을 정리합니다." }); + setDecision({ + title: "운영 상태를 확인하고 있습니다", + description: "최근 실행 기록과 거래 중지 여부를 조회하고 있습니다.", + }); return; } if (state.runtimeStatus === "error") { - setDecision({ title: "운영 상태를 확인할 수 없습니다", description: "거래 중지 여부를 모르는 상태에서는 적립 기록이나 설정 변경을 하지 마세요.", action: "retry", actionLabel: "다시 확인" }); + setDecision({ + title: "운영 상태를 확인할 수 없습니다", + description: + "거래 중지 여부를 확인할 수 없어 적립금 기록을 잠시 제한했습니다.", + action: "retry", + actionLabel: "다시 확인", + }); return; } if (baskets && !baskets.length) { - setDecision({ title: "첫 모의투자 계좌를 켜 주세요", description: "config/baskets.yaml에서 계좌를 활성화하고 모의투자를 한 번 실행하면 첫 기록이 생깁니다.", action: "portfolio", actionLabel: "포트폴리오 보기" }); + setDecision({ + title: "첫 모의투자 계좌를 켜 주세요", + description: + "config/baskets.yaml에서 계좌를 활성화하고 모의투자를 한 번 실행하면 첫 기록이 생깁니다.", + action: "portfolio", + actionLabel: "포트폴리오 보기", + }); return; } if (baskets && baskets.length && !latest) { - setDecision({ title: "첫 자산 기록을 기다리고 있습니다", description: "모의투자를 한 번 실행하면 투자원금, 총자산, 수익률이 분리되어 보입니다.", action: "portfolio", actionLabel: "포트폴리오 보기" }); + setDecision({ + title: "첫 자산 기록을 기다리고 있습니다", + description: + "모의투자를 한 번 실행하면 투자원금, 총자산, 수익률이 분리되어 보입니다.", + action: "portfolio", + actionLabel: "포트폴리오 보기", + }); return; } if (ageDays != null && ageDays > 4) { - setDecision({ title: `자산 기록이 ${ageDays}일째 멈춰 있습니다`, description: `마지막 기록은 ${fmtLong(latest)}입니다. 자동매매가 멈췄을 수 있으니 운영 상태부터 확인하세요.`, action: "operations", actionLabel: "운영 상태 보기" }); + 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; + 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: "운영 상태 보기", + 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: "검토 항목 보기" }); + const paperOnly = (state.evaluations || []).find( + (item) => item.basket === primary?.basket, + )?.paper_only; + setDecision({ + title: + paperOnly && issues.length === 1 + ? "새 운용 규칙을 검증하고 있습니다" + : `확인이 필요한 항목이 ${issues.length}건 있습니다`, + description: issues[0], + action: "review", + actionLabel: "검토 항목 보기", + }); return; } if (contribution === "unknown") { - setDecision({ title: "적립 기록을 확인할 수 없습니다", description: "조회가 끝나기 전에는 같은 적립금을 두 번 기록하지 않도록 기다려 주세요.", action: "retry", actionLabel: "다시 확인" }); + 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: "적립 기록" }); + setDecision({ + title: "이번 달 적립금 기록이 없습니다", + description: `${planned > 0 ? `매월 ${compactWon(planned)}을 적립하는 계좌입니다. ` : ""}${state.mode === "live" ? "입금을 마쳤다면 같은 금액을 기록해 주세요." : "추가할 모의투자금을 기록해 주세요."}`, + action: "deposit", + actionLabel: "적립금 기록", + }); return; } - setDecision({ title: "오늘은 손댈 것이 없습니다", description: "계획대로 기록을 쌓는 중입니다. 시세를 매일 확인하거나 전략을 바꿀 이유가 없습니다." }); + setDecision({ + title: "현재 확인할 항목이 없습니다", + description: "최근 계좌 기록과 자동매매 상태가 정상입니다.", + }); } function statusItem(key, value, stateName) { @@ -824,7 +722,10 @@ /* ---------- 포트폴리오 ---------- */ function sparkline(rows) { - const values = (rows || []).slice(-60).map((r) => Number(r.total_value || 0)).filter(Number.isFinite); + 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; @@ -833,14 +734,20 @@ 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 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 '

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

'; + if (!positions.length) + return '

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

'; const total = basket.snapshot ? Number(basket.snapshot.total_value) : null; const targets = basket.target_weights || {}; const rows = positions.map((p) => ({ @@ -848,25 +755,37 @@ 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 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; + 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) => ` + ${rows + .map( + (r) => ` - `).join("")} - + `, + ) + .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)})` : ""}
보유분 평가금액 ${won(holdingsValue)} · 매입금액 ${won(holdingsCost)} · 평가손익 ${signedWon(holdingsPnl)}${holdingsCost && holdingsPnl != null ? ` (${pct((holdingsPnl / holdingsCost) * 100)})` : ""}
-

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

`; +

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

`; } function renderBasketTracks(data) { @@ -877,183 +796,267 @@ markCoreSuccess(data && data.timestamp); if (!baskets.length) { - el.basketTracks.innerHTML = '
활성화된 계좌가 없습니다.config/baskets.yaml에서 모의투자 계좌를 먼저 켜 주세요.
'; + el.basketTracks.innerHTML = + '
활성화된 계좌가 없습니다.config/baskets.yaml에서 모의투자 계좌를 먼저 켜 주세요.
'; el.portfolioAsOf.textContent = "기준일 없음"; el.portfolioAsOf.dateTime = ""; - updateStoryData(); + updateOverview(); 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 `
+ el.basketTracks.innerHTML = sortedBaskets() + .filter((b) => b.basket === selectedBasket()?.basket) + .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 낮습니다. 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))} 기준` : " · 첫 기록 대기"}

+
${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 ? "" : ``}
+
${target == null ? "" : ``}

${escapeHtml(note)}

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

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

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

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

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

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

' : ""}
`; - }).join(""); + }) + .join(""); - if (firstRender) { - state.tracksAnimated = true; - requestAnimationFrame(() => requestAnimationFrame(() => { - el.basketTracks.querySelectorAll(".bar i[data-width]").forEach((bar) => { bar.style.width = `${bar.dataset.width}%`; }); - })); - } - - const reference = primaryBasket(); - const referenceDate = reference && reference.snapshot && reference.snapshot.date; + const reference = selectedBasket(); + const referenceDate = + reference && reference.snapshot && reference.snapshot.date; el.portfolioAsOf.dateTime = referenceDate || ""; - el.portfolioAsOf.textContent = referenceDate ? `${fmtLong(referenceDate)} 기록 기준` : "첫 기록 대기"; - updateStoryData(); + el.portfolioAsOf.textContent = referenceDate + ? `${fmtLong(referenceDate)} 기록 기준` + : "첫 기록 대기"; + updateOverview(); } 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; - } - })); + 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"); - } - })); + 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: "" }; + const chart = { + progress: 1, + frame: null, + hover: null, + geometry: null, + series: null, + }; function ensureChartAccountOptions() { - const wanted = sortedBaskets().map((b) => ({ value: b.account_key, label: b.display_name })); - wanted.push({ value: "", label: "레거시 기본 계정" }); + const wanted = sortedBaskets().map((b) => ({ + value: b.account_key, + label: b.display_name, + })); 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 : ""); + 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"); + 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}`); + history.replaceState( + null, + "", + `${window.location.pathname}${qs ? `?${qs}` : ""}${window.location.hash}`, + ); } function chartBasket() { - return (state.baskets || []).find((b) => b.account_key === state.chartAccount) || null; + 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; } + 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}거래일째 · 장기 검토가 가능한 구간입니다`; + if (evaluation.paper_only) { + el.chartMaturity.textContent = `운영 기록 ${days}거래일 · 새 규칙 검증 중`; + return; + } + el.chartMaturity.textContent = + days < 20 + ? `운용 ${days}거래일째 · 추세를 판단하기엔 이릅니다` + : days < minimum + ? `모의투자 ${days} / ${minimum}거래일` + : `모의투자 ${days}거래일 기록`; } function renderChartTable(series) { - el.chartRows.innerHTML = series.rows.map((row, i) => ` + el.chartRows.innerHTML = series.rows + .map( + (row, i) => ` ${won(row.total_value)} ${won(series.principal[i])} ${pct(row.cumulative_return)} - `).join(""); - } - - function renderMonthStrip(series) { - const months = monthlyReturns(series); - el.monthStrip.innerHTML = months.map((m) => `
${escapeHtml(m.label)}${pct(m.value, { digits: 1 })}
`).join(""); + `, + ) + .join(""); + } + + function renderMonthStrip(series, history) { + const visibleMonths = new Set( + series.dates.map((d) => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`), + ); + // 조회 기간이 월 중간에서 시작해도 전월 말 기준 수익률을 사용한다. + const months = monthlyReturns(history).filter((month) => + visibleMonths.has(month.key), + ); + 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 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); + 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 width = Math.max(1, el.chartBox.clientWidth); const height = Math.max(200, el.chartBox.clientHeight); const context = setupCanvas(el.chartEquity, width, height); const g = equityGeometry(series, width, height); @@ -1071,18 +1074,26 @@ context.moveTo(padding.left, y); context.lineTo(width - padding.right, y); context.stroke(); - context.fillStyle = inkAlpha(0.42); + context.fillStyle = inkAlpha(0.7); 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); + context.fillStyle = inkAlpha(0.7); for (let i = 0; i < n - 1; i += step) { if (n > 1 && i > n - 1 - step * 0.5) break; context.textAlign = i === 0 ? "left" : "center"; - context.fillText(fmtMD(series.rows[i].date), x(i), height - padding.bottom + 14); + 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); + 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; @@ -1101,7 +1112,10 @@ 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); } + else { + context.lineTo(px, scale.y(series.principal[i - 1])); + context.lineTo(px, py); + } }); context.stroke(); context.restore(); @@ -1117,7 +1131,11 @@ 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.fillText( + `${signedWon(m.amount)} 적립`, + mx + (mx > width * 0.8 ? -5 : 5), + padding.top - 8, + ); context.font = `400 11.5px ${FONT}`; }); @@ -1129,7 +1147,8 @@ 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); + if (i === 0) context.moveTo(px, py); + else context.lineTo(px, py); }); context.stroke(); if (n <= 14) { @@ -1159,7 +1178,7 @@ context.arc(px, py, 4.5, 0, Math.PI * 2); context.fillStyle = COLORS.ink; context.fill(); - context.strokeStyle = "#f2f0ec"; + context.strokeStyle = "#ffffff"; context.lineWidth = 2; context.stroke(); } @@ -1168,14 +1187,23 @@ function drawDrawdown(series) { const box = $("drawdownBox"); - const width = Math.max(280, box.clientWidth); + const width = Math.max(1, 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 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 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); + 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"; @@ -1184,13 +1212,19 @@ context.moveTo(padding.left, y(0)); context.lineTo(width - padding.right, y(0)); context.stroke(); - context.fillStyle = inkAlpha(0.42); + context.fillStyle = inkAlpha(0.7); context.fillText("0%", padding.left - 8, y(0)); - context.fillText(pct(minDD * 100, { digits: 1 }), padding.left - 8, y(minDD)); + 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)); }); + 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)"; @@ -1198,7 +1232,12 @@ 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)); } }); + 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; @@ -1209,25 +1248,6 @@ } } - 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; @@ -1241,7 +1261,11 @@ 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); + 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]; @@ -1260,12 +1284,21 @@ 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 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; + const historyRows = state.series.get(state.chartAccount); + const history = historyRows?.length + ? buildSeries(historyRows, flows, initial) + : series; + const historicalDD = new Map( + history.rows.map((row, i) => [row.date, history.dd[i]]), + ); + series.dd = series.rows.map( + (row, i) => historicalDD.get(row.date) ?? series.dd[i], + ); chart.series = series; state.chartRows = series.rows; chart.hover = null; @@ -1282,28 +1315,35 @@ } 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)}`; + const change = + Number(last.total_value || 0) - Number(first.total_value || 0); + const deposits = series.principal.at(-1) - series.principal[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(); + renderMonthStrip(series, history); + drawChart(); } async function refreshChart() { ensureChartAccountOptions(); state.chartAccount = el.chartAccount.value; + updateOverview(); syncChartQuery(); try { - const data = await fetchJson(`/api/snapshots?days=${state.chartDays}&account_key=${encodeURIComponent(state.chartAccount || "")}`, { timeout: 15_000, key: "chart" }); + 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.chartSummary.textContent = + "성과 기록을 불러오지 못했습니다. 연결을 확인한 뒤 다시 시도하세요."; el.chartWrap.hidden = true; el.chartEmpty.hidden = false; - el.chartEmpty.innerHTML = "성과 기록을 읽지 못했습니다.장부는 바뀌지 않았습니다. 잠시 후 다시 확인하세요."; + el.chartEmpty.innerHTML = + "성과 기록을 읽지 못했습니다.계좌 기록은 바뀌지 않았습니다. 잠시 후 다시 확인하세요."; } } @@ -1319,31 +1359,41 @@ state.evaluations = items; el.basketEval.setAttribute("aria-busy", "false"); if (!items.length) { - el.basketEval.innerHTML = '

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

'; + 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)}

+ 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 ? " · 주력" : ""}

-

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

+

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

${copy}

${issues.length ? `
    ${issues.map((i) => `
  • ${escapeHtml(i)}
  • `).join("")}
` : ""}
`; - }).join(""); + }) + .join(""); renderChartMaturity(); renderDecision(); } @@ -1355,14 +1405,30 @@ } function marketCopy(regime) { const v = String(regime || "").toLowerCase(); - if (v === "bullish") return ["안정", "신규 매수 가능", "ok"]; - if (v === "bearish") return ["방어", "신규 매수 제한", "warning"]; + 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 || "—"; + 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; @@ -1372,14 +1438,29 @@ if (!runtime) { el.haltGuidance.hidden = true; el.runtimeOps.innerHTML = [ - statusRowItem("거래 안전", "확인 불가", "거래 중지 상태를 읽지 못했습니다", "warning"), + statusRowItem( + "거래 안전", + "확인 불가", + "거래 중지 상태를 읽지 못했습니다", + "warning", + ), 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"); + el.runtimeMeta.textContent = + "운영 정보를 불러오지 못했습니다. 자산 계좌 기록은 바뀌지 않았습니다."; + el.statusRow.innerHTML = + statusItem("거래", "확인 불가", "warning") + + statusItem("장 상태", "확인 불가", "warning") + + statusItem("자동매매", "확인 불가", "warning") + + statusItem("데이터", "확인 불가", "warning"); renderSignals(null, null); renderWsGap(null); renderDecision(); @@ -1388,48 +1469,94 @@ 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 [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 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 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 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 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); + 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); + 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 || "신규 매수는 직접 해제하기 전까지 막혀 있습니다."; + if (halted) + $("haltGuidanceReason").textContent = + halt.reason || "신규 매수는 직접 해제하기 전까지 막혀 있습니다."; renderSignals(runtime.signals_today, runtime.signals_date); renderWsGap(runtime); renderDecision(); @@ -1441,7 +1568,10 @@ const error = $("signalError"); const count = $("signalCount"); if (signals == null) { - table.hidden = true; empty.hidden = true; error.hidden = false; count.textContent = "확인 불가"; + table.hidden = true; + empty.hidden = true; + error.hidden = false; + count.textContent = "확인 불가"; return; } const isToday = !signalsDate || signalsDate === localIsoDate(); @@ -1450,14 +1580,20 @@ table.hidden = !rows.length; empty.hidden = Boolean(rows.length); error.hidden = true; - empty.textContent = isToday ? "오늘 생성된 신호가 없습니다. 이상 상태는 아닙니다." : `오늘 생성된 신호가 없습니다. 마지막 신호는 ${fmtLong(signalsDate)}입니다.`; - $("signalRows").innerHTML = rows.map((s) => ` + 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(""); + `, + ) + .join(""); } function renderWsGap(runtime) { @@ -1467,62 +1603,109 @@ const empty = $("wsGapEmpty"); const na = $("wsGapNA"); if (!gap || !gap.available) { - summary.innerHTML = statusRowItem("웹소켓", "정보 없음", "스케줄러 기록 대기", "warning"); - table.hidden = true; empty.hidden = true; na.hidden = false; + 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"); + 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) => ` + $("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(""); + `, + ) + .join(""); } function renderLegacy(portfolio) { state.legacy = portfolio || null; if (!portfolio) { - $("summary").innerHTML = statusRowItem("레거시 계정", "확인 불가", "", "warning"); + $("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"); + 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) => ` + $("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(""); + `, + ) + .join(""); } - /* ---------- 적립 기록 ---------- */ + /* ---------- 적립금 기록 ---------- */ function updateDepositCopy() { const d = $("depositDescription"); if (!d) return; - d.textContent = state.mode === "live" - ? "실제 계좌에 입금이 끝난 뒤 같은 금액을 기록하세요. 주문은 나가지 않고 실전 성과 계산에만 반영됩니다." - : "입금은 수익이 아니므로 수익률 계산에서 분리됩니다. 실제 입금이 끝난 뒤 같은 금액을 기록하세요."; + d.textContent = + state.mode === "live" + ? "실제 계좌에 입금이 끝난 뒤 같은 금액을 기록하세요. 주문은 나가지 않고 실전 성과 계산에만 반영됩니다." + : "모의투자 계좌에 추가할 적립금을 입력하세요. 실제 은행 계좌에서 돈이 이동하지 않습니다."; + $("amountHelp").textContent = + state.mode === "live" + ? "실제 입금액과 같은 금액을 입력하세요." + : "입력한 금액은 모의투자 원금에 더해집니다."; } function resetDepositForm() { state.depositConfirming = false; @@ -1535,34 +1718,55 @@ el.depositSubmit.disabled = false; el.depositError.hidden = true; el.depositError.textContent = ""; - document.querySelectorAll("[data-amount]").forEach((b) => b.setAttribute("aria-pressed", "false")); + document + .querySelectorAll("[data-amount]") + .forEach((b) => b.setAttribute("aria-pressed", "false")); } function openDeposit() { - if (!canRecordDeposit()) { showToast("장부와 거래 상태를 먼저 다시 확인하세요.", "error"); return; } + if (!canRecordDeposit()) { + showToast("계좌 기록과 거래 상태를 먼저 다시 확인하세요.", "error"); + return; + } resetDepositForm(); const select = $("depBasket"); - select.innerHTML = sortedBaskets().map((b) => ``).join(""); - const primary = primaryBasket(); + select.innerHTML = sortedBaskets() + .map( + (b) => + ``, + ) + .join(""); + const primary = selectedBasket(); 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 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() }; + 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)}`; + 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; + $("confirmBasket").textContent = selected + ? selected.textContent + : values.basket; $("confirmAmount").textContent = won(values.amount); - $("confirmMode").textContent = state.mode === "live" ? "실전 장부" : "모의투자 장부"; + $("confirmMode").textContent = + state.mode === "live" ? "실전 투자" : "모의투자"; el.depositFields.hidden = true; el.depositConfirm.hidden = false; el.depositBack.hidden = false; @@ -1586,15 +1790,29 @@ 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) }, + 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) || "기록에 실패했습니다."); + if (!data || !data.ok) + throw new Error((data && data.error) || "기록에 실패했습니다."); closeDeposit(); - showToast(`${state.mode === "live" ? "실전 입금" : "모의투자 적립"} ${won(data.amount)}을 기록했습니다.`); + showToast( + `${state.mode === "live" ? "실전 입금" : "모의투자 적립"} ${won(data.amount)}을 기록했습니다.`, + ); await refreshCore(); } catch (error) { - showDepositError(`기록하지 못했습니다. ${error.message || "연결을 확인한 뒤 다시 시도하세요."}`); + showDepositError( + `기록하지 못했습니다. ${error.message || "연결을 확인한 뒤 다시 시도하세요."}`, + ); el.depositSubmit.disabled = false; el.depositSubmit.textContent = "기록하기"; } @@ -1618,26 +1836,36 @@ 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 basketTask = fetchJson("/api/baskets", { + timeout: 12_000, + key: "baskets", + }).then(async (data) => { + renderBasketTracks(data); + await Promise.allSettled([refreshFlows(), refreshSeries()]); + renderBasketTracks({ + baskets: state.baskets, + mode: state.mode, + timestamp: state.lastCoreSuccess, }); - const legacyTask = fetchJson("/api/portfolio", { timeout: 15_000, key: "legacy" }).then(renderLegacy); + 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) { + el.basketTracks.innerHTML = + '
포트폴리오를 불러오지 못했습니다.이전 화면의 숫자는 최신이 아닐 수 있습니다.
'; + } else if ( + results.some((r) => r.status === "rejected") || + state.flowError + ) { state.coreStatus = "partial"; } else { state.coreStatus = "ready"; @@ -1648,14 +1876,26 @@ 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" }) + 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 = '

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

'; + el.basketEval.innerHTML = + '

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

'; }); - const runtimeTask = fetchJson("/api/runtime", { timeout: 30_000, key: "runtime" }).then(renderRuntime).catch(() => renderRuntime(null)); + const runtimeTask = fetchJson("/api/runtime", { + timeout: 30_000, + key: "runtime", + }) + .then(renderRuntime) + .catch(() => renderRuntime(null)); await Promise.allSettled([evalTask, runtimeTask]); } @@ -1666,22 +1906,63 @@ /* ---------- 이벤트 ---------- */ 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; + const targets = links + .map((a) => document.querySelector(a.getAttribute("href"))) + .filter(Boolean); + let scheduled = false; + const update = () => { + scheduled = false; + const anchor = window.innerWidth <= 600 ? 140 : 120; + const passed = targets.filter( + (target) => target.getBoundingClientRect().top <= anchor, + ); + const current = + Math.ceil(window.scrollY + window.innerHeight) >= + document.documentElement.scrollHeight - 2 + ? "operations" + : passed + .sort( + (a, b) => + a.getBoundingClientRect().top - b.getBoundingClientRect().top, + ) + .at(-1)?.id || "top"; links.forEach((a) => { - if (current && a.getAttribute("href") === `#${current}`) a.setAttribute("aria-current", "true"); + if (a.getAttribute("href") === `#${current}`) + a.setAttribute("aria-current", "location"); else a.removeAttribute("aria-current"); }); - }, { rootMargin: "-30% 0px -55% 0px", threshold: 0 }); - targets.forEach((t) => spy.observe(t)); + }; + // 사용자가 스크롤할 때 한 프레임에 한 번만 현재 위치를 갱신한다. + window.addEventListener( + "scroll", + () => { + if (!scheduled) { + scheduled = true; + requestAnimationFrame(update); + } + }, + { passive: true }, + ); + window.addEventListener("resize", update); + update(); } function wireEvents() { + $("accountTabs").addEventListener("click", (event) => { + const button = event.target.closest("button[data-account]"); + if (!button) return; + state.chartAccount = button.dataset.account; + ensureChartAccountOptions(); + el.chartAccount.value = state.chartAccount; + renderBasketTracks({ + baskets: state.baskets, + mode: state.mode, + timestamp: state.lastCoreSuccess, + }); + renderDecision(); + refreshChart(); + }); el.openDeposit.addEventListener("click", openDeposit); $("closeDepositButton").addEventListener("click", closeDeposit); $("depositCancelButton").addEventListener("click", closeDeposit); @@ -1692,15 +1973,24 @@ 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" }); + else if (action) + document.getElementById(action)?.scrollIntoView({ + behavior: reducedMotion.matches ? "auto" : "smooth", + block: "start", + }); }); - el.chartAccount.addEventListener("change", () => { state.chartAccount = el.chartAccount.value; refreshChart(); }); + 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))); + document + .querySelectorAll("#chartRange button") + .forEach((b) => b.setAttribute("aria-pressed", String(b === button))); refreshChart(); }); el.chartEquity.addEventListener("pointermove", handleChartPointer); @@ -1710,18 +2000,32 @@ 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))); + document + .querySelectorAll("[data-amount]") + .forEach((b) => b.setAttribute("aria-pressed", String(b === button))); $("depAmount").focus(); }); }); el.depositForm.addEventListener("submit", async (event) => { event.preventDefault(); - if (!canRecordDeposit()) { showDepositError("장부와 거래 상태를 최신으로 확인한 뒤 다시 시도하세요."); updateDepositAvailability(); return; } + 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; } + if (!values.basket) { + showDepositError("계좌를 선택하세요.", $("depBasket")); + return; + } + if (!Number.isFinite(values.amount) || values.amount <= 0) { + showDepositError("0원보다 큰 금액을 입력하세요.", $("depAmount")); + return; + } showDepositConfirmation(values); return; } @@ -1731,7 +2035,11 @@ 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; + const inside = + event.clientX >= b.left && + event.clientX <= b.right && + event.clientY >= b.top && + event.clientY <= b.bottom; if (!inside) closeDeposit(); }); @@ -1742,20 +2050,26 @@ updateSyncIndicator(); renderDecision(); }); - document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") refreshAll(); }); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") refreshAll(); + }); } async function main() { wireEvents(); - initStory(); initScrollSpy(); if ("ResizeObserver" in window) { new ResizeObserver(() => drawChart()).observe(el.chartBox); } - document.querySelectorAll("#chartRange button").forEach((b) => b.setAttribute("aria-pressed", String(Number(b.dataset.days) === state.chartDays))); - window.setTimeout(finishBoot, 8000); + document + .querySelectorAll("#chartRange button") + .forEach((b) => + b.setAttribute( + "aria-pressed", + String(Number(b.dataset.days) === state.chartDays), + ), + ); await refreshAll(true); - finishBoot(); window.setInterval(() => refreshCore(), 30_000); window.setInterval(() => refreshSlow(), 60_000); } diff --git a/monitoring/static/fonts/OFL.txt b/monitoring/static/fonts/OFL.txt new file mode 100644 index 0000000..ae8d98d --- /dev/null +++ b/monitoring/static/fonts/OFL.txt @@ -0,0 +1,94 @@ +Copyright (c) 2021, Kil Hyung-jin (https://github.com/orioncactus/pretendard), +with Reserved Font Name Pretendard. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/monitoring/static/fonts/README.md b/monitoring/static/fonts/README.md new file mode 100644 index 0000000..e2103a7 --- /dev/null +++ b/monitoring/static/fonts/README.md @@ -0,0 +1,7 @@ +# 대시보드 글꼴 + +`nungum-ui.woff2`는 [Pretendard 1.3.9](https://github.com/orioncactus/pretendard/releases/tag/v1.3.9)에서 대시보드용으로 글자를 줄인 파일입니다. 한글 KS X 1001의 2,350자, 기본 라틴 문자·숫자·문장부호·한글 자모를 포함합니다. 나머지 글자는 운영체제의 한글 글꼴로 표시합니다. + +원본: `packages/pretendard/dist/web/variable/woff2/PretendardVariable.woff2` (v1.3.9). FontTools로 서브셋을 만들고 내부 이름을 **Nungum UI**로 변경했습니다. 원본의 Reserved Font Name을 수정본의 글꼴 이름으로 사용하지 않습니다. 화면에는 400~700 굵기를 사용하며 외부 글꼴 서버에 요청하지 않습니다. + +원저작자: Kil Hyung-jin. 배포 조건은 함께 있는 [SIL Open Font License 1.1](OFL.txt)을 따릅니다. 실행 시 글꼴을 다시 만드는 과정이나 FontTools 설치는 필요하지 않습니다. diff --git a/monitoring/static/fonts/nungum-ui.woff2 b/monitoring/static/fonts/nungum-ui.woff2 new file mode 100644 index 0000000..6accef8 Binary files /dev/null and b/monitoring/static/fonts/nungum-ui.woff2 differ diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html index 2c63558..5d4714e 100644 --- a/monitoring/templates/dashboard.html +++ b/monitoring/templates/dashboard.html @@ -1,284 +1,629 @@ - - - - - - 눈금 — 오래 투자하기 위한 기준과 기록 - - - - - - - - - + + + + + + 눈금 | 투자 현황 + + + + + + -
-
-

장부 여는 중 0%

-
- - - -
- - - 눈금 - - -
- -
-
-
- - - -
- - - -
- - +
+ -
-
-
-

오늘

-

-
-
-

현재 상태를 확인하고 있습니다

-

장부와 운영 기록을 불러오는 중입니다.

-

-
    -
    -
    +
    + 내 투자 / 계좌 현황 +
    + 연결 확인 중 + 확인 중 + + 계좌와 거래 상태를 확인한 뒤 기록할 수 있습니다. +
    +
    -
    -
    -

    포트폴리오

    - +
    +
    +
    +

    계좌 현황

    +

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

    -
    -

    포트폴리오를 불러오는 중입니다.

    + +
    + +
    +
    +

    총자산

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

    현금 —

    +
    +
    +
    투자원금
    +
    + 초기 자금 + 적립금 +
    +
    +
    + 누적 수익률 + TWR +
    +
    + 적립금은 수익에서 제외 +
    +
    +
    최대 낙폭
    +
    + 운용 중 고점 대비 최대 하락률 +
    +
    +
    +
    +
    +
    +

    자산 추이

    +

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

    +
    -
    -
    -

    성과

    -

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

    -
    - -
    - - -
    - - - - -
    -

    -
    +
    + +
    + + + + +
    +

    +
    -
    -
    -
    평가금액 점선은 투자원금, 세로 눈금은 적립 시점
    -
    - - +
    +
    +
    + 평가금액투자원금 +
    +
    + + +
    +
    +
    +
    + 고점 대비 하락률 입출금을 제외한 수익률 기준 +
    +
    + +
    +
    +
    -
    -
    -
    고점 대비 낙폭 시간가중 기준
    -
    - + -
    -
    -
    - -

    평가금액에는 적립금이 포함됩니다. 운용 성과는 입금 시점의 영향을 제거한 시간가중수익률(TWR)로 읽으세요. 차트 위에 커서를 올리면 날짜별 값이 보입니다.

    +

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

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

    모의투자 검증

    -

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

    -
    -
    -

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

    -
    -

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

    -
    +
    +
    +

    보유 종목

    + +
    +
    +

    포트폴리오를 불러오는 중입니다.

    +
    +
    -
    -
    -

    운영 상태

    - -
    -
    -

    +
    +
    +

    모의투자 검증

    +

    실전 전환 전 확인

    +
    +
    +

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

    +
    +

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

    +
    - +
    +
    +

    자동매매 상태

    + +
    +
    +

    -
    - 오늘 생성된 신호 0건 -
    - - - - -
    오늘 생성된 자동매매 신호
    시각종목신호점수시점
    -
    - - -
    + -
    - 연결 상태와 레거시 계정 -
    - - - +
    + + 오늘 생성된 신호 0건 + +
    + + + + + + + + + + + + +
    + 오늘 생성된 자동매매 신호 +
    시각종목신호점수시점
    +
    + + +
    -

    레거시 기본 계정

    -
    -
    - - - - -
    레거시 기본 계정의 보유 종목
    종목수량평단가현재가평가금액수익률
    -
    - -
    -
    +
    + 연결 기록 및 이전 계좌 +
    + + + +

    이전 기본 계좌

    +
    +
    + + + + + + + + + + + + + +
    + 이전 기본 계좌의 보유 종목 +
    종목수량평단가현재가평가금액수익률
    +
    + +
    +
    +
    + +
    -

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

    -

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

    +

    눈금 국내 주식·ETF 자동매매

    +

    모의투자와 과거 성과는 실제 투자 결과와 다를 수 있습니다.

    -
    -
    +
    - -
    -
    -

    적립 기록

    - -
    -

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

    + + +
    +

    적립금 기록

    + +
    +

    + 모의투자 계좌에 추가할 적립금을 입력하세요. +

    -
    -
    - - -
    -
    - -
    -

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

    -
    - - - +
    +
    + + +
    +
    + +
    + +
    +

    + 입력한 금액은 모의투자 원금에 더해집니다. +

    +
    + + + +
    +
    +
    + +
    -
    - - -
    -
    - + - -
    - - - -
    - -
    + +
    + + + +
    + +
    -
    - +
    + + diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py index 1412ce0..4728dd6 100644 --- a/monitoring/web_dashboard.py +++ b/monitoring/web_dashboard.py @@ -114,7 +114,7 @@ def get_baskets_json() -> dict: 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, + overlay_target_weights, describe_decision, load_overlay_state, parse_overlay_config, @@ -201,16 +201,17 @@ def get_baskets_json() -> dict: # 곱한 '적용 비중'이 그날의 목표다. 화면의 목표 비중·목표 범위 판정은 적용 비중을 쓴다. overlay_cfg = parse_overlay_config(basket_config) overlay_state = load_overlay_state(name) if overlay_cfg.any_enabled else None - design_fraction = ( - applied_stock_fraction(base_fraction, overlay_state) - if overlay_cfg.any_enabled - else base_fraction + scale = float((overlay_state or {}).get("scale", 1.0)) + target_weights = overlay_target_weights( + basket_config.get("holdings") or {}, base_fraction, scale, + (basket_config.get("overlays") or {}).get("defensive_symbol"), ) + design_fraction = sum(target_weights.values()) 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, + "scale": scale 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 []), @@ -221,16 +222,6 @@ def get_baskets_json() -> dict: } # 종목별 목표 비중(총자산 대비) = 바스켓 내 비중 정규화 × 적용 투자 비중. # 현재가는 장부에 저장하지 않으므로 화면은 매입금액 기준 비중과 나란히 보여준다. - 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 @@ -352,12 +343,16 @@ def _api_error(label: str, exc: Exception, message: str) -> web.Response: async def _security_headers(request: web.Request, handler): response = await handler(request) - response.headers["Cache-Control"] = "no-store" + static_asset = request.path.startswith("/static/") + # 정적 파일은 변경 여부를 확인해 재사용한다. 계좌 응답은 디스크에 캐시하지 않는다. + response.headers["Cache-Control"] = "no-cache" if static_asset else "no-store" + if static_asset and request.path.endswith((".css", ".js", ".svg")): + response.enable_compression() response.headers["Content-Security-Policy"] = ( "default-src 'self'; " "script-src 'self'; " - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; " - "font-src https://fonts.gstatic.com; " + "style-src 'self' 'unsafe-inline'; " + "font-src 'self'; " "img-src 'self' data:; connect-src 'self'; " "object-src 'none'; base-uri 'none'; form-action 'self'; " "frame-ancestors 'none'" @@ -537,6 +532,7 @@ def _collect_all() -> dict: { "basket": basket_name, "verdict": result.get("verdict"), + "paper_only": bool(result.get("paper_only", False)), "progress_days": result.get("progress_days"), "min_trading_days": result.get("min_trading_days"), "snapshot_coverage": result.get("snapshot_coverage"), diff --git a/reports/research/dashboard_performance_20260917.json b/reports/research/dashboard_performance_20260917.json new file mode 100644 index 0000000..baa86d3 --- /dev/null +++ b/reports/research/dashboard_performance_20260917.json @@ -0,0 +1,172 @@ +{ + "environment": { + "userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36", + "viewport": [ + 390, + 844 + ], + "cpuSlowdown": 4, + "downloadMbps": 1.6, + "rttMs": 150, + "cache": "disabled", + "reducedMotion": false + }, + "dataReadyMs": 1640.199999999255, + "fcpMs": 628, + "lcpMs": 628, + "cls": 0.03408044418879106, + "longTasks": [ + { + "start": 3059.5999999996275, + "duration": 168 + } + ], + "blockingMs": 118, + "idleRafCalls": 0, + "documentBytes": 22915, + "resources": [ + { + "path": "/static/fonts/nungum-ui.woff2", + "bytes": 441212, + "encoded": 440912, + "decoded": 440912, + "duration": 2837 + }, + { + "path": "/static/dashboard.css", + "bytes": 6632, + "encoded": 6332, + "decoded": 28389, + "duration": 249 + }, + { + "path": "/static/nungum-symbol.svg", + "bytes": 595, + "encoded": 295, + "decoded": 364, + "duration": 202 + }, + { + "path": "/static/dashboard.js", + "bytes": 21213, + "encoded": 20913, + "decoded": 77165, + "duration": 386 + }, + { + "path": "/api/baskets", + "bytes": 3619, + "encoded": 3319, + "decoded": 3319, + "duration": 242 + }, + { + "path": "/api/portfolio", + "bytes": 2607, + "encoded": 2307, + "decoded": 2307, + "duration": 225 + }, + { + "path": "/api/basket_evaluation", + "bytes": 800, + "encoded": 500, + "decoded": 500, + "duration": 178 + }, + { + "path": "/api/runtime", + "bytes": 2639, + "encoded": 2339, + "decoded": 2339, + "duration": 208 + }, + { + "path": "/api/cash_flows", + "bytes": 363, + "encoded": 63, + "decoded": 63, + "duration": 170 + }, + { + "path": "/api/cash_flows", + "bytes": 440, + "encoded": 140, + "decoded": 140, + "duration": 169 + }, + { + "path": "/api/snapshots", + "bytes": 15650, + "encoded": 15350, + "decoded": 15350, + "duration": 372 + }, + { + "path": "/api/snapshots", + "bytes": 10775, + "encoded": 10475, + "decoded": 10475, + "duration": 323 + }, + { + "path": "/api/snapshots", + "bytes": 10773, + "encoded": 10473, + "decoded": 10473, + "duration": 283 + } + ], + "interactionEvents": [ + { + "name": "pointerdown", + "duration": 56, + "interactionId": 8588 + }, + { + "name": "pointerup", + "duration": 56, + "interactionId": 8588 + }, + { + "name": "click", + "duration": 56, + "interactionId": 8588 + }, + { + "name": "pointerdown", + "duration": 56, + "interactionId": 8595 + }, + { + "name": "pointerup", + "duration": 56, + "interactionId": 8595 + }, + { + "name": "click", + "duration": 56, + "interactionId": 8595 + }, + { + "name": "pointerdown", + "duration": 40, + "interactionId": 8602 + }, + { + "name": "pointerup", + "duration": 40, + "interactionId": 8602 + }, + { + "name": "click", + "duration": 40, + "interactionId": 8602 + }, + { + "name": "keydown", + "duration": 40, + "interactionId": 8609 + } + ] +} diff --git a/reports/research/risk_review_20260917.json b/reports/research/risk_review_20260917.json new file mode 100644 index 0000000..e951f28 --- /dev/null +++ b/reports/research/risk_review_20260917.json @@ -0,0 +1,844 @@ +{ + "as_of": "2026-09-17", + "last_complete_bar": "2026-09-16", + "integer_start": "2020-07-07", + "source": "FinanceDataReader, close prices", + "data": { + "069500": { + "first": "2014-06-30", + "last": "2026-09-16", + "rows": 2999, + "sha256": "1ef75c687434ead55cc128f9f933ca589bb4eff2a3390ad3dd104b44822ad933" + }, + "357870": { + "first": "2020-07-07", + "last": "2026-09-16", + "rows": 1520, + "sha256": "fbc29674729fc175cf2f1ff78fee361a96a8cfed0279b9fe7cb3b444562cf810" + }, + "KS200": { + "first": "2014-01-02", + "last": "2026-09-16", + "rows": 3119, + "sha256": "3834a4d6f22a7278ae4327cd5aa88337681ba0db6e877ff9ede325ebfded70f0" + } + }, + "integer_etf": { + "static": { + "label": "고정 비중", + "all": { + "years": 6.19, + "cagr_pct": 13.54, + "period_return_pct": 119.53, + "vol_pct": 13.82, + "sharpe": 0.8, + "mdd_pct": -21.72, + "calmar": 0.62, + "worst_year_pct": -10.48, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 41.3, + "avg_exposure_pct": 46.8, + "turnover_per_year_pct": 39.5, + "final_value": 13876896, + "contributed": 7700000, + "profit": 6176896, + "yearly": { + "2020": 15.54, + "2021": 1.67, + "2022": -10.48, + "2023": 13.15, + "2024": -2.83, + "2025": 40.61, + "2026": 35.03 + } + }, + "periods": { + "2020_2022": { + "years": 2.48, + "cagr_pct": 2.05, + "period_return_pct": 5.16, + "vol_pct": 8.26, + "sharpe": -0.07, + "mdd_pct": -16.25, + "calmar": 0.13, + "worst_year_pct": -10.48, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 40.0, + "avg_exposure_pct": 45.7, + "turnover_per_year_pct": 67.7, + "final_value": 3036402, + "contributed": 3200000, + "profit": -163598, + "yearly": { + "2020": 15.54, + "2021": 1.67, + "2022": -10.48 + } + }, + "2023_2025": { + "years": 2.99, + "cagr_pct": 15.67, + "period_return_pct": 54.6, + "vol_pct": 9.35, + "sharpe": 1.33, + "mdd_pct": -9.51, + "calmar": 1.65, + "worst_year_pct": -2.83, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 41.7, + "avg_exposure_pct": 47.3, + "turnover_per_year_pct": 28.0, + "final_value": 9563195, + "contributed": 6800000, + "profit": 2763195, + "yearly": { + "2023": 13.15, + "2024": -2.83, + "2025": 40.61 + } + }, + "2026": { + "years": 0.7, + "cagr_pct": 53.24, + "period_return_pct": 35.03, + "vol_pct": 32.54, + "sharpe": 1.41, + "mdd_pct": -21.72, + "calmar": 2.45, + "worst_year_pct": 35.03, + "losing_years": 0, + "total_years": 1, + "negative_months_pct": 44.4, + "avg_exposure_pct": 48.4, + "turnover_per_year_pct": 47.1, + "final_value": 13876896, + "contributed": 7700000, + "profit": 6176896, + "yearly": { + "2026": 35.03 + } + } + }, + "triple_cost": { + "years": 6.19, + "cagr_pct": 13.42, + "period_return_pct": 118.11, + "vol_pct": 13.88, + "sharpe": 0.78, + "mdd_pct": -21.75, + "calmar": 0.62, + "worst_year_pct": -10.75, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 42.7, + "avg_exposure_pct": 46.9, + "turnover_per_year_pct": 39.6, + "final_value": 13850257, + "contributed": 7700000, + "profit": 6150257, + "yearly": { + "2020": 15.33, + "2021": 1.58, + "2022": -10.75, + "2023": 13.15, + "2024": -2.84, + "2025": 40.35, + "2026": 35.2 + } + } + }, + "old_product": { + "label": "기존 방식", + "all": { + "years": 6.19, + "cagr_pct": 10.74, + "period_return_pct": 88.12, + "vol_pct": 10.85, + "sharpe": 0.74, + "mdd_pct": -16.02, + "calmar": 0.67, + "worst_year_pct": -5.67, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 42.7, + "avg_exposure_pct": 29.4, + "turnover_per_year_pct": 184.8, + "final_value": 12256469, + "contributed": 7700000, + "profit": 4556469, + "yearly": { + "2020": 15.54, + "2021": 1.75, + "2022": -5.67, + "2023": 3.63, + "2024": -4.68, + "2025": 28.73, + "2026": 33.4 + } + }, + "periods": { + "2020_2022": { + "years": 2.48, + "cagr_pct": 4.26, + "period_return_pct": 10.9, + "vol_pct": 6.15, + "sharpe": 0.23, + "mdd_pct": -10.05, + "calmar": 0.42, + "worst_year_pct": -5.67, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 43.3, + "avg_exposure_pct": 31.9, + "turnover_per_year_pct": 74.5, + "final_value": 3160711, + "contributed": 3200000, + "profit": -39289, + "yearly": { + "2020": 15.54, + "2021": 1.75, + "2022": -5.67 + } + }, + "2023_2025": { + "years": 2.99, + "cagr_pct": 8.36, + "period_return_pct": 27.16, + "vol_pct": 6.4, + "sharpe": 0.86, + "mdd_pct": -7.86, + "calmar": 1.06, + "worst_year_pct": -4.68, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 41.7, + "avg_exposure_pct": 24.8, + "turnover_per_year_pct": 132.4, + "final_value": 8461683, + "contributed": 6800000, + "profit": 1661683, + "yearly": { + "2023": 3.63, + "2024": -4.68, + "2025": 28.73 + } + }, + "2026": { + "years": 0.7, + "cagr_pct": 50.62, + "period_return_pct": 33.4, + "vol_pct": 26.85, + "sharpe": 1.58, + "mdd_pct": -16.02, + "calmar": 3.16, + "worst_year_pct": 33.4, + "losing_years": 0, + "total_years": 1, + "negative_months_pct": 44.4, + "avg_exposure_pct": 39.6, + "turnover_per_year_pct": 351.4, + "final_value": 12256469, + "contributed": 7700000, + "profit": 4556469, + "yearly": { + "2026": 33.4 + } + } + }, + "triple_cost": { + "years": 6.19, + "cagr_pct": 10.27, + "period_return_pct": 83.24, + "vol_pct": 10.81, + "sharpe": 0.71, + "mdd_pct": -16.11, + "calmar": 0.64, + "worst_year_pct": -5.91, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 42.7, + "avg_exposure_pct": 28.8, + "turnover_per_year_pct": 186.0, + "final_value": 12053641, + "contributed": 7700000, + "profit": 4353641, + "yearly": { + "2020": 15.33, + "2021": 1.63, + "2022": -5.91, + "2023": 3.26, + "2024": -5.4, + "2025": 27.59, + "2026": 33.31 + } + } + }, + "minimum": { + "label": "주식만 조절·중복 축소 방지", + "all": { + "years": 6.19, + "cagr_pct": 12.97, + "period_return_pct": 112.81, + "vol_pct": 11.26, + "sharpe": 0.9, + "mdd_pct": -15.74, + "calmar": 0.82, + "worst_year_pct": -4.12, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 40.0, + "avg_exposure_pct": 36.7, + "turnover_per_year_pct": 197.9, + "final_value": 13322718, + "contributed": 7700000, + "profit": 5622718, + "yearly": { + "2020": 15.54, + "2021": 1.9, + "2022": -4.12, + "2023": 7.47, + "2024": -3.96, + "2025": 36.66, + "2026": 33.66 + } + }, + "periods": { + "2020_2022": { + "years": 2.48, + "cagr_pct": 5.01, + "period_return_pct": 12.88, + "vol_pct": 6.26, + "sharpe": 0.35, + "mdd_pct": -9.21, + "calmar": 0.54, + "worst_year_pct": -4.12, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 36.7, + "avg_exposure_pct": 33.0, + "turnover_per_year_pct": 82.8, + "final_value": 3208413, + "contributed": 3200000, + "profit": 8413, + "yearly": { + "2020": 15.54, + "2021": 1.9, + "2022": -4.12 + } + }, + "2023_2025": { + "years": 2.99, + "cagr_pct": 12.18, + "period_return_pct": 41.05, + "vol_pct": 8.09, + "sharpe": 1.14, + "mdd_pct": -8.8, + "calmar": 1.38, + "worst_year_pct": -3.96, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 41.7, + "avg_exposure_pct": 39.3, + "turnover_per_year_pct": 157.9, + "final_value": 9242724, + "contributed": 6800000, + "profit": 2442724, + "yearly": { + "2023": 7.47, + "2024": -3.96, + "2025": 36.66 + } + }, + "2026": { + "years": 0.7, + "cagr_pct": 51.03, + "period_return_pct": 33.66, + "vol_pct": 26.31, + "sharpe": 1.62, + "mdd_pct": -15.74, + "calmar": 3.24, + "worst_year_pct": 33.66, + "losing_years": 0, + "total_years": 1, + "negative_months_pct": 44.4, + "avg_exposure_pct": 39.0, + "turnover_per_year_pct": 335.2, + "final_value": 13322718, + "contributed": 7700000, + "profit": 5622718, + "yearly": { + "2026": 33.66 + } + } + }, + "triple_cost": { + "years": 6.19, + "cagr_pct": 12.68, + "period_return_pct": 109.44, + "vol_pct": 11.28, + "sharpe": 0.88, + "mdd_pct": -15.73, + "calmar": 0.81, + "worst_year_pct": -4.18, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 40.0, + "avg_exposure_pct": 36.7, + "turnover_per_year_pct": 197.1, + "final_value": 13189202, + "contributed": 7700000, + "profit": 5489202, + "yearly": { + "2020": 15.33, + "2021": 1.75, + "2022": -4.18, + "2023": 6.99, + "2024": -4.06, + "2025": 36.66, + "2026": 32.79 + } + } + } + }, + "fractional_research": { + "static": { + "all": { + "years": 12.21, + "cagr_pct": 9.59, + "period_return_pct": 206.13, + "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.9, + "avg_exposure_pct": 50.9, + "turnover_per_year_pct": 16.7, + "final_value": 35409900, + "contributed": 15000000, + "profit": 20409900, + "yearly": { + "2014": -1.59, + "2015": 1.53, + "2016": 6.54, + "2017": 14.43, + "2018": -7.34, + "2019": 8.81, + "2020": 21.17, + "2021": 3.11, + "2022": -11.21, + "2023": 13.89, + "2024": -3.35, + "2025": 44.44, + "2026": 41.33 + } + }, + "zero_cash_yield": { + "years": 12.21, + "cagr_pct": 8.06, + "period_return_pct": 157.67, + "vol_pct": 12.08, + "sharpe": 0.47, + "mdd_pct": -21.87, + "calmar": 0.37, + "worst_year_pct": -12.5, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 43.9, + "avg_exposure_pct": 50.9, + "turnover_per_year_pct": 16.4, + "final_value": 32154384, + "contributed": 15000000, + "profit": 17154384, + "yearly": { + "2014": -2.33, + "2015": 0.06, + "2016": 5.02, + "2017": 12.81, + "2018": -8.67, + "2019": 7.27, + "2020": 19.59, + "2021": 1.62, + "2022": -12.5, + "2023": 12.3, + "2024": -4.7, + "2025": 42.47, + "2026": 40.1 + } + }, + "periods": { + "2014_2018": { + "years": 4.5, + "cagr_pct": 2.73, + "period_return_pct": 12.88, + "vol_pct": 6.06, + "sharpe": -0.01, + "mdd_pct": -10.33, + "calmar": 0.26, + "worst_year_pct": -7.34, + "losing_years": 2, + "total_years": 5, + "negative_months_pct": 41.8, + "avg_exposure_pct": 49.9, + "turnover_per_year_pct": 19.3, + "final_value": 6011424, + "contributed": 5700000, + "profit": 311424, + "yearly": { + "2014": -1.59, + "2015": 1.53, + "2016": 6.54, + "2017": 14.43, + "2018": -7.34 + } + }, + "2019_2022": { + "years": 3.99, + "cagr_pct": 4.83, + "period_return_pct": 20.7, + "vol_pct": 10.26, + "sharpe": 0.23, + "mdd_pct": -18.04, + "calmar": 0.27, + "worst_year_pct": -11.21, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 41.7, + "avg_exposure_pct": 50.9, + "turnover_per_year_pct": 10.1, + "final_value": 12189747, + "contributed": 10500000, + "profit": 1689747, + "yearly": { + "2019": 8.81, + "2020": 21.17, + "2021": 3.11, + "2022": -11.21 + } + }, + "2023_2026": { + "years": 3.7, + "cagr_pct": 24.43, + "period_return_pct": 124.7, + "vol_pct": 17.92, + "sharpe": 1.18, + "mdd_pct": -21.73, + "calmar": 1.12, + "worst_year_pct": -3.35, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 42.2, + "avg_exposure_pct": 51.9, + "turnover_per_year_pct": 19.7, + "final_value": 35409900, + "contributed": 15000000, + "profit": 20409900, + "yearly": { + "2023": 13.89, + "2024": -3.35, + "2025": 44.44, + "2026": 41.33 + } + } + } + }, + "old_product": { + "all": { + "years": 12.21, + "cagr_pct": 8.65, + "period_return_pct": 175.32, + "vol_pct": 9.27, + "sharpe": 0.64, + "mdd_pct": -16.42, + "calmar": 0.53, + "worst_year_pct": -4.57, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 39.9, + "avg_exposure_pct": 38.2, + "turnover_per_year_pct": 72.2, + "final_value": 32804251, + "contributed": 15000000, + "profit": 17804251, + "yearly": { + "2014": -1.83, + "2015": 0.7, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49, + "2019": 5.33, + "2020": 12.72, + "2021": 3.92, + "2022": -4.27, + "2023": 7.87, + "2024": -4.57, + "2025": 40.75, + "2026": 38.96 + } + }, + "zero_cash_yield": { + "years": 12.21, + "cagr_pct": 5.9, + "period_return_pct": 101.43, + "vol_pct": 8.68, + "sharpe": 0.38, + "mdd_pct": -16.44, + "calmar": 0.36, + "worst_year_pct": -5.92, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 43.9, + "avg_exposure_pct": 33.0, + "turnover_per_year_pct": 68.8, + "final_value": 26905077, + "contributed": 15000000, + "profit": 11905077, + "yearly": { + "2014": -2.69, + "2015": -1.36, + "2016": 4.35, + "2017": 12.83, + "2018": -5.47, + "2019": 3.26, + "2020": 7.87, + "2021": 2.13, + "2022": -5.92, + "2023": 3.04, + "2024": -3.09, + "2025": 30.3, + "2026": 35.4 + } + }, + "periods": { + "2014_2018": { + "years": 4.5, + "cagr_pct": 3.31, + "period_return_pct": 15.78, + "vol_pct": 4.59, + "sharpe": 0.1, + "mdd_pct": -6.38, + "calmar": 0.52, + "worst_year_pct": -3.49, + "losing_years": 2, + "total_years": 5, + "negative_months_pct": 38.2, + "avg_exposure_pct": 38.1, + "turnover_per_year_pct": 31.8, + "final_value": 6229198, + "contributed": 5700000, + "profit": 529198, + "yearly": { + "2014": -1.83, + "2015": 0.7, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49 + } + }, + "2019_2022": { + "years": 3.99, + "cagr_pct": 4.26, + "period_return_pct": 18.12, + "vol_pct": 6.98, + "sharpe": 0.21, + "mdd_pct": -11.84, + "calmar": 0.36, + "worst_year_pct": -4.27, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 41.7, + "avg_exposure_pct": 34.7, + "turnover_per_year_pct": 38.4, + "final_value": 12472365, + "contributed": 10500000, + "profit": 1972365, + "yearly": { + "2019": 5.33, + "2020": 12.72, + "2021": 3.92, + "2022": -4.27 + } + }, + "2023_2026": { + "years": 3.7, + "cagr_pct": 20.79, + "period_return_pct": 101.33, + "vol_pct": 14.32, + "sharpe": 1.22, + "mdd_pct": -16.42, + "calmar": 1.27, + "worst_year_pct": -4.57, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 40.0, + "avg_exposure_pct": 42.2, + "turnover_per_year_pct": 100.0, + "final_value": 32804251, + "contributed": 15000000, + "profit": 17804251, + "yearly": { + "2023": 7.87, + "2024": -4.57, + "2025": 40.75, + "2026": 38.96 + } + } + } + }, + "minimum": { + "all": { + "years": 12.21, + "cagr_pct": 9.08, + "period_return_pct": 189.17, + "vol_pct": 9.41, + "sharpe": 0.68, + "mdd_pct": -16.42, + "calmar": 0.55, + "worst_year_pct": -4.58, + "losing_years": 4, + "total_years": 13, + "negative_months_pct": 39.9, + "avg_exposure_pct": 38.7, + "turnover_per_year_pct": 71.0, + "final_value": 33748221, + "contributed": 15000000, + "profit": 18748221, + "yearly": { + "2014": -1.83, + "2015": 0.7, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49, + "2019": 5.33, + "2020": 18.43, + "2021": 3.91, + "2022": -4.28, + "2023": 7.85, + "2024": -4.58, + "2025": 40.77, + "2026": 38.96 + } + }, + "zero_cash_yield": { + "years": 12.21, + "cagr_pct": 6.39, + "period_return_pct": 113.04, + "vol_pct": 9.12, + "sharpe": 0.41, + "mdd_pct": -16.4, + "calmar": 0.39, + "worst_year_pct": -6.35, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 43.9, + "avg_exposure_pct": 36.5, + "turnover_per_year_pct": 68.0, + "final_value": 27517557, + "contributed": 15000000, + "profit": 12517557, + "yearly": { + "2014": -2.69, + "2015": -1.36, + "2016": 4.35, + "2017": 12.83, + "2018": -5.47, + "2019": 3.26, + "2020": 15.22, + "2021": 2.67, + "2022": -6.35, + "2023": 6.07, + "2024": -6.21, + "2025": 33.89, + "2026": 30.85 + } + }, + "periods": { + "2014_2018": { + "years": 4.5, + "cagr_pct": 3.31, + "period_return_pct": 15.78, + "vol_pct": 4.59, + "sharpe": 0.1, + "mdd_pct": -6.38, + "calmar": 0.52, + "worst_year_pct": -3.49, + "losing_years": 2, + "total_years": 5, + "negative_months_pct": 38.2, + "avg_exposure_pct": 38.1, + "turnover_per_year_pct": 31.8, + "final_value": 6229198, + "contributed": 5700000, + "profit": 529198, + "yearly": { + "2014": -1.83, + "2015": 0.7, + "2016": 6.05, + "2017": 14.43, + "2018": -3.49 + } + }, + "2019_2022": { + "years": 3.99, + "cagr_pct": 5.56, + "period_return_pct": 24.07, + "vol_pct": 7.55, + "sharpe": 0.37, + "mdd_pct": -11.83, + "calmar": 0.47, + "worst_year_pct": -4.28, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 41.7, + "avg_exposure_pct": 36.3, + "turnover_per_year_pct": 34.3, + "final_value": 12942422, + "contributed": 10500000, + "profit": 2442422, + "yearly": { + "2019": 5.33, + "2020": 18.43, + "2021": 3.91, + "2022": -4.28 + } + }, + "2023_2026": { + "years": 3.7, + "cagr_pct": 20.79, + "period_return_pct": 101.3, + "vol_pct": 14.32, + "sharpe": 1.22, + "mdd_pct": -16.42, + "calmar": 1.27, + "worst_year_pct": -4.58, + "losing_years": 1, + "total_years": 4, + "negative_months_pct": 40.0, + "avg_exposure_pct": 42.1, + "turnover_per_year_pct": 100.0, + "final_value": 33748221, + "contributed": 15000000, + "profit": 18748221, + "yearly": { + "2023": 7.85, + "2024": -4.58, + "2025": 40.77, + "2026": 38.96 + } + } + } + } + }, + "limitations": [ + "동일 기간을 이미 살펴본 사후 검증이며 독립적인 미사용 표본이 아님", + "ETF 분배금 미포함; 현금 이자 0%, CD ETF 양의 매매차익 15.4% 상한 과세", + "다음 거래일 종가에 비용을 더한 근사 체결; 실시간 호가·괴리율·미체결 미재현", + "소수 주 연구의 현금금리는 연 3% 고정 가정; 금리 0% 민감도도 공개", + "부분 연도는 연도 전체 수익률이 아님; 실전 자동 전환 없음" + ] +} \ No newline at end of file diff --git a/tests/test_basket_evaluation.py b/tests/test_basket_evaluation.py index bad0ff8..fe432d0 100644 --- a/tests/test_basket_evaluation.py +++ b/tests/test_basket_evaluation.py @@ -201,6 +201,19 @@ def test_collector_uses_basket_initial_capital(self): ) assert result["metrics"]["initial_capital"] == 30_000_000 + def test_changed_policy_reports_wait_even_if_old_operation_passed(self): + from unittest.mock import patch + from core.basket_evaluation import collect_basket_paper_evaluation + baskets = {"changed": {"enabled": True, "holdings": {"069500": 1.}, + "promotion": {"paper_only": True, "review_note": "새 규칙 검증 중"}}} + with patch("core.basket_rebalancer.BasketRebalancer._load_baskets_config", return_value=baskets), \ + patch("core.basket_evaluation.evaluate_basket_paper_operation", return_value={"verdict": "PASS_CANDIDATE", "issues": []}): + result, _ = collect_basket_paper_evaluation(basket_name="changed", include_benchmark=False) + assert result["verdict"] == "WAIT" + assert result["operation_verdict"] == "PASS_CANDIDATE" + assert result["paper_only"] is True + assert "새 규칙 검증 중" in result["issues"] + def test_collector_falls_back_to_global_capital(self): from unittest.mock import patch from core.basket_rebalancer import BasketRebalancer diff --git a/tests/test_basket_live_gate.py b/tests/test_basket_live_gate.py index a20b230..28edf5d 100644 --- a/tests/test_basket_live_gate.py +++ b/tests/test_basket_live_gate.py @@ -64,6 +64,14 @@ def test_pass_candidate_opens_gate(self): issues = self._run("basket_rebalance:kr_diversified_hold") assert issues == [] + def test_changed_policy_cannot_reuse_old_pass_or_mock_bypass(self): + baskets = {"changed": {"enabled": True, "holdings": {"069500": 1.}, + "promotion": {"paper_only": True}}} + for use_mock in (False, True): + issues = self._run("basket_rebalance:changed", baskets=baskets, + verdict="PASS_CANDIDATE", use_mock=use_mock) + assert any("paper_only" in issue for issue in issues) + def test_gate_evaluates_its_own_basket_record(self): """게이트는 반드시 '자기 바스켓'의 기록으로 평가한다 — 이름 없이 합산하면 다른 바스켓의 60일 트랙레코드로 신규 바스켓이 승격되는 구멍.""" diff --git a/tests/test_basket_overlay_integration.py b/tests/test_basket_overlay_integration.py index 0ae14c1..759dca3 100644 --- a/tests/test_basket_overlay_integration.py +++ b/tests/test_basket_overlay_integration.py @@ -111,6 +111,14 @@ def test_missing_index_data_keeps_previous_state_and_flags(self): assert decision.scale == pytest.approx(0.5) assert decision.data_issues and "부족" in decision.data_issues[0] + def test_missing_last_close_cannot_look_like_a_trend_recovery(self): + save_overlay_state("t", OverlayDecision(scale=0.5, trend_below=True)) + rb = _make(_basket(self.overlays), [100.] * 199 + [110., float("nan")]) + with _patch_snapshots(rb): + decision = rb.overlay_decision() + assert decision.scale == .5 + assert decision.data_issues + def test_today_bar_is_excluded(self): """오늘 날짜 봉은 전일까지의 정보가 아니므로 제외한다.""" from datetime import datetime @@ -143,3 +151,37 @@ def test_recovery_releases(self): 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% 안 → 해제 + + +def test_defensive_etf_is_retained_and_gets_released_equity_allocation(): + cfg = _basket({"combination": "minimum", "defensive_symbol": "357870", + "trend_filter": {"enabled": True}, "drawdown_guard": {"enabled": True}}, target=.95) + cfg["holdings"] = {"069500": .5, "357870": .5} + rb = _make(cfg, closes=[100.]*199+[90.], cumulative=[0., -12.]) + with _patch_snapshots(rb): + assert rb.overlay_decision().scale == .5 + assert rb._stock_fraction() == pytest.approx(.95) + targets = rb.get_target_weights() + assert targets == pytest.approx({"069500": .25, "357870": .75}) + assert targets["357870"] * rb._stock_fraction() == pytest.approx(.7125) + + +def test_future_index_bar_and_unsorted_rows_cannot_change_signal(): + cfg = _basket({"trend_filter": {"enabled": True, "ma_days": 200}}) + rb = _make(cfg, closes=[]) + from datetime import datetime, timedelta + future = datetime.now() + timedelta(days=2) + dates = list(pd.date_range(end="2020-01-01", periods=200, freq="B")) + [future] + frame = pd.DataFrame({"date":dates,"close":[100.]*200 + [10000.]}) + rb.data_collector.fetch_korean_stock = lambda *a: frame.iloc[::-1] + assert rb._fetch_index_closes("KS200", 200) == [100.]*200 + + +def test_nav_ignores_today_and_future_snapshots(): + cfg = _basket({"drawdown_guard": {"enabled": True}}) + rb = _make(cfg, closes=[], cumulative=[0., -12., 100.]) + from datetime import datetime, timedelta + today = datetime.now().date() + rb._nav_frame["date"] = [today-timedelta(days=2), today-timedelta(days=1), today] + with _patch_snapshots(rb): + assert rb.overlay_decision().drawdown_active is True diff --git a/tests/test_critical_fixes.py b/tests/test_critical_fixes.py index 0c4ed41..bbb0a50 100644 --- a/tests/test_critical_fixes.py +++ b/tests/test_critical_fixes.py @@ -945,7 +945,9 @@ def test_dashboard_docs_and_config_default_to_loopback(self): settings = settings_path.read_text(encoding="utf-8") assert 'host: "127.0.0.1"' in settings assert 'host: "127.0.0.1"' in example - assert "기본 바인드는 http://127.0.0.1:8080" in readme + # 문장과 Markdown 표현은 바뀌어도 로컬 접속 주소 계약은 유지한다. + assert "http://127.0.0.1:8080" in readme + assert "http://0.0.0.0:8080" not in readme assert "| **dashboard** | host(127.0.0.1), port(8080) |" in project_guide assert "| **dashboard** | host(0.0.0.0), port(8080) |" not in project_guide diff --git a/tests/test_dashboard_baskets.py b/tests/test_dashboard_baskets.py index ed856f6..2a12509 100644 --- a/tests/test_dashboard_baskets.py +++ b/tests/test_dashboard_baskets.py @@ -69,6 +69,23 @@ def _cfg(basket_name): class TestGetBasketsJson: + def test_defensive_target_and_zero_scale_match_order_planner(self, tmp_path, monkeypatch): + from core.risk_overlays import OverlayDecision, save_overlay_state + monkeypatch.setenv("QUANT_OVERLAY_STATE_DIR", str(tmp_path)) + name = "dashboard_defensive_zero" + _seed_pocket(name) + cfg = _cfg(name) + cfg[name].update({"target_stock_weight": .95, "min_cash_ratio": .05, + "holdings": {"069500":.5,"357870":.5}, + "overlays":{"trend_filter":{"enabled":True},"defensive_symbol":"357870"}}) + save_overlay_state(name, OverlayDecision(scale=0.)) + 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["overlay"]["scale"] == 0. + assert b["design_fraction"] == pytest.approx(.95) + assert b["target_weights"] == pytest.approx({"069500":0.,"357870":.95}) + def test_principal_snapshot_deployment_positions(self): name = "kr_pocket_t1" acct = _seed_pocket(name) @@ -335,8 +352,9 @@ def test_html_page_contains_basket_tracks_section(): assert "basketTracks" in html # 주력 포트폴리오 섹션 assert "chartAccount" in html # 장기 차트 계정 선택기 assert 'id="decisionTitle"' in html # 오늘의 단일 판단 - assert ' 300_000 + np.testing.assert_allclose(frame.twr, 1.0) + + +def test_fees_count_from_first_trade_and_no_cash_borrowing(): + frame, _ = simulate( + prices([100.0] * 4), + Policy("all", "전액"), + target_stock=1, + monthly=0, + rf_annual=0, + commission=0.01, + slippage=0.02, + ) + assert (frame.cash >= -1e-8).all() + assert frame.iloc[0].daily_return < -0.02 + assert frame.iloc[-1].twr < 1 + + +def test_full_exit_never_creates_short_position(): + series = prices([100.0] * 21 + [50.0] * 5) + frame, _ = simulate( + series, + Policy("exit", "청산", trend=True, trend_ma_days=20, trend_off_scale=0), + target_stock=1, + monthly=0, + rf_annual=0, + slippage=0.03, + ) + assert (frame.shares >= 0).all() + assert frame.iloc[-1].shares == 0 + + +def test_changing_future_prices_cannot_change_past_orders(): + series = prices([100.0] * 25 + [80.0] * 10 + [105.0] * 10) + changed = series.copy() + changed.iloc[35:] *= 3 + policy = Policy( + "both", + "추세와 낙폭", + trend=True, + trend_ma_days=20, + dd=True, + combination="minimum", + ) + a, _ = simulate(series, policy) + b, _ = simulate(changed, policy) + pd.testing.assert_frame_equal(a.iloc[:35], b.iloc[:35]) + + +@pytest.mark.parametrize( + "values", [[100.0, float("nan")], [100.0, float("inf")], [100.0, 0.0]] +) +def test_invalid_prices_are_rejected(values): + with pytest.raises(ValueError): + simulate(prices(values), Policy("test", "검증")) + + +def test_integer_etf_execution_respects_shares_cash_and_future_boundary(): + from tools.risk_review import integer_etf_simulation + + idx = pd.bdate_range("2020-01-01", periods=280) + panel = pd.DataFrame( + { + "KS200": [100.0] * 220 + [65.0] * 60, + "069500": [12000.0] * 220 + [8000.0] * 60, + "357870": [5000.0] * 280, + }, + index=idx, + ) + policy = Policy("min", "방어", trend=True, dd=True, combination="minimum") + f, _ = integer_etf_simulation(panel, policy) + changed = panel.copy() + changed.iloc[245:] = changed.iloc[245:] * 3 + g, _ = integer_etf_simulation(changed, policy) + assert (f.cash >= 0).all() + assert (f.qty_069500 >= 0).all() and (f.qty_357870 >= 0).all() + assert f.scale.min() == 0.5 + pd.testing.assert_frame_equal(f.iloc[:45], g.iloc[:45]) + np.testing.assert_allclose(f.twr, (1 + f.daily_return).cumprod()) diff --git a/tests/test_risk_overlays.py b/tests/test_risk_overlays.py index 7ecf014..655c3a4 100644 --- a/tests/test_risk_overlays.py +++ b/tests/test_risk_overlays.py @@ -84,6 +84,8 @@ def test_drawdown_from_cumulative_returns_uses_initial_capital_as_first_peak(sel 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 + assert drawdown_from_cumulative_returns([0., None, 10.]) is None + assert drawdown_from_cumulative_returns([0., "오류", 10.]) is None def test_trigger_and_release(self): assert drawdown_guard_active(-0.09, self.cfg, False) is False @@ -135,9 +137,43 @@ def test_missing_data_keeps_previous_state_and_flags_issue(self): 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 describe_decision(d).startswith("기본 투자 비중 유지") assert "첫 실행 대기" in describe_decision(None) + def test_minimum_combination_does_not_cut_same_risk_twice(self): + cfg = parse_overlay_config({"overlays": {"combination": "minimum", "trend_filter": {"enabled": True}, "drawdown_guard": {"enabled": True}}}) + d = compute_decision(cfg, index_closes=_closes(95.), cumulative_returns_pct=[0., -12.]) + assert d.scale == .5 + + def test_missing_data_cannot_increase_previous_exposure(self): + cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True}}}) + d = compute_decision(cfg, index_closes=[], prev_state={"scale": .25}) + assert d.scale == .25 + assert d.data_issues + + def test_nonfinite_price_is_not_a_recovery_signal(self): + cfg = parse_overlay_config({"overlays": {"trend_filter": {"enabled": True}}}) + d = compute_decision(cfg, index_closes=_closes(float('inf')), prev_state={"trend_below": True, "scale": .5}) + assert d.scale == .5 + assert d.data_issues + + +class TestDefensiveAllocation: + def test_released_stock_weight_goes_to_defensive_asset(self): + from core.risk_overlays import overlay_target_weights + result = overlay_target_weights({"069500": .5, "357870": .5}, .95, .5, "357870") + assert result == pytest.approx({"069500": .2375, "357870": .7125}) + assert sum(result.values()) == pytest.approx(.95) + + def test_stock_only_basket_keeps_cash_fallback(self): + from core.risk_overlays import overlay_target_weights + assert overlay_target_weights({"A": 1}, .6, .5) == {"A": .3} + + def test_unknown_defensive_asset_is_not_silently_accepted(self): + from core.risk_overlays import overlay_target_weights + with pytest.raises(ValueError): + overlay_target_weights({"A": 1}, .6, .5, "missing") + class TestState: def test_roundtrip_and_applied_fraction(self, tmp_path): diff --git a/tools/risk_overlay_backtest.py b/tools/risk_overlay_backtest.py index 6ffeaaa..91fc760 100644 --- a/tools/risk_overlay_backtest.py +++ b/tools/risk_overlay_backtest.py @@ -1,30 +1,17 @@ #!/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만 +"""추세·낙폭·변동성 규칙의 과거 성과를 비교하는 소수 주 연구용 백테스트. + +위험자산 한 종목(또는 고정 수량 주식 바스켓 지수)과 연 3% 가정 현금을 비교한다. +매월 적립금은 시간가중수익률로 분리하며, 전일 정보로 다음 거래일 종가에 거래한다. +국내 주식형 ETF는 증권거래세를 제외하고, 개별 주식은 매도 시 0.20%를 적용한다. +수수료·슬리피지는 반영하지만 ETF 분배금과 실제 호가·정수 주 제약은 반영하지 않는다. + +실제 ETF 두 종목과 1주 단위를 쓰는 최신 검증은 tools/risk_review.py에 있다. +이 실험만으로 초과수익의 가능성이나 향후 성과를 단정하지 않는다. + +실행: python tools/risk_overlay_backtest.py --as-of 2026-09-17 """ + from __future__ import annotations import argparse @@ -37,8 +24,18 @@ _ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(_ROOT)) -BASKET_SYMBOLS = ["005930", "000660", "035420", "005380", "051910", - "005490", "055550", "035720", "012330", "105560"] +BASKET_SYMBOLS = [ + "005930", + "000660", + "035420", + "005380", + "051910", + "005490", + "055550", + "035720", + "012330", + "105560", +] COMMISSION = 0.00015 SLIPPAGE = 0.0005 STOCK_TAX = 0.0020 @@ -53,43 +50,92 @@ class Policy: trend: bool = False trend_ma_days: int = 200 trend_off_scale: float = 0.5 - trend_band: float = 0.02 # 200일선 ±2% 히스테리시스 — 선 근처 왕복 매매를 막는다 + 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 단위로 양자화 — 매일 미세 조정하지 않는다 + 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 + combination: str = "product" notes: list[str] = field(default_factory=list) POLICIES = [ - Policy("static", "고정 비중(현행)"), - Policy("trend50", "추세 필터 · 200일선 아래면 절반", trend=True, trend_off_scale=0.5), + 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), + Policy( + "trend50_dd10", + "추세 절반 + 낙폭 제어", + trend=True, + trend_off_scale=0.5, + dd=True, + ), + Policy( + "trend50_dd10_min", + "추세·낙폭 중 더 낮은 비중", + trend=True, + dd=True, + combination="minimum", + ), + Policy( + "trend50_vol20", + "추세 절반 + 변동성 20%", + trend=True, + trend_off_scale=0.5, + vol=True, + vol_target=0.20, + ), ] +AS_OF = "2026-09-17" +CACHE_DIR = _ROOT / "data" / "research_prices" + + def _fdr(symbol: str, start: str): import FinanceDataReader as fdr - df = fdr.DataReader(symbol, start) + import pandas as pd + + # 당일 미확정 종가와 기준일 이후의 데이터가 재실행 결과에 섞이지 않게 한다. + CACHE_DIR.mkdir(parents=True, exist_ok=True) + cache = CACHE_DIR / f"{symbol}_{start}_{AS_OF}.csv" + if cache.exists(): + df = pd.read_csv(cache, index_col=0, parse_dates=True) + else: + df = fdr.DataReader(symbol, start, AS_OF) + df.to_csv(cache) 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() + df.index = ( + df.index.tz_localize(None) + if getattr(df.index, "tz", None) is not None + else df.index + ) + result = ( + df.loc[ + (df.index >= pd.Timestamp(start)) & (df.index < pd.Timestamp(AS_OF)), + "close", + ] + .astype(float) + .dropna() + ) + if result.empty or not result.index.is_unique or not (result > 0).all(): + raise ValueError(f"{symbol}: 유효한 종가가 없거나 날짜가 중복됐습니다") + return result.sort_index() 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) @@ -99,12 +145,14 @@ def _scale_series(closes, nav_twr, policy: Policy, index_closes=None): rel = (ref / ma - 1).reindex(closes.index).ffill().shift(1) # 히스테리시스: 선 아래로 band만큼 내려가야 '하락', 위로 band만큼 올라와야 '상승'. below = [] - state = False + state = None for v in rel.to_numpy(): - if v != v: # NaN + if math.isnan(v): below.append(False) continue - if state and v > policy.trend_band: + if state is None: + state = v < 0.0 + elif state and v > policy.trend_band: state = False elif (not state) and v < -policy.trend_band: state = True @@ -115,16 +163,30 @@ def _scale_series(closes, nav_twr, policy: Policy, index_closes=None): 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) + 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): +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, + commission=COMMISSION, + slippage=SLIPPAGE, + min_trade=1_000.0, +): """단일 위험자산 + 현금성 자산의 DCA 포트폴리오. 위험자산은 소수 주 허용(연구용). - 매월 첫 거래일에 monthly 적립 → 현금으로 들어와 다음 리밸런싱에서 배분. @@ -136,6 +198,24 @@ def simulate(closes, policy: Policy, *, index_closes=None, target_stock=0.5, import numpy as np import pandas as pd + if ( + closes.empty + or not closes.index.is_unique + or not closes.index.is_monotonic_increasing + ): + raise ValueError("종가는 날짜순으로 정렬된, 중복 없는 시계열이어야 합니다") + if not np.isfinite(closes.to_numpy()).all() or not (closes > 0).all(): + raise ValueError("종가는 유한한 양수여야 합니다") + if initial <= 0 or monthly < 0 or not 0 <= target_stock <= 1 or rf_annual <= -1: + raise ValueError("자본·적립금·목표 비중·금리 설정을 확인하세요") + if ( + commission < 0 + or not 0 <= slippage < 1 + or tax_rate < 0 + or commission + tax_rate >= 1 + ): + raise ValueError("거래비용 설정을 확인하세요") + rf_daily = (1 + rf_annual) ** (1 / TRADING_DAYS) - 1 dates = closes.index scale = _scale_series(closes, None, policy, index_closes) @@ -151,73 +231,104 @@ def simulate(closes, policy: Policy, *, index_closes=None, target_stock=0.5, turnover_value = 0.0 exposure_sum = 0.0 rows = [] - prev_total_after_flow = None + prev_total = initial 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: + month = (day.year, day.month) + if prev_month is not None and month != prev_month and monthly > 0: cash += monthly contributed += monthly flow = monthly - prev_month = day.month + prev_month = month # 2) 현금 이자 - cash *= (1 + rf_daily) + cash *= 1 + rf_daily total_before = shares * price + cash # 3) 낙폭 제어 상태 (전일까지의 TWR 기준) s = float(scale.iloc[i]) + dd_now = twr_index / peak_index - 1 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 + s = ( + min(s, policy.dd_scale) + if policy.combination == "minimum" + else 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) + 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 + traded = 0.0 + cost = 0.0 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) + if abs(diff_value) > min_trade: + fill = ( + price * (1 + slippage) if diff_value > 0 else price * (1 - slippage) + ) + # 수수료까지 포함해 살 수 있는 수량, 실제 보유한 수량 안에서만 체결한다. + # 기존 diff/fill 매도는 슬리피지 때문에 전량 매도 때 음수 보유를 만들었다. + qty = ( + min(diff_value / price, cash / (fill * (1 + commission))) + if diff_value > 0 + else -min(shares, -diff_value / price) + ) + traded = abs(qty * fill) + cost = traded * (commission + (tax_rate if qty < 0 else 0.0)) cash -= qty * fill + cost shares += qty - turnover_value += abs(diff_value) + turnover_value += traded 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)) + daily_return = total / (prev_total + flow) - 1.0 + twr_index *= 1.0 + daily_return peak_index = max(peak_index, twr_index) - prev_total_after_flow = total - # 다음 날 적립이 들어오면 분모를 적립 포함으로 갱신해야 하므로 flow는 다음 루프의 total_before에 반영됨 + prev_total = total 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를 더해 둔다 (적립은 수익이 아니다). + rows.append( + { + "date": day, + "total": total, + "twr": twr_index, + "contributed": contributed, + "stock_w": shares * price / total if total > 0 else 0.0, + "scale": s, + "daily_return": daily_return, + "flow": flow, + "cash": cash, + "shares": shares, + "drawdown": twr_index / peak_index - 1.0, + "signal_drawdown": dd_now, + "drawdown_active": dd_active, + "trade_value": traded, + "cost": cost, + } + ) 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)} + # 판단에 쓴 TWR과 보고하는 TWR은 동일하다. 시작일 매수 비용도 지우지 않는다. + 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() + daily = frame["daily_return"] 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) @@ -225,23 +336,28 @@ def metrics(frame, extra, years_hint=None): 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 + first_year = twr.resample("YE").last().iloc[0] - 1 yearly.iloc[0] = first_year - monthly = twr.resample("ME").last().pct_change().dropna() + monthly_end = twr.resample("ME").last() + monthly = monthly_end.pct_change() + monthly.iloc[0] = monthly_end.iloc[0] - 1.0 avg_total = frame["total"].mean() - turnover_per_year = extra["turnover_value"] / max(avg_total, 1) / n_years if n_years > 0 else 0.0 + 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), + "period_return_pct": round((float(twr.iloc[-1]) - 1) * 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, + "calmar": round(float(calmar), 2) if math.isfinite(calmar) else None, "worst_year_pct": round(float(yearly.min()) * 100, 2), "losing_years": int((yearly < 0).sum()), - "total_years": int(len(yearly)), + "total_years": 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), @@ -260,34 +376,61 @@ def run_pocket(start="2002-01-01", use_etf=False): 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) + 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 + 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) # 동일비중 지수 (일별 리밸런싱 근사 없음: 첫날 동일비중 보유) + 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) + 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 + 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 @@ -299,8 +442,17 @@ def _plot(track_name, summary, frames, out_png, title): 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]}) + 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) @@ -313,14 +465,33 @@ def _plot(track_name, summary, frames, out_png, title): 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[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.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) @@ -328,20 +499,36 @@ def _plot(track_name, summary, frames, out_png, title): 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}% |") + lines = [ + f"### {track_title}", + "", + f"- 데이터: {summary['symbol']} {summary['start']} → {summary['end']}", + "", + "| 정책 | 연수익률(CAGR) | 최대낙폭 | 샤프 | 칼마 | 최악 연도 | 손실 연도 | 평균 주식 비중 | 연 회전율 |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|", + ] + for r in summary["results"].values(): + 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(): + global AS_OF parser = argparse.ArgumentParser() - parser.add_argument("--track", choices=["all", "pocket", "pocket_etf", "basket"], default="all") + parser.add_argument( + "--as-of", default=AS_OF, help="이 날짜 미만의 확정 일봉만 사용 (YYYY-MM-DD)" + ) + 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() + from datetime import date + + AS_OF = date.fromisoformat(args.as_of).isoformat() out_dir = Path(args.out_dir) image_dir = Path(args.image_dir) out_dir.mkdir(parents=True, exist_ok=True) @@ -351,22 +538,39 @@ def main(): 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만원 적립") + _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만원 적립") + _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년 약세장 포함") + _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.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)) diff --git a/tools/risk_review.py b/tools/risk_review.py new file mode 100644 index 0000000..f4b1dd5 --- /dev/null +++ b/tools/risk_review.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""2026-09 위험 관리 검증. 주문 없이 과거 종가와 격리된 가상 계좌만 사용한다. + +고정한 정책을 기간별·비용별로 비교한다. 미래에 수집될 자료를 확보한 실험이 아니므로 +이 결과를 독립적인 미사용 표본 검증이나 향후 수익 보장으로 해석하지 않는다. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import sys +from pathlib import Path + +import pandas as pd + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from core.risk_overlays import ( + compute_decision, + overlay_target_weights, + parse_overlay_config, +) +from tools import risk_overlay_backtest as research + + +def integer_etf_simulation( + panel, + policy, + *, + redirect=True, + cost_multiple=1.0, + initial=300_000.0, + monthly=100_000.0, + min_trade=50_000.0, +): + """ETF 1주 단위, 다음 거래일 종가, 현금 5%, 편도 회전 상한 60%로 비교. + + 실제 주문 엔진의 호가·체결 지연·괴리율은 재현하지 않는다. CD ETF는 양의 매매차익에 + 15.4%를 차감하는 상한 근사(과표기준가 미확보), KODEX 200 분배금은 미반영이다. + """ + cfg = parse_overlay_config( + { + "overlays": { + "combination": policy.combination, + "trend_filter": { + "enabled": policy.trend, + "ma_days": policy.trend_ma_days, + "band": policy.trend_band, + }, + "drawdown_guard": { + "enabled": policy.dd, + "trigger": policy.dd_trigger, + "release": policy.dd_release, + "scale": policy.dd_scale, + }, + } + } + ) + symbols = ["069500", "357870"] + quantity = {s: 0 for s in symbols} + avg = {s: 0.0 for s in symbols} + cash = initial + contributed = initial + previous_total = initial + twr = peak = 1.0 + previous_state = None + previous_month = None + rows = [] + # 200日 선 계산 기간은 성과 집계 전에 따로 확보한다. + start = max(200, panel.index.get_indexer([panel[symbols].dropna().index[0]])[0]) + commission = research.COMMISSION * cost_multiple + slippage = research.SLIPPAGE * cost_multiple + for i in range(start, len(panel)): + day = panel.index[i] + prices = {s: float(panel[s].iloc[i]) for s in symbols} + if any(not math.isfinite(p) or p <= 0 for p in prices.values()): + raise ValueError("ETF 종가가 누락됐습니다") + month = (day.year, day.month) + flow = ( + monthly if previous_month is not None and month != previous_month else 0.0 + ) + cash += flow + contributed += flow + previous_month = month + decision = compute_decision( + cfg, + index_closes=panel["KS200"].iloc[:i].tolist(), + cumulative_returns_pct=[(peak - 1) * 100, (twr - 1) * 100], + prev_state=previous_state, + now=day.to_pydatetime(), + ) + previous_state = decision.to_dict() + targets = overlay_target_weights( + {s: 0.5 for s in symbols}, + 0.95, + decision.scale, + "357870" if redirect else None, + ) + total_before = cash + sum(quantity[s] * prices[s] for s in symbols) + invested_fraction = sum(targets.values()) + sleeve = total_before * invested_fraction + invested = total_before - cash + drift = ( + max( + abs(quantity[s] * prices[s] / sleeve - targets[s] / invested_fraction) + for s in symbols + ) + if sleeve > 0 + else 0.0 + ) + need = ( + i == start + or drift >= 0.08 + or abs(invested / total_before - invested_fraction) >= 0.03 + ) + trade_value = costs = 0.0 + budget = total_before * 0.6 + if need: + # 매도 후 매수. 목표 미달 1주를 억지로 올려 사지 않는다. + diffs = { + s: total_before * targets[s] - quantity[s] * prices[s] for s in symbols + } + for s in sorted(symbols, key=lambda symbol: diffs[symbol]): + price, diff = prices[s], diffs[s] + qty = int(abs(diff) / price) + if qty <= 0 or qty * price < min_trade: + continue + fill = price * (1 + slippage if diff > 0 else 1 - slippage) + qty = min(qty, int(budget / fill)) + if diff < 0: + qty = min(qty, quantity[s]) + else: + available = max(0.0, cash - total_before * 0.05) + qty = min(qty, int(available / (fill * (1 + commission)))) + if qty <= 0 or qty * fill < min_trade: + continue + notional = qty * fill + cost = notional * commission + if diff < 0: + if s == "357870": + cost += max(0.0, fill - avg[s]) * qty * 0.154 + quantity[s] -= qty + cash += notional - cost + else: + avg[s] = (avg[s] * quantity[s] + notional + cost) / ( + quantity[s] + qty + ) + quantity[s] += qty + cash -= notional + cost + budget -= notional + trade_value += notional + costs += cost + total = cash + sum(quantity[s] * prices[s] for s in symbols) + daily_return = total / (previous_total + flow) - 1.0 + twr *= 1 + daily_return + peak = max(peak, twr) + previous_total = total + rows.append( + { + "date": day, + "total": total, + "twr": twr, + "drawdown": twr / peak - 1, + "daily_return": daily_return, + "flow": flow, + "contributed": contributed, + "cash": cash, + "stock_w": quantity["069500"] * prices["069500"] / total, + "scale": decision.scale, + "trade_value": trade_value, + "cost": costs, + **{f"qty_{s}": quantity[s] for s in symbols}, + } + ) + frame = pd.DataFrame(rows).set_index("date") + return frame, { + "turnover_value": frame.trade_value.sum(), + "avg_exposure": frame.stock_w.mean(), + } + + +def period_metrics(frame, start, end): + part = frame.loc[start:end].copy() + if len(part) < 2: + return None + part["twr"] = (1 + part.daily_return).cumprod() + part["drawdown"] = part.twr / part.twr.cummax().clip(lower=1) - 1 + extra = { + "turnover_value": part.trade_value.sum(), + "avg_exposure": part.stock_w.mean(), + } + return research.metrics(part, extra) + + +def plot_comparison(frames, path): + import matplotlib + + matplotlib.use("Agg") + from matplotlib import font_manager + from matplotlib import pyplot as plt + + for name in ["Malgun Gothic", "Noto Sans CJK 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 + fig, ax = plt.subplots( + 2, 1, figsize=(11, 6.6), sharex=True, gridspec_kw={"height_ratios": [2, 1]} + ) + names = { + "static": "고정 비중", + "old_product": "기존 방식 (TWR 오류 수정)", + "minimum": "주식만 조절·중복 축소 방지", + } + colors = {"static": "#9aa5b5", "old_product": "#8e6853", "minimum": "#3157a4"} + for key, f in frames.items(): + if key not in names: + continue + ax[0].plot(f.index, f.twr * 100, label=names[key], color=colors[key], lw=1.8) + ax[1].plot(f.index, f.drawdown * 100, color=colors[key], lw=1.2) + ax[0].set_title("ETF 적립 계좌의 수익과 하락 구간", loc="left", fontsize=16, pad=17) + ax[0].set_ylabel("시간가중 지수\n시작 = 100", fontsize=10) + ax[1].set_ylabel("고점 대비 하락률 (%)", fontsize=10) + ax[0].legend(frameon=False, fontsize=9, loc="upper left") + for a in ax: + a.spines[["top", "right"]].set_visible(False) + a.spines[["left", "bottom"]].set_color("#cbd1dd") + a.grid(axis="y", alpha=0.12) + a.tick_params(labelsize=9, colors="#596579") + fig.text( + 0.07, + 0.025, + "실제 ETF 종가 · 1주 단위 · 월 10만원 적립 · 수수료·슬리피지 반영 · 과거 성과이며 미래 수익을 보장하지 않음", + fontsize=9, + color="#596579", + ) + fig.tight_layout(rect=(0, 0.05, 1, 1)) + path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(path, dpi=150, facecolor="white") + plt.close(fig) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--as-of", default="2026-09-17") + parser.add_argument( + "--output", default="reports/research/risk_review_20260917.json" + ) + parser.add_argument("--image", default="docs/images/risk-review-20260917.png") + args = parser.parse_args() + research.AS_OF = args.as_of + series = {s: research._fdr(s, "2014-01-01") for s in ["069500", "357870", "KS200"]} + panel = pd.DataFrame(series).loc[: args.as_of] + policies = { + "static": research.Policy("static", "고정 비중"), + "old_product": research.Policy("old_product", "기존 방식", trend=True, dd=True), + "minimum": research.Policy( + "minimum", + "주식만 조절·중복 축소 방지", + trend=True, + dd=True, + combination="minimum", + ), + } + frames = {} + result = {} + for name, policy in policies.items(): + f, extra = integer_etf_simulation(panel, policy, redirect=name != "old_product") + frames[name] = f + result[name] = { + "label": policy.label, + "all": research.metrics(f, extra), + "periods": { + label: period_metrics(f, a, b) + for label, a, b in [ + ("2020_2022", "2020-01-01", "2022-12-31"), + ("2023_2025", "2023-01-01", "2025-12-31"), + ("2026", "2026-01-01", args.as_of), + ] + }, + } + stressed, ex = integer_etf_simulation( + panel, policy, redirect=name != "old_product", cost_multiple=3 + ) + result[name]["triple_cost"] = research.metrics(stressed, ex) + # 소수 주 장기 연구: 고정된 정책으로 구간을 나눠 보고, 금리 0%에서도 비교한다. + long_run = {} + for name, policy in policies.items(): + f, extra = research.simulate( + series["069500"], policy, index_closes=series["KS200"] + ) + zero, ex = research.simulate( + series["069500"], policy, index_closes=series["KS200"], rf_annual=0 + ) + long_run[name] = { + "all": research.metrics(f, extra), + "zero_cash_yield": research.metrics(zero, ex), + "periods": { + label: period_metrics(f, a, b) + for label, a, b in [ + ("2014_2018", "2014-01-01", "2018-12-31"), + ("2019_2022", "2019-01-01", "2022-12-31"), + ("2023_2026", "2023-01-01", args.as_of), + ] + }, + } + manifest = { + s: { + "first": str(v.index[0].date()), + "last": str(v.index[-1].date()), + "rows": len(v), + "sha256": hashlib.sha256(v.to_csv().encode()).hexdigest(), + } + for s, v in series.items() + } + payload = { + "as_of": args.as_of, + "last_complete_bar": str(frames["static"].index[-1].date()), + "integer_start": str(frames["static"].index[0].date()), + "source": "FinanceDataReader, close prices", + "data": manifest, + "integer_etf": result, + "fractional_research": long_run, + "limitations": [ + "동일 기간을 이미 살펴본 사후 검증이며 독립적인 미사용 표본이 아님", + "ETF 분배금 미포함; 현금 이자 0%, CD ETF 양의 매매차익 15.4% 상한 과세", + "다음 거래일 종가에 비용을 더한 근사 체결; 실시간 호가·괴리율·미체결 미재현", + "소수 주 연구의 현금금리는 연 3% 고정 가정; 금리 0% 민감도도 공개", + "부분 연도는 연도 전체 수익률이 아님; 실전 자동 전환 없음", + ], + } + target = Path(args.output) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text( + json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False), + encoding="utf-8", + ) + plot_comparison(frames, Path(args.image)) + print( + json.dumps( + { + "integer_start": payload["integer_start"], + "last": payload["last_complete_bar"], + "integer_etf": result, + "fractional": long_run, + }, + ensure_ascii=False, + indent=2, + ) + ) + + +if __name__ == "__main__": + main()