diff --git a/README.md b/README.md
index f747306..8579796 100644
--- a/README.md
+++ b/README.md
@@ -6,14 +6,15 @@
정해 둔 비중에 맞춰 주식과 ETF를 사고팔고, 원금·수익률·보유 종목을 한 화면에서 확인하는 개인용 투자 도구입니다. 과거 데이터로 운용 규칙을 비교하고, 모의투자로 실제 동작을 점검할 수 있습니다.
-
+
-*2026년 9월 17일 모의투자 기록을 불러온 실제 화면입니다. 아래 백테스트와는 별도의 계좌 기록입니다.*
+*2026년 9월 22일 모의투자 기록을 불러온 실제 화면입니다. 아래 백테스트와는 별도의 계좌 기록입니다.*
## 할 수 있는 일
- **계좌 확인** — 총자산, 투자원금, 현금, 보유 종목과 목표 비중을 확인합니다.
-- **성과 확인** — 적립금을 수익에서 제외한 수익률, 자산 추이, 고점 대비 하락률을 봅니다.
+- **날짜별 성과 탐색** — 날짜 눈금을 움직이면 그날의 수익률·원금·평가금액이 함께 바뀝니다. 적립금은 수익에서 제외합니다.
+- **입체·평면 차트와 CSV** — 같은 투자 기록을 두 방식으로 살펴보고, 조회한 기간의 원본 수치를 내려받습니다.
- **자동 리밸런싱** — 실제 비중이 설정 범위를 벗어나면 조정합니다. 최소 주문금액과 1주 단위를 반영합니다.
- **모의투자 검증** — 운영 기록, 누락된 자산 기록, 거래비용과 미해결 주문을 확인합니다.
- **운영 점검** — 자동매매 실행 여부, 거래 중지 상태, 증권사 연결과 데이터 갱신 상태를 봅니다.
@@ -22,11 +23,17 @@ Python·aiohttp·SQLite·한국투자증권 KIS API를 사용합니다. 화면
## 화면
+### 날짜를 고르면 그날의 계좌가 보입니다
+
+파란 차트의 띠는 실제 누적 수익률입니다. 날짜 눈금은 마우스·터치·방향키로 움직일 수 있습니다. 입체감은 선에 폭을 준 표현이며, 별도의 지표를 뜻하지 않습니다. 정확한 값은 평면 차트와 일별 기록 표에서도 확인할 수 있습니다.
+
+한글 글꼴은 로컬에서 읽고, 입체 표현은 가벼운 Canvas로 그립니다. 흰색·검정·코발트·시안의 역할과 참고한 실제 디자인 사례는 [화면 설계와 검증 기록](docs/DESIGN_20260922.md)에 정리했습니다.
+
### 보유 종목과 비중
매입금액과 목표 비중을 나란히 보여 줍니다. 계좌를 바꾸면 요약, 종목표, 차트, 적립금 입력 대상이 함께 바뀝니다.
-
+
### 모바일과 운영 상태
@@ -35,14 +42,14 @@ Python·aiohttp·SQLite·한국투자증권 KIS API를 사용합니다. 화면
모바일 화면 보기
-
+
자동매매 상태 화면 보기
-
+
@@ -61,21 +68,23 @@ ETF 적립의 기본 목표는 주식 ETF 47.5%·CD금리 ETF 47.5%·현금 5%
적립금이 들어오면 낙폭이 작아 보이던 백테스트 계산을 고쳤습니다. 이어서 추세·낙폭 조건이 겹칠 때 비중을 중복해서 줄이지 않고, 주식 축소분을 기존 CD금리 ETF로 옮기도록 바꿨습니다.
-
+
+
+**2020-07-07~2026-09-17, 실제 ETF 종가·1주 단위·월 10만원 적립 비교**
-**2020-07-07~2026-09-16, 실제 ETF 종가·1주 단위·월 10만원 적립 비교**
+9월 22일 수집 자료에서 지수 종가가 17일까지 제공되어, 모든 자료가 있는 17일까지 비교했습니다. ETF에만 있는 18일·21일 기록을 섞어 기간을 늘리지 않았습니다.
| 방식 | 연환산 수익률 | 최대 낙폭 | 샤프² |
|---|---:|---:|---:|
-| 고정 비중 | 13.54% | -21.72% | 0.80 |
-| 기존 위험 관리¹ | 10.74% | -16.02% | 0.74 |
-| 변경한 위험 관리 | 12.97% | -15.74% | 0.90 |
+| 고정 비중 | 13.53% | -21.72% | 0.79 |
+| 기존 위험 관리¹ | 10.73% | -16.02% | 0.74 |
+| 변경한 위험 관리 | 12.96% | -15.74% | 0.90 |
¹ 기존 방식도 적립금 계산 오류를 수정한 뒤 같은 조건으로 다시 계산했습니다. ² 샤프 계산의 기준금리는 연 3%로 고정했습니다.
변경한 방식은 이 전체 기간에서 기존 방식보다 수익률이 높고 최대 낙폭이 작았습니다. **고정 비중보다 수익률은 낮았고, 2023~2025년에는 기존 방식보다 낙폭이 컸습니다.** 수수료·슬리피지와 CD ETF의 보수적 세금 근사를 반영했지만, ETF 분배금·실시간 호가·미체결은 재현하지 못했습니다. 이미 살펴본 과거 자료로 비교한 결과이며 향후 수익을 보장하지 않습니다.
-기간별 결과, 비용 3배 조건, 2014년부터의 보조 실험, 2026년 9월까지 확인한 자료와 코드 변경 근거는 [위험 관리 검증 보고서](docs/RISK_REVIEW_20260917.md)에 정리했습니다.
+9월 22일에는 오래된 시세로 주식 비중을 다시 늘리던 가능성을 막고, 모의·실전 계좌의 위험 판단 기록을 분리했습니다. 백테스트 계산도 같은 일별 결과를 유지하면서 빨라졌습니다. [최신 검증과 자료 기준일](docs/RISK_REVIEW_20260922.md), [운용 규칙을 변경한 근거](docs/RISK_REVIEW_20260917.md)에서 결과와 한계를 확인할 수 있습니다.
## 시작하기
@@ -101,14 +110,14 @@ Copy-Item .env.example .env
.\.venv\Scripts\python.exe main.py --mode rebalance --basket kr_pocket
# 같은 조건으로 백테스트 재현
-.\.venv\Scripts\python.exe tools/risk_review.py --as-of 2026-09-17
+.\.venv\Scripts\python.exe tools/risk_review.py --as-of 2026-09-22
```
매일 자동 실행하려면 `main.py --mode schedule`을 사용합니다. KIS API 키 등 개인 설정은 `.env`에 넣고 Git에는 올리지 않습니다. 대시보드는 계좌 정보를 표시하므로 기본 설정대로 이 PC에서만 접속해 사용합니다.
## 자세히 보기
-- [위험 관리 검증과 한계](docs/RISK_REVIEW_20260917.md)
-- [화면 설계·한글 글꼴·성능 측정](docs/DASHBOARD_REVIEW_20260917.md)
+- [위험 관리 검증과 한계](docs/RISK_REVIEW_20260922.md)
+- [화면 설계·참고한 디자인·성능 측정](docs/DESIGN_20260922.md)
- [모의투자 평가 기준](docs/BASKET_PAPER_EVALUATION.md) · [실전 전환 절차](docs/PAPER_TO_LIVE_RUNBOOK.md)
- [거래 안전장치](docs/SAFETY_MODEL.md) · [프로젝트 구조](docs/PROJECT_GUIDE.md)
diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py
index 756486a..c7643a3 100644
--- a/core/basket_rebalancer.py
+++ b/core/basket_rebalancer.py
@@ -195,10 +195,14 @@ def overlay_decision(self):
self._overlay_decision = None
if not self._overlay_cfg.any_enabled:
return None
- if getattr(self, "_overlay_decision", None) is not None:
+ evaluation_date = datetime.now(_KST).date()
+ if (getattr(self, "_overlay_decision", None) is not None
+ and getattr(self, "_overlay_evaluation_date", None) == evaluation_date):
return self._overlay_decision
- prev = load_overlay_state(self.basket_name)
+ self._overlay_input_issues = []
+ self._overlay_source_dates = {}
+ prev = load_overlay_state(self.basket_name, mode=self._ledger_mode())
index_closes = None
if self._overlay_cfg.trend.enabled:
index_closes = self._fetch_index_closes(
@@ -215,8 +219,10 @@ def overlay_decision(self):
daily_returns=daily,
prev_state=prev,
)
+ decision.data_issues.extend(self._overlay_input_issues)
+ decision.source_dates = dict(self._overlay_source_dates)
try:
- save_overlay_state(self.basket_name, decision)
+ save_overlay_state(self.basket_name, decision, mode=self._ledger_mode())
except OSError as exc:
logger.warning("바스켓 '{}' 오버레이 상태 저장 실패: {}", self.basket_name, exc)
if decision.data_issues:
@@ -226,8 +232,61 @@ def overlay_decision(self):
else:
logger.info("바스켓 '{}' 리스크 오버레이 계산 완료 (배수 {})", self.basket_name, decision.scale)
self._overlay_decision = decision
+ self._overlay_evaluation_date = evaluation_date
return decision
+ def _overlay_previous_session(self):
+ """한국 거래일 기준 직전 완료 일봉. 주말·설정된 휴장일은 건너뛴다."""
+ from core.trading_hours import TradingHours
+
+ calendar = TradingHours(self.config)
+ day = datetime.now(_KST) - timedelta(days=1)
+ for _ in range(31):
+ if calendar.is_trading_day(day):
+ return day.date()
+ day -= timedelta(days=1)
+ raise ValueError("직전 거래일을 확인할 수 없습니다")
+
+ def _overlay_data_issue(self, message: str):
+ if not hasattr(self, "_overlay_input_issues"):
+ self._overlay_input_issues = []
+ self._overlay_input_issues.append(message)
+
+ def _overlay_dated_frame(self, frame, source: str):
+ """날짜 오류·중복·오래된 자료는 버림 없이 전체 입력을 보류한다."""
+ import pandas as pd
+
+ try:
+ values = frame["date"] if "date" in frame.columns else frame.index
+ if "date" not in frame.columns and not isinstance(frame.index, pd.DatetimeIndex):
+ raise ValueError("날짜 열 없음")
+ dates = pd.to_datetime(values, errors="coerce")
+ parsed = [
+ (value.tz_convert(_KST) if value.tzinfo is not None else value).date()
+ if not pd.isna(value) else None
+ for value in dates
+ ]
+ if any(value is None for value in parsed):
+ raise ValueError("날짜를 읽지 못한 행 있음")
+ today = datetime.now(_KST).date()
+ keep = [value < today for value in parsed]
+ result = frame.loc[keep].copy()
+ result["_overlay_date"] = [value for value in parsed if value < today]
+ result = result.sort_values("_overlay_date")
+ if result["_overlay_date"].duplicated().any():
+ raise ValueError("같은 날짜의 기록이 둘 이상 있음")
+ expected = self._overlay_previous_session()
+ latest = result["_overlay_date"].iloc[-1] if not result.empty else None
+ if not hasattr(self, "_overlay_source_dates"):
+ self._overlay_source_dates = {}
+ self._overlay_source_dates[source] = str(latest) if latest else None
+ if latest != expected:
+ raise ValueError(f"최근 기록 {latest or '없음'}, 필요한 기준일 {expected}")
+ return result
+ except (ValueError, TypeError, OverflowError) as exc:
+ self._overlay_data_issue(f"{source}: {exc}. 자료 확인 전 비중 확대 보류")
+ return None
+
def _fetch_index_closes(self, symbol: str, ma_days: int) -> list[float] | None:
"""추세 판단용 지수 종가(오래된 순). 오늘 날짜 봉은 제외 — 전일까지의 정보만."""
start, end = self._recent_range(int(ma_days * 1.7) + 30)
@@ -238,26 +297,8 @@ def _fetch_index_closes(self, symbol: str, ma_days: int) -> list[float] | None:
return None
if df is None or df.empty or "close" not in df.columns:
return None
- frame = df.copy()
- today = datetime.now(_KST).date()
- dates = None
- if "date" in frame.columns:
- dates = frame["date"]
- elif hasattr(frame.index, "date"):
- dates = frame.index.to_series()
- if dates is not None:
- try:
- import pandas as pd
- parsed = pd.to_datetime(dates).dt.date if hasattr(pd.to_datetime(dates), "dt") else pd.to_datetime(dates).date
- mask = [d < today for d in parsed]
- frame = frame[mask].copy()
- frame["_overlay_date"] = [d for d, keep in zip(parsed, mask) if keep]
- frame = frame.sort_values("_overlay_date")
- if frame["_overlay_date"].duplicated().any():
- return None
- except Exception:
- return None
- else:
+ frame = self._overlay_dated_frame(df, "지수 종가")
+ if frame is None:
return None
# 누락 봉을 삭제하면 이동평균 창이 과거로 밀려 잘못 복귀할 수 있다.
try:
@@ -278,11 +319,9 @@ def _nav_series_for_overlay(self) -> tuple[list[float] | None, list[float] | Non
return None, None
if snaps is None or getattr(snaps, "empty", True) or "cumulative_return" not in snaps.columns:
return None, None
- if "date" in snaps.columns:
- import pandas as pd
- snaps = snaps.copy()
- snaps["date"] = pd.to_datetime(snaps["date"], errors="coerce")
- snaps = snaps[snaps["date"].dt.date < datetime.now(_KST).date()].sort_values("date")
+ snaps = self._overlay_dated_frame(snaps, "계좌 기록")
+ if snaps is None:
+ return None, None
try:
cumulative = [float(v) if v is not None else float("nan") for v in snaps["cumulative_return"].tolist()]
except (TypeError, ValueError):
diff --git a/core/risk_overlays.py b/core/risk_overlays.py
index 99a5e5e..d0b02ad 100644
--- a/core/risk_overlays.py
+++ b/core/risk_overlays.py
@@ -71,6 +71,7 @@ class OverlayDecision:
vol_scale: float | None = None
realized_vol: float | None = None
evaluated_at: str = ""
+ source_dates: dict[str, str | None] = field(default_factory=dict)
def to_dict(self) -> dict[str, Any]:
return asdict(self)
@@ -229,7 +230,7 @@ def compute_decision(
prev_state: dict[str, Any] | None = None,
now: datetime | None = None,
) -> OverlayDecision:
- """설정된 오버레이를 모두 평가해 배수 하나로 합친다(곱)."""
+ """설정한 결합 방식에 따라 하나의 위험 배수를 계산한다."""
prev = prev_state or {}
decision = OverlayDecision(evaluated_at=(now or datetime.now()).isoformat(timespec="seconds"))
scale = 1.0
@@ -358,21 +359,49 @@ def overlay_state_path(basket_name: str, state_dir: str | os.PathLike | None = N
return overlay_state_dir(state_dir) / f"{safe}.json"
-def load_overlay_state(basket_name: str, state_dir: str | os.PathLike | None = None) -> dict[str, Any] | None:
- path = overlay_state_path(basket_name, state_dir)
+def load_overlay_state(
+ basket_name: str, state_dir: str | os.PathLike | None = None, *, mode: str | None = None,
+) -> dict[str, Any] | None:
+ directory = overlay_state_dir(state_dir)
+ if mode is not None and mode not in {"paper", "live"}:
+ raise ValueError("위험 관리 기록의 모드는 paper 또는 live여야 합니다")
+ path = overlay_state_path(basket_name, directory / mode if mode else directory)
+ # 이전 버전의 공용 기록은 모의투자만 이어받는다. 실전 계좌로 전파하지 않는다.
+ if mode == "paper" and not path.exists():
+ path = overlay_state_path(basket_name, directory)
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
- return data if isinstance(data, dict) else None
+ if not isinstance(data, dict):
+ return None
+ try:
+ scale = float(data["scale"])
+ if not math.isfinite(scale) or not 0 <= scale <= 1:
+ return None
+ except (KeyError, TypeError, ValueError):
+ return None
+ for key in ("trend_below", "drawdown_active"):
+ if data.get(key) is not None and not isinstance(data[key], bool):
+ return None
+ for key in ("reasons", "data_issues"):
+ if key in data and (not isinstance(data[key], list)
+ or any(not isinstance(item, str) for item in data[key])):
+ return None
+ data["scale"] = scale
+ return data
def save_overlay_state(
basket_name: str, decision: OverlayDecision, state_dir: str | os.PathLike | None = None,
+ *, mode: str | None = None,
) -> Path:
- path = overlay_state_path(basket_name, state_dir)
+ directory = overlay_state_dir(state_dir)
+ if mode is not None and mode not in {"paper", "live"}:
+ raise ValueError("위험 관리 기록의 모드는 paper 또는 live여야 합니다")
+ path = overlay_state_path(basket_name, directory / mode if mode else directory)
path.parent.mkdir(parents=True, exist_ok=True)
payload = decision.to_dict()
payload["basket"] = str(basket_name)
diff --git a/docs/DESIGN_20260922.md b/docs/DESIGN_20260922.md
new file mode 100644
index 0000000..937367b
--- /dev/null
+++ b/docs/DESIGN_20260922.md
@@ -0,0 +1,102 @@
+# 눈금: 투자 기록을 재는 도구
+
+2026년 9월 22일 개편의 설계 기준입니다. 계좌를 읽는 도구라는 목적과 기존 HTML/CSS/JavaScript·aiohttp 구조를 유지합니다.
+
+## 방향
+
+흰 카드가 반복되던 화면을 하나의 계측 화면과 기록 영역으로 나눕니다. 눈금, 기준선, 선택일 표시가 같은 조작을 설명하게 합니다. 대표 시각 요소는 **실제 시간가중수익률로 그리는 입체 띠**입니다. 입금 때문에 자산이 갑자기 늘어난 것을 투자 성과처럼 연출하지 않습니다. 입체감은 같은 수익률 선에 폭을 준 표현이며, 숨겨진 제3의 지표가 아닙니다. 평면 보기와 원본 표를 함께 제공합니다.
+
+```
+눈금 계좌 / 보유 종목 / 검증 / 운영 연결 · 적립
+나의 투자 기록 마지막 자산 기준일
+계좌 선택
+┌ 선택일 수익률 ───────── 실제 수익률의 입체 궤적 ────────────┐
+│ 평가금액 / 원금 기준선, 날짜, 입금 표시 평면 전환 │
+│ ← 날짜 탐색 눈금 → │
+└ 월별 수익률 ─────────────────────────────────────────────┘
+최신 총자산 원금 수익률 최대 낙폭
+보유 종목·원본 기록 자산 구성·비중 판단 근거
+모의투자 검증 확인할 일
+운영 기록
+```
+
+## 색과 글자
+
+색상값은 IBM Carbon의 공개 색상표를 사용하고 역할을 분리합니다. 색상표를 쓰는 것과 컴포넌트를 복사하는 것은 구분합니다. 화면 구조와 입체 날짜 탐색은 눈금의 계좌 기록에 맞춰 직접 구현했습니다.
+
+| 역할 | 색상 | 참고 규격 |
+|---|---|---|
+| 페이지 | `#FFFFFF` | White |
+| 보조 면 | `#F4F4F4` | Gray 10 |
+| 본문·주요 버튼 | `#161616` | Gray 100 |
+| 선택·링크·로고 | `#0F62FE` | Blue 60 |
+| 주요 차트 바탕 | `#002D9C` | Blue 80 |
+| 차트 선·선택한 기록 | `#82CFFF`, `#FFFFFF` | Cyan 30, White |
+| 상승·하락 수치 | `#DA1E28`, `#0072C3` | Red 60, Cyan 60 |
+
+어두운 차트의 손익 수치는 더 밝은 Red 30·Cyan 20으로 바꿉니다. 입체 면의 명암은 데이터의 추가 변수가 아닌 조명 표현입니다. 양수·음수 기호를 함께 표시합니다. 안내는 회색·파랑, 연결 실패나 거래 중지 알림은 회색 면과 빨간 선으로 구분합니다. 중요한 장애 알림은 모바일에서도 페이지 위쪽에 표시합니다.
+
+한글은 기존 로컬 Nungum UI 글꼴을 유지합니다. 제목 30~40px, 주요 성과 숫자 37~66px, 본문 14~15px, 표와 좁은 화면의 보조 표기는 11~13px입니다. 숫자는 같은 폭으로 정렬합니다. 글꼴과 연출용 외부 요청은 없습니다.
+
+## 조작과 연출
+
+- 날짜 슬라이더·차트 포인터·키보드로 동일한 날짜를 선택합니다. 수익률, 평가금액, 원금, 낙폭이 함께 바뀝니다.
+- 입체·평면 전환은 좌표계를 바꾸는 짧은 전환 한 번으로 보여줍니다. 상시 재생하는 영상이나 회전 물체를 두지 않습니다.
+- 계좌 변경 중에는 이전 계좌의 차트와 새 계좌의 이름이 섞이지 않도록 합니다.
+- 최신 계좌 요약과 선택한 과거 날짜의 수치를 구분합니다.
+- 작은 화면에서는 선택일 숫자, 차트, 날짜 조작 순서로 배치합니다. 동작 줄이기 설정에서는 전환을 생략합니다.
+
+## 화면의 차별성을 두는 곳
+
+색의 역할은 공개된 전문 디자인 규격을 따릅니다. 눈금의 차별성은 실제 날짜를 고르는 눈금, 같은 기록을 입체와 평면으로 바꾸는 조작, 원금과 수익을 분리하는 데이터 구성에 둡니다. 꾸밈용 영문이나 수익을 과장하는 문구는 넣지 않습니다.
+
+## 참고 자료
+
+- [IBM Carbon 색상 가이드, 2026-08-12 갱신](https://preview.carbondesignsystem.com/building-blocks/foundations/color/overview): 중성 회색의 단계, 배경별 글자 대비, 색의 역할.
+- [Carbon 데이터 시각화 색상표, 2026-09-09 갱신](https://carbondesignsystem.com/data-visualization/color-palettes/): Blue·Cyan·Red의 실제 HEX 값. 신규 유행 자료가 아닌 현재 공개된 색상 규격입니다.
+- [Pentagram — PayPal](https://www.pentagram.com/work/paypal): 흑백 중심의 구조와 파랑 강조, 실제 지불 동작을 출발점으로 한 모션. 기존 디자인 사례이며 2026년 신작이라고 소개하지 않습니다.
+- [2026 Webby 시각 디자인 수상작](https://winners.webbyawards.com/winners/websites-and-mobile-sites/features-design/best-visual-design-aesthetic): Igloo Inc 등의 수상 여부 확인. 수상작 하나를 전체 웹 디자인의 유행이라고 단정하지 않습니다.
+
+
+- [Webby 2026 데이터 시각화 수상작](https://winners.webbyawards.com/winners/websites-and-mobile-sites/features-design/best-data-visualization): 실제 데이터를 탐색하게 만드는 화면 구성 사례.
+- [Teenage Engineering OP–XY 조작 체계](https://teenage.engineering/guides/op-xy/layout): 표시와 조작의 대응 관계를 참고했습니다. 제품 외형을 복제하지 않습니다.
+- [web.dev 애니메이션 성능](https://web.dev/articles/animations-and-performance): 합성 가능한 짧은 전환과 불필요한 재계산 억제.
+- [web.dev Canvas 성능](https://web.dev/articles/canvas-performance): 반복 도형의 그리기 횟수와 해상도를 제한하는 구현 원칙. 오래된 원리 문서이며 2026년 새 기능으로 소개하지 않습니다.
+
+## 실제 화면
+
+
+
+
+
+## 화면·조작 검증
+
+2026-09-22 로컬 모의투자 기록으로 확인했습니다. 운영 계좌에 주문이나 적립을 추가하지 않았습니다.
+
+- 320·390·768·1440px에서 페이지 가로 넘침 없음. 입체·평면 버튼 높이는 36px 이상.
+- 날짜 슬라이더 Home·방향키·최근 날짜 복귀, 기간 변경, 두 계좌 전환 확인.
+- ETF 계좌 51개 일별 기록을 CSV로 내려받아 행 수·첫 날짜·원금·수익률 확인.
+- 기록이 없으면 빈 상태, 잘못된 금액이나 적립금 조회 실패 시 오류 표시 및 내려받기 제한.
+- 운영 상태 조회 실패 시 상단 알림과 적립 기록 제한 확인. 정상 상태에 경고 색을 사용하지 않음.
+- `prefers-reduced-motion`에서 입체·평면 전환 생략. 일별 표와 낙폭 차트로 같은 기록 확인.
+- 실제 화면의 브라우저 경고·오류 0건. 오류 상황은 별도 탭에서 응답을 바꿔 확인.
+
+초기 검토에서 작은 화면을 바꾼 직후의 캡처가 Canvas 크기 갱신보다 빨랐습니다. 크기 변경 후 다시 그려지는 것을 확인하고 최종 캡처를 교체했습니다. 일반 안내까지 빨갛게 표시되던 부분과 버튼 높이도 함께 수정했습니다.
+
+## 성능 측정
+
+Playwright에서 Chrome DevTools Protocol과 PerformanceObserver로 측정했습니다. 별도 DevTools 프로필이 사용 중이라 기존 Playwright 브라우저로 진행했습니다. Chromium 153.0.8010.53, 390×844px, CPU 4배 감속, 지연 150ms, 다운로드 200,000B/s, 캐시 비활성화 조건의 로컬 서버 단일 측정입니다.
+
+| 항목 | 결과 |
+|---|---:|
+| LCP | 1.16초 |
+| CLS | 0.0133 |
+| 입체·평면 및 키보드 조작의 최대 Event Timing | 56ms |
+| 조작 없는 1초 동안 새 rAF 요청 | 0회 |
+| CSS 전송량 / 원본 | 약 7.4 / 34.2KiB |
+| JS 전송량 / 원본 | 약 24.0 / 85.9KiB |
+| 로컬 한글 글꼴 | 약 431KiB |
+
+새 렌더링 라이브러리·외부 글꼴·영상·3D 모델 요청은 없습니다. 차트는 최대 약 420개 선분으로 그리고, 날짜 변경은 프레임당 한 번, 보기 전환은 360ms 동안만 그립니다. 숨은 탭의 주기 조회는 기존 정책대로 쉬게 했습니다. 전체 이력은 조회 주기에 한 번 받아 계좌·기간 변경에 재사용합니다.
+
+이 값은 실제 사용자 집계인 INP나 모든 기기의 무지연 보장이 아닙니다. 글꼴이 초기 전송량의 대부분이며, 감속 조건에서 초기 로딩 중 125·99·346ms의 긴 작업이 관찰됐습니다. 연출을 쓰지 않을 때 계속 CPU를 점유하지 않는지 별도로 확인했습니다.
diff --git a/docs/RISK_REVIEW_20260922.md b/docs/RISK_REVIEW_20260922.md
new file mode 100644
index 0000000..6ee7c56
--- /dev/null
+++ b/docs/RISK_REVIEW_20260922.md
@@ -0,0 +1,69 @@
+# 위험 관리 보완과 재검증
+
+검증일 **2026-09-22** · 비교에 사용한 마지막 공통 종가 **2026-09-17**
+
+이번에는 위험 비중을 결정하는 자료와 계좌 구분을 보완하고 계산 시간을 줄였습니다. 9월 17일 정한 추세·낙폭 임계값과 자산 구성은 유지했습니다. 수익률을 높이려고 새 매매 조건을 반복 탐색한 결과가 아닙니다.
+
+## 고친 문제
+
+- **오래된 자료로 비중을 늘릴 수 있었습니다.** 지수와 계좌 기록의 마지막 날짜가 한국 거래일 기준 직전 거래일인지 확인합니다. 주말과 설정된 휴장일은 제외합니다. 오래되거나 날짜가 잘못된 자료는 위험 비중 확대의 근거로 쓰지 않고, 화면에 판단 보류 사유를 표시합니다.
+- **손실 행을 삭제한 채 계산할 가능성을 막았습니다.** 날짜 오류·중복이 있으면 해당 입력 전체를 보류합니다. 당일·미래 기록은 신호 계산에서 제외합니다. 사용한 자료 날짜도 판단 기록에 남깁니다.
+- **모의투자와 실전의 판단 기록을 분리했습니다.** 위험 관리 상태 파일을 `paper`·`live`별로 저장합니다. 이전 공용 파일은 모의투자만 이어받고, 잘못된 배수·상태는 읽지 않습니다.
+- **날짜가 바뀌면 다시 판단합니다.** 동일 프로세스에서 전날 계산 결과가 다음 날까지 남지 않게 했습니다. 같은 날의 주문 계획과 실행은 같은 판단을 사용합니다.
+- **연구 자료의 마지막 날짜를 맞춥니다.** 세 자료가 모두 있는 날까지만 비교합니다. 기간 중 누락은 이전 값으로 채우지 않고 검증을 중단합니다. 기준일을 바꾸면 결과 파일 이름도 해당 날짜로 바뀝니다.
+
+거래일 판단은 프로젝트의 휴장일 설정에 의존합니다. 발표된 임시 휴장일이 설정에 빠지면 자료 확인을 보류할 수 있습니다. 손실 자체를 없애는 장치는 아니며, ETF 적립은 계속 모의투자 전용입니다.
+
+## 9월 22일 자료 상태
+
+| 자료 | 실제 제공된 마지막 날짜 | 비교에 사용한 마지막 날짜 | 뒤쪽에서 제외한 거래일 |
+|---|---|---|---:|
+| KODEX 200 `069500` | 9월 21일 | 9월 17일 | 2일 |
+| TIGER CD금리 ETF `357870` | 9월 21일 | 9월 17일 | 2일 |
+| 코스피200 `KS200` | 9월 17일 | 9월 17일 | 0일 |
+
+조회한 날짜가 22일이라는 이유로 21일까지 검증됐다고 표시하지 않습니다. 자료별 날짜·사용 행 수·SHA-256은 [원본 결과](../reports/research/risk_review_20260922.json)에 있습니다.
+
+## 같은 조건으로 다시 비교
+
+2020-07-07~2026-09-17, 시작 30만원, 월 10만원 적립, 1주 단위, 최소 주문 5만원. 수수료·슬리피지 및 CD ETF 양의 매매차익에 대한 보수적 세금 근사를 포함합니다.
+
+| 방식 | 연환산 수익률 | 최대 낙폭 | 샤프 | 비용 3배 연환산 수익률 |
+|---|---:|---:|---:|---:|
+| 고정 비중 | 13.53% | −21.72% | 0.79 | 13.41% |
+| 기존 위험 관리 | 10.73% | −16.02% | 0.74 | 10.27% |
+| 9월 17일 변경한 위험 관리 | 12.96% | −15.74% | 0.90 | 12.67% |
+
+
+
+기존 방식도 적립금 계산 오류를 고친 상태로 비교했습니다. 변경한 규칙의 전체 기간 낙폭은 작았지만 고정 비중보다 수익률이 낮았습니다. 2023~2025년에는 기존 방식보다 최대 낙폭이 컸습니다(−8.80%, 기존 −7.86%). 이 비교는 이미 살펴본 자료로 하는 사후 검증이며 앞으로의 수익을 보장하지 않습니다. 분배금·실제 호가·괴리율·미체결은 재현하지 못했습니다. 자세한 가정은 [9월 17일 보고서](RISK_REVIEW_20260917.md)에 있습니다.
+
+## 계산 속도
+
+매일 pandas 행과 전체 과거 이력을 새로 만들던 부분을 바꿨습니다. 종가는 한 번 배열로 읽고, 추세 판단에는 필요한 이동평균 기간만 전달합니다.
+
+Windows Python 3.12.10에서 기준 커밋 `9a7c145`와 비교했습니다. 같은 2026-09-17 가격 캐시를 사용한 **1,520거래일 결과 전체**를 매회 정확히 대조했고, 각 정책을 세 번씩 실행했습니다. 단위는 실행 시간의 중앙값입니다.
+
+| 방식 | 수정 전 | 수정 후 | 속도 비율 |
+|---|---:|---:|---:|
+| 고정 비중 | 234ms | 54ms | 4.30배 |
+| 기존 위험 관리 | 278ms | 90ms | 3.09배 |
+| 변경한 위험 관리 | 271ms | 78ms | 3.45배 |
+
+세 방식 모두 모든 날짜의 자산·현금·수량·수익률·낙폭·비용·위험 배수가 정확히 같았습니다. 이 속도 개선을 수익률 개선으로 표시하지 않습니다. [측정 원본](../reports/research/implementation_benchmark_20260922.json)과 [재현 스크립트](../tools/benchmark_risk_review.py)를 제공합니다.
+
+## 확인한 자료와 적용 범위
+
+- [AQR, Inflation Redux?, 2026-09-15](https://www.aqr.com/Insights/Research/Alternative-Thinking/Inflation-Redux): 인플레이션 불확실성, 주식·채권의 동반 손실과 구현 방식에 따른 차이를 다룹니다. 이를 국내 ETF의 특정 매수 시점이나 수익 보장 근거로 옮기지 않았습니다.
+- [scikit-learn TimeSeriesSplit](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html): 시간 순서를 지켜 미래 자료가 과거 판단에 들어가지 않도록 하는 평가 원칙을 재확인했습니다. 새 모델이나 의존성을 추가하지 않았습니다.
+- 자산 배분·비용·TWR 관련 기존 근거와 그 한계는 [9월 17일 조사 기록](RISK_REVIEW_20260917.md#2026년-자료를-어떻게-반영했나)에 정리했습니다.
+
+## 재현
+
+```powershell
+.\.venv\Scripts\python.exe tools/risk_review.py --as-of 2026-09-22
+.\.venv\Scripts\python.exe tools/benchmark_risk_review.py --baseline 9a7c145 --as-of 2026-09-17
+.\.venv\Scripts\python.exe -m pytest tests/test_risk_review_data.py tests/test_basket_overlay_integration.py tests/test_risk_overlays.py tests/test_risk_overlay_backtest.py -q
+```
+
+데이터 공급자의 정정과 캐시 유무에 따라 재수집 값이 달라질 수 있으므로 결과 파일의 해시를 함께 확인합니다. 이 검증은 주문을 실행하지 않습니다.
diff --git a/docs/images/dashboard-account-20260922.png b/docs/images/dashboard-account-20260922.png
new file mode 100644
index 0000000..e953519
Binary files /dev/null and b/docs/images/dashboard-account-20260922.png differ
diff --git a/docs/images/dashboard-holdings-20260922.png b/docs/images/dashboard-holdings-20260922.png
new file mode 100644
index 0000000..e15294f
Binary files /dev/null and b/docs/images/dashboard-holdings-20260922.png differ
diff --git a/docs/images/dashboard-mobile-20260922.png b/docs/images/dashboard-mobile-20260922.png
new file mode 100644
index 0000000..4f0ace2
Binary files /dev/null and b/docs/images/dashboard-mobile-20260922.png differ
diff --git a/docs/images/dashboard-operations-20260922.png b/docs/images/dashboard-operations-20260922.png
new file mode 100644
index 0000000..5b6c4ee
Binary files /dev/null and b/docs/images/dashboard-operations-20260922.png differ
diff --git a/docs/images/risk-review-20260922.png b/docs/images/risk-review-20260922.png
new file mode 100644
index 0000000..dd0de05
Binary files /dev/null and b/docs/images/risk-review-20260922.png differ
diff --git a/monitoring/static/dashboard.css b/monitoring/static/dashboard.css
index 81d2162..09c1422 100644
--- a/monitoring/static/dashboard.css
+++ b/monitoring/static/dashboard.css
@@ -1,4 +1,4 @@
-/* 한글·숫자는 같은 로컬 글꼴로 표시한다. 차트는 입력과 데이터 변경 때만 그린다. */
+/* 눈금: 실제 기록을 탐색하는 계측 화면. 외부 글꼴이나 애니메이션 라이브러리 없음. */
@font-face {
font-family: "Nungum UI";
src: url("/static/fonts/nungum-ui.woff2") format("woff2");
@@ -6,37 +6,39 @@
font-display: swap;
}
:root {
- --paper: #f5f6f8;
- --surface: #fff;
- --ink: #202b3d;
- --ink-soft: #596579;
- --ink-faint: #667185;
- --rule: #e8ebf0;
- --rule-strong: #cbd1dd;
- --accent: #3157a4;
- --accent-bg: #edf2fb;
- --up: #c4383c;
- --down: #2563c7;
- --warn: #916018;
- --stop: #b83232;
- --pill-bg: #263d64;
+ --paper: #ffffff;
+ --surface: #f4f4f4;
+ --ink: #161616;
+ --ink-soft: #525252;
+ --ink-faint: #6f6f6f;
+ --rule: #e0e0e0;
+ --rule-strong: #c6c6c6;
+ --accent: #0f62fe;
+ --accent-bg: #edf5ff;
+ --up: #da1e28;
+ --down: #0072c3;
+ --warn: #8a3800;
+ --stop: #da1e28;
+ --pill-bg: #161616;
--pill-fg: #fff;
--font: "Nungum UI", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
- --rail: 204px;
+ --panel: #002d9c;
+ --signal: #82cfff;
+ --cursor: #ffffff;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
- scroll-padding-top: 88px;
+ scroll-padding-top: 94px;
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
- font: 400 14px/1.55 var(--font);
- letter-spacing: -0.015em;
+ font: 400 14px/1.6 var(--font);
+ letter-spacing: -0.018em;
word-break: keep-all;
overflow-wrap: anywhere;
-webkit-font-smoothing: antialiased;
@@ -102,7 +104,6 @@ button:disabled {
color: white;
background: var(--pill-bg);
transform: translateY(-200%);
- border-radius: 8px;
}
.skip-link:focus {
transform: none;
@@ -127,583 +128,920 @@ button:disabled {
text-align: right;
font-variant-numeric: tabular-nums;
}
-
-/* 주 메뉴 */
-.rail {
- position: fixed;
- inset: 0 auto 0 0;
- width: var(--rail);
+.chrome {
+ position: sticky;
+ top: 0;
z-index: 40;
- background: var(--surface);
- border-right: 1px solid var(--rule);
- padding: 31px 18px 22px;
+ min-height: 78px;
display: flex;
- flex-direction: column;
+ align-items: center;
+ gap: 48px;
+ padding: 12px max(28px, calc((100vw - 1376px) / 2));
+ background: var(--paper);
+ border-bottom: 1px solid var(--rule-strong);
}
.brand {
- display: inline-flex;
+ display: flex;
align-items: center;
- gap: 10px;
- padding-left: 14px;
- font-size: 25px;
+ gap: 12px;
+ font-size: 27px;
font-weight: 700;
letter-spacing: -0.065em;
text-decoration: none;
+ flex-shrink: 0;
}
.brand img {
display: block;
}
.nav {
display: flex;
- flex-direction: column;
- gap: 7px;
- margin-top: 51px;
-}
-.nav > a {
- display: flex;
+ gap: 30px;
+ align-self: stretch;
align-items: center;
- min-height: 46px;
- gap: 12px;
- padding: 10px 14px;
- text-decoration: none;
- border-radius: 8px;
- color: var(--ink-soft);
- font-weight: 500;
-}
-.nav > a > svg {
- width: 19px;
- height: 19px;
- flex-shrink: 0;
- fill: none;
- stroke: currentColor;
- stroke-width: 1.5;
- stroke-linecap: round;
- stroke-linejoin: round;
-}
-.nav > a:hover {
- background: #f6f8fb;
-}
-.nav > a[aria-current] {
- color: var(--accent);
- background: var(--accent-bg);
- font-weight: 650;
}
-.rail-bottom {
- margin-top: auto;
- padding: 20px 14px 0;
- border-top: 1px solid var(--rule);
- font-size: 12px;
+.nav a {
+ position: relative;
+ padding: 11px 0;
+ font-size: 14px;
color: var(--ink-soft);
-}
-.rail-caption {
- display: block;
- color: var(--ink-faint);
- margin-bottom: 7px;
-}
-.rail-bottom a {
text-decoration: none;
- display: flex;
- justify-content: space-between;
- min-height: 32px;
- align-items: center;
-}
-.chrome {
- position: sticky;
- top: 0;
- margin-left: var(--rail);
- z-index: 30;
- min-height: 72px;
- background: var(--surface);
- border-bottom: 1px solid var(--rule);
- padding: max(16px, env(safe-area-inset-top)) 36px 16px;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
-}
-.location {
- color: var(--ink-faint);
- font-size: 12px;
white-space: nowrap;
}
-.location strong {
- color: var(--ink-soft);
- font-weight: 500;
- margin-left: 6px;
+.nav a[aria-current] {
+ color: var(--ink);
+ font-weight: 700;
+}
+.nav a[aria-current]:after {
+ content: "";
+ position: absolute;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--accent);
+ left: calc(50% - 2px);
+ bottom: 2px;
}
.header-actions {
+ margin-left: auto;
display: flex;
- gap: 18px;
align-items: center;
+ gap: 16px;
}
.state {
- display: flex;
+ display: inline-flex;
align-items: center;
- gap: 6px;
+ gap: 7px;
+ font-size: 12px;
color: var(--ink-soft);
- font-size: 11px;
}
.state time {
- margin-left: 3px;
- color: var(--ink-faint);
+ display: none;
}
.dot {
- display: inline-block;
width: 6px;
height: 6px;
+ background: #8d8d8d;
border-radius: 50%;
- background: #718097;
- flex-shrink: 0;
+ display: inline-block;
}
-.dot[data-state="ok"],
-.dot[data-state="ready"] {
- background: #32705d;
+.dot[data-state="ready"],
+.dot[data-state="ok"] {
+ background: #198038;
}
-.dot[data-state="warning"],
-.dot[data-state="partial"] {
- background: #b67c29;
+.dot[data-state="partial"],
+.dot[data-state="warning"] {
+ background: #b28600;
}
.dot[data-state="error"] {
- background: var(--stop);
+ background: #da1e28;
}
.mode {
- font-size: 11px;
- font-weight: 600;
- color: #506075;
- background: #f0f3f7;
- padding: 4px 8px;
- border-radius: 5px;
+ border: 1px solid var(--rule-strong);
+ padding: 3px 8px;
+ border-radius: 3px;
+ font-size: 12px;
white-space: nowrap;
}
.mode[data-mode="live"] {
color: var(--stop);
- background: #fff0ef;
+ border-color: var(--stop);
}
.pill {
+ min-height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
- min-height: 38px;
- padding: 0 17px;
- background: var(--pill-bg);
+ border: 1px solid var(--pill-bg);
+ border-radius: 6px;
+ padding: 0 18px;
color: var(--pill-fg);
- -webkit-text-fill-color: var(--pill-fg);
- border: 1px solid transparent;
- border-radius: 7px;
+ background: var(--pill-bg);
font-weight: 600;
- font-size: 13px;
text-decoration: none;
+ transition: background 0.18s;
white-space: nowrap;
- transition: background 0.16s;
}
.pill:hover:not(:disabled) {
- background: #182d4e;
+ background: #393939;
}
.pill-line {
- background: white;
+ background: transparent;
color: var(--ink);
- -webkit-text-fill-color: var(--ink);
border-color: var(--rule-strong);
}
.pill-line:hover:not(:disabled) {
- background: var(--paper);
+ background: var(--accent-bg);
}
.link {
- display: inline-flex;
- align-items: center;
- min-height: 36px;
+ background: none;
border: 0;
- background: transparent;
- padding: 0;
+ padding: 8px 0;
color: var(--accent);
- text-decoration: none;
- font-size: 12px;
- font-weight: 600;
-}
-.link:hover {
+ font-size: 13px;
text-decoration: underline;
text-underline-offset: 4px;
+ min-height: 36px;
}
-
-/* 계좌 요약 */
.page {
- max-width: 1640px;
- margin-left: var(--rail);
- padding: 32px 36px 0;
+ max-width: 1432px;
+ margin: 0 auto;
+ padding: 40px 28px 0;
}
.page-heading {
display: flex;
- align-items: center;
+ align-items: flex-end;
justify-content: space-between;
- gap: 14px;
- margin-bottom: 26px;
+ gap: 20px;
+ margin-bottom: 28px;
+}
+.page-kicker {
+ color: var(--ink-soft);
+ font-size: 13px;
+ margin-bottom: 7px;
}
h1 {
- font-size: 26px;
- line-height: 1.3;
- font-weight: 700;
- letter-spacing: -0.04em;
+ font-size: 40px;
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: -0.06em;
}
-.page-heading p {
- margin-top: 7px;
- color: var(--ink-soft);
- font-size: 14px;
+.record-date {
+ text-align: right;
+ font-size: 13px;
}
-.page-heading time {
+.record-date span {
+ display: block;
color: var(--ink-faint);
font-size: 12px;
+ margin-bottom: 4px;
+}
+.record-date time {
+ font-weight: 500;
}
.account-tabs {
display: flex;
- gap: 10px;
- margin-bottom: 19px;
+ gap: 6px;
+ min-height: 60px;
+ margin-bottom: 15px;
}
.account-tabs > button {
- flex: 1;
- max-width: 330px;
- min-width: 0;
display: flex;
- gap: 12px;
- text-align: left;
align-items: center;
- background: #fff;
- border: 1px solid var(--rule);
- border-radius: 10px;
- padding: 13px 16px;
- font-weight: 600;
- font-size: 13px;
+ gap: 13px;
+ text-align: left;
+ padding: 11px 20px;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 8px 8px 0 0;
+ min-width: 230px;
+ font-size: 15px;
}
.account-tabs > button[aria-pressed="true"] {
- border-color: #718fca;
- box-shadow: 0 0 0 1px #718fca;
+ background: var(--panel);
+ color: var(--surface);
}
.account-tabs small {
display: block;
- font-size: 11px;
+ font-size: 12px;
+ opacity: 0.78;
margin-top: 2px;
font-weight: 400;
- color: var(--ink-faint);
}
.account-icon {
- flex: 0 0 34px;
- height: 34px;
+ width: 28px;
+ height: 28px;
display: grid;
place-items: center;
+ border: 1px solid currentColor;
font-size: 11px;
- border-radius: 10px;
- color: #53627b;
- background: #f0f3f7;
-}
-.account-tabs button[aria-pressed="true"] .account-icon {
- background: var(--accent-bg);
- color: var(--accent);
+ border-radius: 50%;
+ flex-shrink: 0;
}
.account-check {
margin-left: auto;
- color: var(--accent);
- visibility: hidden;
+ opacity: 0;
}
.account-tabs button[aria-pressed="true"] .account-check {
- visibility: visible;
+ opacity: 1;
+ color: var(--signal);
+}
+.instrument {
+ background: var(--panel);
+ color: #ffffff;
+ border-radius: 0 12px 12px 12px;
+ margin-top: -15px;
+ padding: 24px 30px 0;
+ position: relative;
+ box-shadow: none;
+ overflow: hidden;
}
-.overview {
+.instrument:focus-within {
+ --focus: #ffffff;
+}
+.instrument :focus-visible {
+ outline-color: var(--cursor);
+}
+.instrument-head {
display: flex;
align-items: center;
- background: var(--surface);
- border: 1px solid var(--rule);
- border-radius: 14px;
- padding: 26px 30px;
- gap: 30px;
- margin-bottom: 22px;
- min-height: 160px;
+ justify-content: space-between;
+ gap: 16px;
}
-.overview-total {
- flex: 1.25;
- min-width: 210px;
+.instrument h2 {
+ font-size: 18px;
+ font-weight: 500;
+ letter-spacing: -0.035em;
}
-.overview-total > p {
- color: var(--ink-soft);
- font-size: 13px;
+.instrument-head p {
+ font-size: 12px;
+ color: #d0e2ff;
+ margin-top: 3px;
}
-.overview-cash {
- display: none;
- margin-top: 8px;
+.view-switch {
+ display: flex;
+ border: 1px solid #78a9ff;
+ border-radius: 5px;
+ padding: 3px;
}
-.overview-total > strong {
- display: block;
- font-size: clamp(29px, 2.7vw, 40px);
+.view-switch button {
+ min-height: 36px;
+ min-width: 53px;
+ border: 0;
+ background: none;
+ color: #d0e2ff;
+ border-radius: 3px;
+ font-size: 12px;
+}
+.view-switch button[aria-pressed="true"] {
+ background: #ffffff;
+ color: #161616;
font-weight: 600;
- line-height: 1.45;
- letter-spacing: -0.045em;
- font-variant-numeric: tabular-nums;
}
-.overview-total > span {
+.chart-controls {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 20px;
+}
+.seg {
+ display: flex;
+ gap: 4px;
+}
+.seg button {
+ min-height: 36px;
+ padding: 0 14px;
+ color: #d0e2ff;
+ border: 0;
+ border-bottom: 1px solid transparent;
+ background: none;
+ font-size: 13px;
+}
+.seg button[aria-pressed="true"] {
+ color: var(--cursor);
+ border-bottom-color: var(--cursor);
+}
+.chart-maturity {
font-size: 12px;
- display: block;
- font-variant-numeric: tabular-nums;
+ color: #d0e2ff;
}
-.overview-stats {
- flex: 2.5;
+.instrument-body {
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 26px;
- padding-left: 32px;
- border-left: 1px solid var(--rule);
+ grid-template-columns: 250px minmax(0, 1fr);
+ gap: 14px;
+ align-items: center;
+ min-height: 300px;
}
-.overview-stats dt {
- color: var(--ink-soft);
+.readout {
+ position: relative;
+ z-index: 1;
+ padding: 16px 0;
+}
+.readout-date {
+ display: flex;
+ align-items: center;
+ gap: 7px;
font-size: 13px;
}
-.overview-stats abbr {
+.readout-date i {
+ height: 5px;
+ width: 5px;
+ border-radius: 50%;
+ background: var(--cursor);
+}
+.readout-date span {
+ margin-left: 5px;
font-size: 11px;
- background: var(--paper);
- color: var(--ink-soft);
- padding: 2px 4px;
- text-decoration: none;
- border-radius: 3px;
- cursor: help;
+ color: #d0e2ff;
+ border: 1px solid #78a9ff;
+ border-radius: 2px;
+ padding: 0 4px;
}
-.overview-stats dd {
- font-size: clamp(20px, 1.85vw, 27px);
- font-weight: 600;
- margin-top: 6px;
- line-height: 1.4;
- letter-spacing: -0.035em;
- font-variant-numeric: tabular-nums;
- white-space: nowrap;
+.readout-label {
+ margin-top: 18px;
+ font-size: 13px;
+ color: #d0e2ff;
}
-.overview-stats small {
+.readout-return {
display: block;
- font-size: 11px;
- color: var(--ink-faint);
- margin-top: 6px;
+ font-size: clamp(40px, 4.4vw, 66px);
+ line-height: 1.14;
+ font-weight: 400;
+ letter-spacing: -0.065em;
+ font-variant-numeric: tabular-nums;
+ margin-top: 5px;
}
-.workspace {
- display: grid;
- grid-template-columns: minmax(0, 1fr) 256px;
- gap: 22px;
- align-items: start;
+.readout .down {
+ color: #bae6ff;
}
-.main-column,
-.side-column {
- min-width: 0;
+.readout .up {
+ color: #ffb3b8;
}
-.sec {
- background: white;
- border: 1px solid var(--rule);
- border-radius: 14px;
- padding: 23px 25px;
- margin-bottom: 22px;
- scroll-margin-top: 20px;
- min-width: 0;
+.readout .flat {
+ color: #ffffff;
}
-.sec-head {
- display: flex;
- align-items: baseline;
+.readout dl {
+ margin-top: 19px;
+ max-width: 230px;
+}
+.readout dl > div {
+ display: flex;
justify-content: space-between;
+ gap: 15px;
+ margin-top: 8px;
+ font-size: 13px;
+}
+.readout dt {
+ color: #d0e2ff;
+}
+.readout dd {
+ font-variant-numeric: tabular-nums;
+}
+.readout-event {
+ min-height: 24px;
+ max-width: 240px;
+ color: var(--cursor);
+ font-size: 12px;
+ margin-top: 17px;
+}
+.chart-main {
+ min-width: 0;
+}
+.chart-box {
+ position: relative;
+ width: 100%;
+ height: 290px;
+}
+.chart-box canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+.chart-main canvas {
+ cursor: crosshair;
+ touch-action: pan-y;
+}
+.chart-legend {
+ display: flex;
+ justify-content: flex-end;
flex-wrap: wrap;
- gap: 5px 16px;
- margin-bottom: 18px;
+ gap: 18px;
+ font-size: 12px;
+ color: #d0e2ff;
+ margin-top: 0;
+ padding-bottom: 9px;
+}
+.chart-legend span {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+}
+.line-key {
+ width: 18px;
+ border-top: 2px solid var(--signal);
+}
+.zero-key {
+ width: 18px;
+ border-top: 1px dashed #a6c8ff;
+}
+.point-key {
+ width: 5px;
+ height: 5px;
+ border: 1px solid var(--cursor);
+ transform: rotate(45deg);
+}
+.timeline {
+ border-top: 1px solid #4589ff;
+ padding: 18px 0 19px;
+}
+.timeline-labels {
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ gap: 14px;
+}
+.timeline-labels output {
+ color: #d0e2ff;
+}
+.latest-button {
+ margin-left: auto;
+ border: 1px solid #78a9ff;
+ background: none;
+ border-radius: 4px;
+ color: #ffffff;
+ min-height: 36px;
+ padding: 0 11px;
+ font-size: 12px;
+}
+.latest-button:hover {
+ background: #0043ce;
+}
+.timeline-track {
+ position: relative;
+ height: 38px;
+ margin-top: 6px;
+ background:
+ repeating-linear-gradient(90deg, #a6c8ff40 0 1px, transparent 1px 10px) left
+ center/100% 10px no-repeat,
+ repeating-linear-gradient(90deg, #a6c8ff90 0 1px, transparent 1px 50px) left
+ center/100% 19px no-repeat;
+}
+.timeline input {
+ appearance: none;
+ -webkit-appearance: none;
+ width: 100%;
+ height: 38px;
+ margin: 0;
+ background: none;
+ cursor: ew-resize;
+}
+.timeline input::-webkit-slider-runnable-track {
+ background: #a6c8ff55;
+ height: 1px;
+}
+.timeline input::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ height: 29px;
+ width: 10px;
+ border: 2px solid #002d9c;
+ border-radius: 3px;
+ background: var(--cursor);
+ margin-top: -14px;
+ box-shadow: 0 0 0 1px var(--cursor);
+}
+.timeline input::-moz-range-track {
+ background: #a6c8ff55;
+ height: 1px;
+}
+.timeline input::-moz-range-thumb {
+ height: 26px;
+ width: 8px;
+ border: 2px solid #002d9c;
+ border-radius: 3px;
+ background: var(--cursor);
+}
+.timeline-dates {
+ display: flex;
+ justify-content: space-between;
+ color: #d0e2ff;
+ font-size: 12px;
+}
+.instrument-foot {
+ display: flex;
+ align-items: center;
+ gap: 24px;
+ padding: 14px 0;
+ border-top: 1px solid #4589ff;
+}
+.instrument-foot > span {
+ font-size: 12px;
+ color: #d0e2ff;
+ white-space: nowrap;
+}
+.months {
+ display: flex;
+ gap: 28px;
+ overflow-x: auto;
+}
+.months > div {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ white-space: nowrap;
+ font-size: 13px;
+}
+.months b {
+ font-size: 12px;
+ font-weight: 400;
+ color: #d0e2ff;
+}
+.months .down {
+ color: #bae6ff;
+}
+.months .up {
+ color: #ffb3b8;
+}
+.months .flat {
+ color: #ffffff;
+}
+.instrument-status {
+ margin: 0 -30px;
+ padding: 12px 30px;
+ background: #001d6c;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ color: #d0e2ff;
+ font-size: 12px;
+}
+.instrument-export {
+ background: none;
+ border: 0;
+ min-height: 36px;
+ color: #ffffff;
+ font-size: 12px;
+ white-space: nowrap;
+}
+.instrument-export span {
+ margin-left: 12px;
+}
+.instrument-empty {
+ min-height: 300px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 12px;
+ text-align: center;
+}
+.instrument[data-loading="true"] .instrument-body {
+ opacity: 0.35;
+}
+.instrument[data-loading="true"] .timeline {
+ opacity: 0.35;
+ pointer-events: none;
+}
+.overview {
+ display: grid;
+ grid-template-columns: 1.2fr 2.6fr;
+ gap: 35px;
+ padding: 30px 0;
+ border-bottom: 1px solid var(--rule-strong);
+}
+.overview-total > p {
+ font-size: 13px;
+ color: var(--ink-soft);
+}
+.overview-total > strong {
+ display: block;
+ font-size: 31px;
+ font-weight: 500;
+ letter-spacing: -0.04em;
+ font-variant-numeric: tabular-nums;
+ margin: 4px 0;
+}
+.overview-total > span {
+ display: block;
+ font-size: 12px;
+}
+.overview-total .overview-cash {
+ margin-top: 8px;
+ font-size: 12px;
+}
+.overview-stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 26px;
+ align-content: start;
+}
+.overview-stats dt {
+ color: var(--ink-soft);
+ font-size: 13px;
+}
+.overview-stats abbr {
+ font-size: 10px;
+ text-decoration: none;
+ border-bottom: 1px dotted var(--rule-strong);
+ margin-left: 4px;
+}
+.overview-stats dd {
+ font-size: 27px;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.04em;
+ line-height: 1.4;
+ margin: 8px 0;
+}
+.overview-stats small {
+ font-size: 12px;
+ color: var(--ink-soft);
+}
+.history-details {
+ margin-bottom: 36px;
+}
+.history-details > summary {
+ color: var(--ink-soft);
+}
+.chart-title {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ color: var(--ink-soft);
+ font-size: 13px;
+ padding: 20px 0 12px;
+}
+.chart-title > span {
+ font-size: 12px;
+}
+.chart-box-small {
+ height: 130px;
+}
+.workspace {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 300px;
+ gap: 36px;
+}
+.main-column,
+.side-column {
+ min-width: 0;
+}
+.sec {
+ min-width: 0;
+ padding: 25px 0;
+ margin-bottom: 24px;
+ border-top: 1px solid var(--rule-strong);
+}
+.main-column > .sec:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+.sec-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ margin-bottom: 22px;
}
.sec h2 {
- font-size: 16px;
- font-weight: 650;
- letter-spacing: -0.025em;
+ font-size: 21px;
+ font-weight: 600;
+ letter-spacing: -0.04em;
}
.sec-meta {
- color: var(--ink-faint);
- font-size: 11px;
+ color: var(--ink-soft);
+ font-size: 12px;
}
.loading,
.empty {
color: var(--ink-soft);
- font-size: 13px;
- padding: 18px 0;
+ padding: 20px 0;
+ font-size: 14px;
}
.empty > strong,
.empty > span {
display: block;
}
.empty > span {
- margin-top: 5px;
+ font-size: 13px;
+ margin-top: 8px;
}
.fine {
- font-size: 11px;
- color: var(--ink-faint);
- line-height: 1.65;
- margin-top: 13px;
-}
-
-/* 차트 */
-#performance .sec-head {
- margin-bottom: 13px;
+ color: var(--ink-soft);
+ font-size: 12px;
+ line-height: 1.7;
+ margin-top: 15px;
}
-#chartSummary {
- width: 100%;
- font-size: 11px;
+.disclosure {
+ border-bottom: 1px solid var(--rule);
}
-.chart-controls {
+.disclosure > summary {
+ cursor: pointer;
display: flex;
align-items: center;
- justify-content: space-between;
- gap: 8px;
- margin-bottom: 15px;
+ min-height: 48px;
+ gap: 12px;
+ font-size: 13px;
+ list-style: none;
}
-.seg {
- display: flex;
- gap: 3px;
- padding: 3px;
- background: #f4f6f9;
- border-radius: 6px;
+.disclosure > summary::-webkit-details-marker {
+ display: none;
}
-.seg button {
- border: 0;
- background: transparent;
- min-height: 36px;
- min-width: 45px;
- padding: 0 11px;
+.disclosure > summary::after {
+ content: "+";
+ font-size: 18px;
+ margin-left: auto;
+}
+.disclosure[open] > summary::after {
+ content: "−";
+}
+.count {
+ font-size: 12px;
color: var(--ink-soft);
- border-radius: 4px;
- font-size: 11px;
}
-.seg button[aria-pressed="true"] {
- background: white;
- color: var(--ink);
- font-weight: 650;
- box-shadow: 0 1px 3px #25344c14;
+.side-column {
+ border-left: 1px solid var(--rule-strong);
+ padding-left: 28px;
}
-.chart-maturity {
- font-size: 11px;
- color: var(--ink-faint);
- max-width: 28ch;
+.side-column .sec:first-child {
+ border-top: 0;
+ padding-top: 0;
}
-.chart-title {
+.side-column h2 {
+ font-size: 16px;
+}
+.risk-explainer h2 {
+ margin-bottom: 20px;
+}
+.risk-scale {
display: flex;
- align-items: center;
- gap: 15px;
- font-size: 11px;
+ align-items: baseline;
+ gap: 10px;
+ margin-bottom: 16px;
+}
+.risk-scale strong {
+ font-size: 36px;
+ font-weight: 500;
+ letter-spacing: -0.05em;
+}
+.risk-scale span {
+ font-size: 12px;
color: var(--ink-soft);
- margin-bottom: 8px;
}
-.chart-title > span {
- font-size: 11px;
- color: var(--ink-faint);
+.risk-facts {
+ margin: 0;
}
-.legend-value::before,
-.legend-principal::before {
- content: "";
- display: inline-block;
- width: 14px;
- border-top: 2px solid var(--accent);
- margin-right: 6px;
- vertical-align: middle;
+.risk-facts > div {
+ padding: 12px 0;
+ border-bottom: 1px solid var(--rule);
}
-.legend-principal::before {
- border-top: 1px dashed #8290a4;
+.risk-facts dt {
+ font-size: 12px;
+ color: var(--ink-soft);
}
-.chart-box {
- position: relative;
- height: 238px;
- width: 100%;
+.risk-facts dd {
+ font-size: 14px;
+ margin-top: 3px;
}
-.chart-box canvas {
+.risk-explainer time {
display: block;
- width: 100%;
- height: 100%;
+ font-size: 12px;
+ color: var(--ink-soft);
+ margin-top: 14px;
}
-.chart-box-small {
- height: 76px;
+.attention {
+ background: #f4f4f4;
+ border: 0;
+ border-left: 3px solid var(--rule-strong);
+ padding: 22px !important;
+ border-radius: 4px;
}
-.chart + .chart {
- margin-top: 24px;
- padding-top: 16px;
- border-top: 1px solid var(--rule);
+.attention .sec-head {
+ margin-bottom: 15px;
}
-.tip {
- position: absolute;
- top: 0;
- left: 0;
- padding: 10px 12px;
- background: #fff;
+.attention h3 {
+ font-size: 16px;
+ line-height: 1.65;
+ font-weight: 600;
+}
+.attention #decisionDescription {
+ margin-top: 12px;
+ font-size: 13px;
+ color: #525252;
+}
+.attention .sec-meta {
+ margin-top: 14px;
+ font-size: 12px;
+ color: #525252;
+}
+.notice-symbol {
+ display: grid;
+ place-items: center;
+ width: 19px;
+ height: 19px;
+ border: 1px solid currentColor;
+ border-radius: 50%;
+ font-size: 12px;
+}
+.attention[data-state="info"] {
+ border-left-color: var(--accent);
+}
+.attention[data-state="alert"] {
+ border-left-color: var(--stop);
+}
+.attention[data-state="alert"] .notice-symbol {
+ color: var(--stop);
+}
+.priority-notice {
+ display: block;
+ width: 100%;
+ min-height: 44px;
+ padding: 12px 16px;
+ margin-bottom: 24px;
+ background: var(--surface);
border: 1px solid var(--rule);
- border-radius: 8px;
- box-shadow: 0 5px 20px #16294514;
- max-width: 230px;
- width: max-content;
- pointer-events: none;
- z-index: 4;
- font-size: 11px;
+ border-left: 3px solid var(--stop);
+ color: var(--ink);
+ text-align: left;
+ font-size: 13px;
+}
+.instrument .empty {
+ color: #d0e2ff;
+}
+.instrument .empty strong {
+ color: #ffffff;
+}
+.allocation-ring {
+ position: relative;
+ margin: 20px 0 16px;
+ padding-top: 25px;
+}
+.allocation-ring::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ width: 100%;
+ height: 12px;
+ background: linear-gradient(
+ 90deg,
+ var(--accent) var(--invested, 0%),
+ #c6c6c6 var(--invested, 0%)
+ );
+ mask-image: repeating-linear-gradient(90deg, #000 0 5px, transparent 5px 7px);
}
-.tip time,
-.tip strong,
-.tip span {
- display: block;
+.allocation-ring > span {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
}
-.tip strong {
- margin: 3px 0;
- font-size: 16px;
+.allocation-ring small {
+ color: var(--ink-soft);
+ font-size: 12px;
}
-.tip span {
- max-width: 26ch;
- font-size: 11px;
+.allocation-ring strong {
+ font-size: 21px;
+ font-weight: 500;
}
-.months {
+.allocation dl > div {
display: flex;
- flex-wrap: wrap;
- gap: 9px 22px;
- margin-top: 17px;
- padding-top: 15px;
- border-top: 1px solid var(--rule);
+ justify-content: space-between;
+ gap: 12px;
+ margin-top: 10px;
+ font-size: 13px;
}
-.months > div {
+.allocation dt {
display: flex;
align-items: center;
- gap: 8px;
- font-size: 11px;
+ gap: 7px;
+ color: var(--ink-soft);
}
-.months b {
- font-weight: 400;
- color: var(--ink-faint);
+.asset-dot,
+.cash-dot {
+ width: 6px;
+ height: 6px;
+ background: var(--accent);
+ display: inline-block;
}
-.months span {
- font-weight: 600;
- font-variant-numeric: tabular-nums;
+.cash-dot {
+ background: #8d8d8d;
}
-.disclosure {
- margin-top: 18px;
- border-top: 1px solid var(--rule);
+.status-row {
+ margin: 15px 0 5px;
+ padding: 0;
+ list-style: none;
}
-.disclosure > summary {
- cursor: pointer;
- min-height: 44px;
+.status-row li {
display: flex;
- align-items: center;
justify-content: space-between;
- font-size: 12px;
- font-weight: 500;
- gap: 12px;
- list-style: none;
-}
-.disclosure > summary::-webkit-details-marker {
- display: none;
-}
-.disclosure > summary::after {
- content: "+";
- color: var(--ink-faint);
- font-size: 18px;
-}
-.disclosure[open] > summary::after {
- content: "−";
+ gap: 8px;
+ margin-bottom: 10px;
+ font-size: 13px;
}
-.count {
- font-size: 11px;
- color: var(--ink-faint);
- margin-left: auto;
+.status-row .k {
+ color: var(--ink-soft);
}
-.disclosure > .table-scroll {
- max-height: 390px;
+.status-row .v {
+ font-weight: 500;
}
-
/* 보유 종목 */
.track-head {
display: flex;
@@ -733,7 +1071,7 @@ h1 {
.alloc {
padding: 14px 16px;
border-radius: 8px;
- background: #f7f9fc;
+ background: #f4f4f4;
margin-bottom: 20px;
}
.alloc-line {
@@ -753,7 +1091,7 @@ h1 {
.bar {
height: 5px;
border-radius: 3px;
- background: #e6ebf3;
+ background: #e0e0e0;
position: relative;
margin: 9px 0;
}
@@ -762,7 +1100,7 @@ h1 {
height: 100%;
max-width: 100%;
border-radius: 3px;
- background: #5475ae;
+ background: #0f62fe;
}
.bar > em {
position: absolute;
@@ -851,11 +1189,11 @@ tfoot td {
.wbar > i {
display: block;
height: 3px;
- background: #6a88ba;
+ background: #0f62fe;
border-radius: 2px;
}
.wbar > i.t {
- background: #c6cedb;
+ background: #c6c6c6;
}
.flow {
font-size: 11px;
@@ -863,134 +1201,6 @@ tfoot td {
padding-top: 15px;
}
-/* 확인할 일 */
-.side-column .sec {
- padding: 20px;
-}
-.side-column h2 {
- font-size: 13px;
-}
-.attention {
- background: #eef2f9;
- border-color: #e6ebf4;
-}
-.attention .sec-head {
- align-items: center;
- margin-bottom: 17px;
-}
-.notice-symbol {
- height: 20px;
- width: 20px;
- border: 1px solid #a8b7ce;
- border-radius: 50%;
- display: grid;
- place-items: center;
- font-size: 11px;
- color: var(--accent);
-}
-.attention h3 {
- font-size: 16px;
- line-height: 1.65;
- font-weight: 600;
- letter-spacing: -0.025em;
-}
-.attention #decisionDescription {
- color: var(--ink-soft);
- font-size: 13px;
- line-height: 1.75;
- margin-top: 9px;
-}
-.attention .link {
- margin-top: 10px;
-}
-.attention .sec-meta {
- margin-top: 13px;
- padding-top: 12px;
- border-top: 1px solid #dce3ef;
- font-size: 11px;
-}
-.allocation-ring {
- --invested: 0%;
- margin: 22px auto;
- width: 144px;
- height: 144px;
- border-radius: 50%;
- background: conic-gradient(
- #5777ae 0 var(--invested),
- #e7ebf2 var(--invested) 100%
- );
- padding: 15px;
-}
-.allocation-ring > span {
- display: flex;
- align-items: center;
- justify-content: center;
- flex-direction: column;
- height: 100%;
- background: white;
- border-radius: 50%;
-}
-.allocation-ring small {
- font-size: 11px;
- color: var(--ink-soft);
-}
-.allocation-ring strong {
- font-size: 24px;
- font-weight: 600;
- letter-spacing: -0.025em;
-}
-.allocation dl > div {
- display: flex;
- justify-content: space-between;
- gap: 8px;
- padding: 7px 0;
- font-size: 12px;
-}
-.allocation dt {
- color: var(--ink-soft);
- display: flex;
- align-items: center;
- gap: 7px;
-}
-.allocation dd {
- font-variant-numeric: tabular-nums;
- font-weight: 500;
-}
-.asset-dot,
-.cash-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- border-radius: 2px;
- background: #5777ae;
-}
-.cash-dot {
- background: #d2d9e5;
-}
-.allocation .fine {
- font-size: 11px;
-}
-.status-row {
- list-style: none;
- margin: 14px 0 8px;
- padding: 0;
-}
-.status-row li {
- display: flex;
- align-items: center;
- gap: 7px;
- font-size: 11px;
- padding: 8px 0;
-}
-.status-row .k {
- color: var(--ink-soft);
-}
-.status-row .v {
- margin-left: auto;
- font-weight: 500;
-}
-
-/* 검증 및 자동매매 */
.review-item {
display: grid;
grid-template-columns: minmax(100px, 1fr) minmax(130px, 1.2fr) auto;
@@ -1051,7 +1261,7 @@ tfoot td {
margin-top: 3px;
}
.halt {
- background: #fff5f3;
+ background: #fff1f1;
padding: 18px;
border-radius: 8px;
margin-top: 20px;
@@ -1103,10 +1313,10 @@ tfoot td {
background: white;
max-height: calc(100dvh - 32px);
overflow: auto;
- box-shadow: 0 20px 80px #0d203333;
+ box-shadow: 0 20px 80px #16161633;
}
.deposit-dialog::backdrop {
- background: #15263e66;
+ background: #16161666;
}
.deposit-dialog form {
padding: 25px;
@@ -1195,7 +1405,7 @@ tfoot td {
.quick button[aria-pressed="true"] {
background: var(--accent-bg);
color: var(--accent);
- border-color: #718fca;
+ border-color: #0f62fe;
}
.confirm {
padding: 16px 0;
@@ -1235,7 +1445,7 @@ tfoot td {
left: 50%;
transform: translate(-50%, 12px);
opacity: 0;
- background: #263d64;
+ background: var(--accent);
color: white;
border-radius: 9px;
padding: 12px 20px;
@@ -1252,403 +1462,583 @@ tfoot td {
transform: translate(-50%, 0);
}
-@media (min-width: 1021px) and (min-height: 960px) {
- .side-column {
- position: sticky;
- top: 92px;
- }
+/* 기록과 폼은 밝은 바탕에서 읽고, 계측 화면만 어두운 면을 쓴다. */
+table {
+ background: transparent;
+}
+.track-name {
+ font-size: 20px;
+}
+.note,
+.overlay {
+ font-size: 13px;
+}
+.track-kind {
+ font-size: 12px;
+}
+.review-item {
+ grid-template-columns: 1.1fr 1.3fr auto;
+}
+.foot {
+ padding: 25px 0;
+ border-top: 1px solid var(--rule-strong);
+}
+.tip {
+ display: none;
}
-@media (max-width: 1240px) {
- :root {
- --rail: 178px;
+@media (max-width: 1120px) {
+ .chrome {
+ gap: 30px;
}
- .page {
- padding: 27px 24px 0;
+ .header-actions {
+ gap: 10px;
}
- .chrome {
- padding-inline: 24px;
+ .state {
+ display: none;
}
.workspace {
- grid-template-columns: minmax(0, 1fr) 232px;
- gap: 18px;
+ grid-template-columns: minmax(0, 1fr) 260px;
+ gap: 24px;
+ }
+ .side-column {
+ padding-left: 22px;
+ }
+ .instrument-body {
+ grid-template-columns: 220px minmax(0, 1fr);
}
.overview {
- gap: 18px;
- padding: 24px;
+ gap: 25px;
}
.overview-stats {
- padding-left: 22px;
- gap: 18px;
+ gap: 15px;
}
- .overview-total {
- min-width: 180px;
+ .overview-stats dd {
+ font-size: 24px;
}
- .overview-stats small {
- max-width: 15ch;
+}
+@media (max-width: 860px) {
+ .chrome {
+ gap: 24px;
+ padding: 12px 20px;
+ }
+ .nav {
+ gap: 19px;
+ }
+ .brand {
+ font-size: 23px;
+ gap: 8px;
+ }
+ .brand img {
+ width: 26px;
+ height: 26px;
+ }
+ .page {
+ padding: 30px 20px 0;
+ }
+ h1 {
+ font-size: 34px;
+ }
+ .instrument {
+ padding: 22px 22px 0;
}
- .rail {
- padding-inline: 12px;
+ .instrument-status {
+ margin: 0 -22px;
+ padding: 10px 22px;
}
- .nav > a {
+ .instrument-body {
+ grid-template-columns: 190px minmax(0, 1fr);
+ gap: 0;
+ }
+ .readout-return {
+ font-size: 43px;
+ }
+ .readout dl {
+ max-width: 180px;
+ }
+ .chart-box {
+ height: 300px;
+ }
+ .chart-legend {
gap: 10px;
- font-size: 13px;
+ font-size: 11px;
}
- .sec {
- padding: 21px;
+ .overview {
+ grid-template-columns: 1fr;
+ gap: 25px;
+ }
+ .overview-total {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 4px 12px;
+ }
+ .overview-total > p {
+ grid-column: 1;
+ }
+ .overview-total > strong {
+ grid-column: 1;
+ grid-row: 2;
+ }
+ .overview-total > span {
+ grid-column: 2;
+ grid-row: 2;
+ text-align: right;
+ align-self: center;
+ }
+ .overview-total .overview-cash {
+ grid-column: 2;
+ grid-row: 1;
+ text-align: right;
+ margin: 0;
}
- .chart-maturity {
- display: none;
+ .workspace {
+ grid-template-columns: 1fr;
+ gap: 18px;
}
- .state time {
- display: none;
+ .side-column {
+ padding-left: 0;
+ border: 0;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 24px;
+ align-items: start;
}
-}
-@media (max-width: 1020px) {
- :root {
- --rail: 72px;
+ .side-column .sec {
+ margin: 0;
}
- .rail {
- padding: 27px 11px;
+ .side-column .sec:nth-child(2) {
+ order: -1;
+ grid-column: 1 / -1;
}
- .brand {
- padding: 0;
- justify-content: center;
- }
- .brand > span,
- .nav > a > span,
- .rail-bottom {
- position: absolute;
- width: 1px;
- height: 1px;
- clip-path: inset(50%);
- overflow: hidden;
- }
- .nav > a {
- padding: 13px;
- justify-content: center;
- }
- .nav > a > svg {
- width: 21px;
- height: 21px;
+ .system-summary {
+ display: none;
}
- .workspace {
- grid-template-columns: minmax(0, 1fr) 218px;
+ .risk-explainer {
+ border-top: 1px solid var(--rule-strong) !important;
+ padding-top: 22px !important;
}
- .overview-stats {
- gap: 15px;
- padding-left: 20px;
+ .record-date {
+ font-size: 12px;
}
- .overview {
- gap: 20px;
+}
+@media (max-width: 600px) {
+ html {
+ scroll-padding-top: 125px;
}
- .overview-total {
- min-width: 165px;
+ .chrome {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 6px 12px;
+ padding: 12px 16px 0;
+ min-height: 105px;
}
- .overview-stats dd {
- font-size: 21px;
+ .nav {
+ grid-row: 2;
+ grid-column: 1/-1;
+ gap: 26px;
}
- .sec {
- padding: 20px;
+ .nav a {
+ padding: 9px 0 12px;
+ font-size: 13px;
}
.header-actions {
- gap: 13px;
+ grid-column: 2;
+ grid-row: 1;
}
- .side-column .sec {
- padding: 18px;
+ .header-actions .pill {
+ min-height: 36px;
+ padding: 0 12px;
+ font-size: 12px;
}
- .page-heading time {
+ .mode {
font-size: 11px;
+ padding: 2px 5px;
}
-}
-@media (max-width: 800px) {
- .overview-cash {
- display: block;
- }
- .workspace {
- grid-template-columns: minmax(0, 1fr);
+ .page {
+ padding: 25px 16px 0;
}
- .side-column {
- display: grid;
- grid-template-columns: 1fr 1fr;
- gap: 18px;
- grid-row: 1;
+ .page-heading {
+ align-items: flex-start;
+ margin-bottom: 23px;
+ gap: 12px;
}
- .side-column .sec {
- margin-bottom: 0;
+ h1 {
+ font-size: 30px;
}
- .side-column .attention {
- grid-column: 1/-1;
+ .page-kicker {
+ font-size: 12px;
+ margin-bottom: 6px;
}
- .attention #decisionDescription {
- margin-top: 4px;
+ .record-date {
+ padding-top: 4px;
+ max-width: 125px;
+ font-size: 11px;
}
- .attention .sec-head {
- margin-bottom: 6px;
+ .record-date span {
+ font-size: 11px;
}
- .attention .sec-meta,
- .side-column .allocation,
- .side-column .system-summary {
- display: none;
+ .account-tabs {
+ gap: 2px;
+ min-height: 57px;
}
- .main-column {
- margin-top: 0;
+ .account-tabs > button {
+ min-width: 0;
+ flex: 1;
+ padding: 11px 11px;
+ gap: 7px;
+ font-size: 13px;
}
- .overview {
- flex-direction: column;
- align-items: stretch;
- gap: 21px;
+ .account-tabs small {
+ font-size: 11px;
}
- .overview-stats {
- padding: 20px 0 0;
- border-left: 0;
- border-top: 1px solid var(--rule);
+ .account-icon,
+ .account-check {
+ display: none;
}
- .overview-total > strong {
- font-size: 36px;
+ .instrument {
+ padding: 19px 15px 0;
+ border-radius: 0 8px 8px 8px;
}
- .overview-stats small {
- max-width: none;
+ .instrument h2 {
+ font-size: 16px;
}
- .overview-stats dd {
- font-size: 23px;
+ .instrument-head p {
+ font-size: 11px;
}
- .state {
- display: none;
+ .view-switch button {
+ min-height: 36px;
+ min-width: 42px;
}
- .page-heading {
+ .chart-controls {
align-items: flex-start;
+ gap: 6px;
+ margin-top: 14px;
+ flex-wrap: wrap;
}
- .page-heading time {
- margin-top: 5px;
+ .seg {
+ gap: 2px;
}
- .attention .link {
- margin-top: 4px;
+ .seg button {
+ font-size: 12px;
+ padding: 0 12px;
}
-}
-@media (max-width: 560px) {
- :root {
- --rail: 0px;
+ .chart-maturity {
+ width: 100%;
+ font-size: 11px;
+ margin-top: 8px;
}
- html {
- scroll-padding-top: calc(112px + env(safe-area-inset-top, 0px));
+ .instrument-body {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
}
- .rail {
- top: calc(57px + env(safe-area-inset-top, 0px));
- bottom: auto;
+ .readout {
width: 100%;
- height: 50px;
- padding: 0 12px;
- border-right: 0;
- border-bottom: 1px solid var(--rule);
- }
- .brand {
- display: none;
+ padding: 20px 0 5px;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ column-gap: 18px;
}
- .nav {
- flex-direction: row;
- margin: 0;
- gap: 0;
- height: 100%;
- justify-content: space-between;
+ .readout-date {
+ grid-column: 1/-1;
+ font-size: 12px;
}
- .nav > a {
- flex: 1;
- min-width: 0;
- min-height: 44px;
- padding: 0 6px;
- gap: 0;
- border-radius: 0;
+ .readout-label {
+ grid-column: 1;
+ margin-top: 19px;
font-size: 11px;
- border-bottom: 2px solid transparent;
- white-space: nowrap;
}
- .nav > a > svg {
- display: none;
+ .readout-return {
+ grid-column: 1;
+ font-size: 43px;
+ margin-top: 0;
+ }
+ .readout dl {
+ grid-column: 2;
+ grid-row: 2/4;
+ align-self: center;
+ width: 100%;
+ max-width: none;
+ margin: 8px 0 0;
}
- .nav > a > span {
- display: inline;
- position: static;
- width: auto;
- height: auto;
- clip-path: none;
- overflow: visible;
+ .readout dl > div {
+ font-size: 11px;
+ gap: 8px;
+ margin-top: 6px;
}
- .nav > a[aria-current] {
- background: transparent;
- border-bottom-color: var(--accent);
+ .readout-event {
+ grid-column: 1/-1;
+ margin-top: 12px;
+ min-height: 20px;
+ max-width: none;
+ font-size: 11px;
}
- .chrome {
- height: calc(57px + env(safe-area-inset-top, 0px));
- min-height: 57px;
- padding: calc(10px + env(safe-area-inset-top, 0px))
- max(16px, env(safe-area-inset-right)) 10px
- max(16px, env(safe-area-inset-left));
+ .chart-main {
+ width: 100%;
}
- .location {
- font-size: 0;
+ .chart-box {
+ height: 242px;
}
- .location::before {
- content: "눈금";
- font-size: 22px;
- font-weight: 700;
- letter-spacing: -0.065em;
- color: var(--ink);
+ .chart-legend {
+ justify-content: flex-start;
+ gap: 15px;
+ font-size: 11px;
+ padding-bottom: 14px;
}
- .location strong {
- display: none;
+ .timeline {
+ padding: 13px 0;
}
- .header-actions {
+ .timeline-labels {
+ font-size: 11px;
gap: 10px;
}
- .pill {
- min-height: 36px;
- font-size: 12px;
- padding-inline: 12px;
+ .timeline-dates {
+ font-size: 11px;
}
- .page {
- padding: 73px 16px 0;
+ .latest-button {
+ font-size: 11px;
}
- .page-heading {
- margin-bottom: 23px;
- flex-wrap: wrap;
- gap: 9px;
+ .instrument-foot {
+ gap: 16px;
+ padding: 12px 0;
}
- .page-heading time {
+ .instrument-foot > span {
font-size: 11px;
- width: 100%;
- }
- h1 {
- font-size: 23px;
}
- .page-heading p {
- font-size: 12px;
+ .months {
+ gap: 18px;
}
- .account-tabs {
- gap: 8px;
+ .months > div {
+ flex-direction: column;
+ gap: 2px;
+ font-size: 13px;
}
- .account-tabs > button {
- padding: 12px 10px;
- gap: 7px;
+ .months b {
font-size: 11px;
}
- .account-tabs small {
+ .instrument-status {
+ margin: 0 -15px;
+ padding: 10px 15px;
+ display: block;
font-size: 11px;
}
- .account-icon,
- .account-check {
- display: none;
+ .instrument-status p {
+ line-height: 1.6;
+ }
+ .instrument-export {
+ display: block;
+ margin: 5px 0 0 auto;
}
.overview {
- padding: 20px;
- margin-bottom: 16px;
+ padding: 23px 0;
+ gap: 20px;
}
.overview-total > strong {
- font-size: 34px;
+ font-size: 28px;
}
- .overview-stats {
- gap: 11px;
+ .overview-total > span {
+ font-size: 11px;
}
- .overview-stats dd {
- font-size: 19px;
+ .overview-total > p,
+ .overview-total .overview-cash {
+ font-size: 12px;
}
- .overview-stats dt {
- font-size: 11px;
+ .overview-stats {
+ gap: 10px;
}
- .overview-stats small {
+ .overview-stats dt {
font-size: 11px;
}
.overview-stats abbr {
display: none;
}
- .overview-total > span {
+ .overview-stats dd {
+ font-size: 20px;
+ margin: 6px 0;
+ }
+ .overview-stats small {
font-size: 11px;
+ line-height: 1.6;
+ display: block;
}
- .workspace {
- gap: 16px;
+ .history-details {
+ margin-bottom: 23px;
}
.sec {
- padding: 20px 16px;
- margin-bottom: 16px;
- border-radius: 12px;
- }
- .sec-head {
- gap: 5px;
+ padding: 22px 0;
+ margin-bottom: 15px;
}
.sec h2 {
- font-size: 15px;
+ font-size: 20px;
+ }
+ .sec-head {
+ gap: 10px;
+ margin-bottom: 18px;
}
.sec-meta {
font-size: 11px;
}
- .chart-box {
- height: 215px;
+ .track-name {
+ font-size: 19px;
}
- .chart-box-small {
- height: 66px;
+ .track-head {
+ align-items: flex-start;
}
- .chart-title {
- gap: 12px;
+ .spark {
+ width: 85px;
+ }
+ .note,
+ .overlay {
+ font-size: 12px;
+ }
+ .table-scroll {
+ margin: 0 -16px;
+ padding: 0 16px;
+ }
+ table {
+ min-width: 580px;
+ }
+ .table-scroll:focus-visible {
+ outline-offset: -3px;
}
.review-item {
- grid-template-columns: minmax(0, 1fr) auto;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ }
+ .review-name {
+ flex: 1 1 100%;
}
.review-progress {
- grid-row: 2;
- grid-column: 1/-1;
+ flex: 1;
+ }
+ .review-issues {
+ flex-basis: 100%;
+ }
+ .review-progress p {
+ font-size: 11px;
}
.status-item {
- grid-template-columns: 88px minmax(0, 1fr);
- gap: 10px;
+ grid-template-columns: 95px 1fr;
+ }
+ .side-column {
+ gap: 20px;
+ }
+ .side-column h2 {
+ font-size: 15px;
+ }
+ .risk-scale {
+ display: block;
+ }
+ .risk-scale strong {
+ font-size: 30px;
+ display: block;
+ }
+ .risk-facts dd {
+ font-size: 12px;
+ }
+ .risk-facts dt,
+ .risk-explainer time {
+ font-size: 11px;
+ }
+ .allocation dl > div {
+ flex-wrap: wrap;
+ font-size: 12px;
+ }
+ .allocation .fine {
+ font-size: 11px;
+ }
+ .attention {
+ padding: 20px !important;
+ }
+ .side-column .attention h2 {
+ font-size: 16px;
}
.foot {
flex-direction: column;
align-items: flex-start;
- gap: 6px;
+ gap: 8px;
+ padding: 25px 0 max(25px, env(safe-area-inset-bottom));
font-size: 11px;
}
- .track-head .spark {
- display: none;
+ .deposit-dialog {
+ max-width: calc(100% - 32px);
+ width: 400px;
+ padding: 22px;
}
- .track-name {
- font-size: 13px;
+ .dialog-actions {
+ flex-wrap: wrap;
}
- .fine {
- font-size: 11px;
+ .dialog-actions .pill {
+ flex: 1;
+ min-width: 85px;
+ }
+ .toast {
+ max-width: calc(100vw - 32px);
}
}
@media (max-width: 360px) {
- .overview {
- padding: 17px;
+ .page {
+ padding-left: 12px;
+ padding-right: 12px;
}
- .overview-stats {
- gap: 8px;
+ .chrome {
+ padding-left: 12px;
+ padding-right: 12px;
}
- .overview-stats dd {
- font-size: 17px;
+ .nav {
+ gap: 23px;
}
- .overview-stats small {
- font-size: 11px;
+ h1 {
+ font-size: 26px;
}
- .page {
- padding-inline: 12px;
+ .record-date {
+ max-width: 110px;
}
- .nav > a {
- font-size: 11px;
- padding-inline: 4px;
+ .instrument {
+ padding-left: 12px;
+ padding-right: 12px;
+ }
+ .instrument-status {
+ margin-left: -12px;
+ margin-right: -12px;
+ padding-left: 12px;
+ padding-right: 12px;
+ }
+ .readout {
+ gap: 0 10px;
+ }
+ .readout-return {
+ font-size: 37px;
+ }
+ .overview-stats dd {
+ font-size: 18px;
+ }
+ .overview-total > strong {
+ font-size: 25px;
+ }
+ .overview-total > span {
+ max-width: 125px;
+ justify-self: end;
}
.chart-box {
- height: 200px;
+ height: 225px;
+ }
+ .side-column {
+ grid-template-columns: 1fr;
+ }
+ .side-column .sec:nth-child(2) {
+ grid-column: 1;
}
- .attention h3 {
- font-size: 14px;
+ .allocation dl > div {
+ flex-wrap: nowrap;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
- transition: none !important;
- animation: none !important;
}
}
diff --git a/monitoring/static/dashboard.js b/monitoring/static/dashboard.js
index 1f12b18..f112139 100644
--- a/monitoring/static/dashboard.js
+++ b/monitoring/static/dashboard.js
@@ -110,12 +110,12 @@
const FONT =
'"Nungum UI", "Apple SD Gothic Neo", "Malgun Gothic", sans-serif';
const COLORS = {
- ink: "#3157a4",
- up: "#c4383c",
- down: "#2563c7",
- warn: "#976019",
+ ink: "#0f62fe",
+ up: "#da1e28",
+ down: "#0072c3",
+ warn: "#a2191f",
};
- const inkAlpha = (a) => `rgba(48, 60, 78, ${a})`;
+ const inkAlpha = (a) => `rgba(22, 22, 22, ${a})`;
/* ---------- 상태 ---------- */
@@ -142,6 +142,8 @@
coreStatus: "loading",
runtimeStatus: "loading",
activeRequests: new Map(),
+ chartRequest: 0,
+ chartDataAccount: null,
depositConfirming: false,
depositRequestId: null,
};
@@ -233,7 +235,7 @@
setSync("partial", "계좌 기록 정상 · 운영 상태 확인 불가");
else if (state.coreStatus === "partial")
setSync("partial", "일부 데이터 지연");
- else setSync("ok", "연결 정상");
+ else setSync("ok", "계좌 기록 연결 정상");
updateDepositAvailability();
}
function markCoreSuccess(timestamp) {
@@ -246,9 +248,17 @@
}
function setMode(mode) {
const normalized = String(mode || "unknown").toLowerCase();
- state.mode = ["paper", "live"].includes(normalized)
+ const nextMode = ["paper", "live"].includes(normalized)
? normalized
: "unknown";
+ if (state.mode !== nextMode) {
+ state.flows.clear();
+ state.flowStatus.clear();
+ state.series.clear();
+ state.seriesStatus.clear();
+ state.chartDataAccount = null;
+ }
+ state.mode = nextMode;
el.modeBadge.dataset.mode = state.mode;
el.modeBadge.textContent =
state.mode === "paper"
@@ -314,12 +324,27 @@
/** 스냅샷·입금 기록으로 평가금액, 투자원금(계단), 시간가중 자산, 낙폭 시계열을 만든다. */
function buildSeries(rows, flows, initialCapital) {
+ if (
+ rows.some(
+ (r) =>
+ !parseDate(r.date) ||
+ !Number.isFinite(num(r.total_value)) ||
+ num(r.total_value) < 0 ||
+ !Number.isFinite(num(r.cumulative_return)) ||
+ num(r.cumulative_return) < -100,
+ )
+ )
+ throw new Error("날짜 또는 계좌 금액이 올바르지 않습니다.");
+ if (
+ new Set(rows.map((r) => String(r.date).slice(0, 10))).size !== rows.length
+ )
+ throw new Error("같은 날짜의 계좌 기록이 중복됐습니다.");
const sorted = [...rows]
.filter((r) => parseDate(r.date))
.sort((a, b) => parseDate(a.date) - parseDate(b.date));
const dates = sorted.map((r) => parseDate(r.date));
- const value = sorted.map((r) => Number(r.total_value || 0));
- const cr = sorted.map((r) => Number(r.cumulative_return || 0));
+ const value = sorted.map((r) => Number(r.total_value));
+ const cr = sorted.map((r) => Number(r.cumulative_return));
const twr = cr.map((c) => initialCapital * (1 + c / 100));
const sortedFlows = [...(flows || [])]
.map((f) => ({
@@ -330,13 +355,18 @@
.filter((f) => f.at)
.sort((a, b) => a.at - b.at);
const instants = sorted.map(rowInstant);
- const principal = instants.map(
- (at) =>
- initialCapital +
- sortedFlows
- .filter((f) => at && f.at <= at)
- .reduce((s, f) => s + f.amount, 0),
- );
+ let flowIndex = 0;
+ let principalAt = initialCapital;
+ const principal = instants.map((at) => {
+ while (
+ flowIndex < sortedFlows.length &&
+ at &&
+ sortedFlows[flowIndex].at <= at
+ ) {
+ principalAt += sortedFlows[flowIndex++].amount;
+ }
+ return principalAt;
+ });
// 계좌 기록과 같은 규칙: 시작 자본을 첫 고점으로 두고 시간가중 자산으로 낙폭을 잰다.
let peak = initialCapital > 0 ? initialCapital : -Infinity;
let peakIdx = -1;
@@ -531,10 +561,42 @@
? "자산 구성 기록 없음"
: `보유 자산 ${(100 - cashPct).toFixed(1)}%, 현금 ${cashPct.toFixed(1)}%`,
);
+ renderRiskExplanation(b);
$("allocationList").innerHTML =
`
보유 자산${won(snap ? snap.total_value - snap.cash : null)}현금${won(snap?.cash)}`;
}
+ function renderRiskExplanation(basket) {
+ const target = $("riskExplanation");
+ const overlay = basket?.overlay;
+ if (!basket) {
+ target.innerHTML =
+ '계좌 기록을 불러오는 중입니다.
';
+ return;
+ }
+ if (!overlay?.enabled) {
+ target.innerHTML = `${Math.round((basket.design_fraction || 0) * 100)}%보유 자산 목표 비중
설정한 비중을 유지합니다. 추세와 낙폭에 따른 자동 축소는 사용하지 않습니다.
`;
+ return;
+ }
+ const riskWeights = Object.entries(basket.target_weights || {})
+ .filter(([symbol]) => symbol !== overlay.defensive_symbol)
+ .reduce((sum, [, weight]) => sum + weight, 0);
+ const issues = overlay.data_issues || [];
+ const trend =
+ overlay.trend_below == null
+ ? "판단 기록 확인 필요"
+ : overlay.trend_below
+ ? "장기 추세 아래 · 비중 축소"
+ : "장기 추세 기준 유지";
+ const dd =
+ overlay.drawdown_active == null
+ ? "판단 기록 확인 필요"
+ : overlay.drawdown_active
+ ? "낙폭 기준 도달 · 비중 축소"
+ : "낙폭 기준 이내";
+ target.innerHTML = `${(riskWeights * 100).toFixed(1)}%주식 목표 비중
- 추세 판단
- ${escapeHtml(trend)}
- 손실 관리
- ${escapeHtml(dd)}
- 비중을 줄이면
- ${overlay.defensive_symbol ? "줄인 금액은 CD금리 ETF에 배분" : "현금으로 보유"}
${issues.length ? `${escapeHtml(issues.join(" / "))}
` : ""}${overlay.evaluated_at ? `` : '첫 판단 기록을 기다리고 있습니다.
'}`;
+ }
+
function latestSnapshotDate() {
const b = selectedBasket();
return b && b.snapshot ? b.snapshot.date : null;
@@ -572,6 +634,11 @@
el.decisionAction.hidden = !action;
el.decisionAction.dataset.action = action || "";
el.decisionAction.textContent = actionLabel;
+ const urgent = ["operations", "retry"].includes(action);
+ $("today").dataset.state = urgent ? "alert" : action ? "info" : "quiet";
+ $("today").querySelector(".notice-symbol").textContent = urgent ? "!" : "·";
+ $("priorityNotice").hidden = !urgent;
+ $("priorityNotice").textContent = `${title} · ${actionLabel} →`;
}
function renderDecision() {
const baskets = state.baskets;
@@ -932,6 +999,10 @@
hover: null,
geometry: null,
series: null,
+ view: "ribbon",
+ perspective: 1,
+ selected: null,
+ paintFrame: null,
};
function ensureChartAccountOptions() {
@@ -1033,156 +1104,287 @@
.join("");
}
- function equityGeometry(series, width, height) {
- const padding = {
- top: 20,
- right: 12,
- bottom: 28,
- left: width < 480 ? 52 : 64,
- };
- const n = series.rows.length;
- const x = (i) =>
- padding.left +
- ((width - padding.left - padding.right) * i) / Math.max(1, n - 1);
- const scale = makeScale(
- [...series.value, ...series.principal],
- padding.top,
- height - padding.bottom,
- 0.08,
- );
- return { padding, x, scale, width, height, n };
- }
-
- function drawChart(rows) {
+ // 같은 기록을 입체와 평면으로 표현한다. 깊이는 별도 지표가 아니다.
+ // 최대 420개 꼭짓점으로 표현하되 원본 기록·날짜 선택·다운로드는 생략하지 않는다.
+ function drawChart() {
if (!chart.series || el.chartWrap.hidden) return;
const series = chart.series;
+ const n = series.rows.length;
+ if (!n) return;
const width = Math.max(1, el.chartBox.clientWidth);
- const height = Math.max(200, el.chartBox.clientHeight);
- const context = setupCanvas(el.chartEquity, width, height);
- const g = equityGeometry(series, width, height);
- chart.geometry = g;
- const { padding, x, scale, n } = g;
- context.font = `400 11.5px ${FONT}`;
- context.textBaseline = "middle";
-
- context.textAlign = "right";
- niceTicks(scale.min, scale.max, 4).forEach((v, i) => {
- const y = scale.y(v);
- context.strokeStyle = inkAlpha(i === 4 ? 0.34 : 0.12);
- context.lineWidth = 1;
- context.beginPath();
- context.moveTo(padding.left, y);
- context.lineTo(width - padding.right, y);
- context.stroke();
- context.fillStyle = inkAlpha(0.7);
- context.fillText(compactWon(v), padding.left - 8, y);
+ const height = Math.max(180, el.chartBox.clientHeight);
+ const ctx = setupCanvas(el.chartEquity, width, height);
+ const p = chart.perspective;
+ const small = width < 480;
+ const dx = (small ? 23 : 45) * p;
+ const dy = (small ? 42 : 65) * p;
+ const left = small ? 37 : 57;
+ const right = small ? 26 : 42;
+ const plotWidth = Math.max(1, width - left - right - dx);
+ const max = Math.max(0, ...series.cr);
+ const min = Math.min(0, ...series.cr);
+ const spread = Math.max(2, max - min);
+ const top = 36 + dy;
+ const floor = height - 44;
+ const span = Math.max(50, floor - top - 25 * p);
+ const x = (i) => left + (plotWidth * i) / Math.max(1, n - 1);
+ const y = (v, i = 0) =>
+ top +
+ ((max + spread * 0.15 - v) / (spread * 1.3)) * span +
+ (22 * p * i) / Math.max(1, n - 1);
+ chart.geometry = {
+ width,
+ height,
+ n,
+ x,
+ padding: { left, right: right + dx },
+ scale: { y },
+ dx,
+ dy,
+ };
+ ctx.font = `400 ${small ? 11 : 12}px ${FONT}`;
+ ctx.lineWidth = 1;
+ ctx.textBaseline = "middle";
+ // 그리드와 기준선도 같은 투영을 사용한다. 0%가 손익의 기준이다.
+ const grid = [min, (min + max) / 2, max];
+ grid.forEach((v, index) => {
+ if (index > 0 && Math.abs(v - grid[index - 1]) < 0.01) return;
+ ctx.beginPath();
+ ctx.moveTo(left, y(v));
+ ctx.lineTo(x(n - 1), y(v, n - 1));
+ ctx.lineTo(x(n - 1) + dx, y(v, n - 1) - dy);
+ ctx.strokeStyle = "#a6c8ff44";
+ ctx.stroke();
+ ctx.fillStyle = "#d0e2ff";
+ ctx.textAlign = "right";
+ ctx.fillText(`${v.toFixed(0)}%`, left - 10, y(v));
});
- const step = Math.max(1, Math.ceil((n - 1) / (width < 480 ? 3 : 6)));
- context.fillStyle = inkAlpha(0.7);
- for (let i = 0; i < n - 1; i += step) {
- if (n > 1 && i > n - 1 - step * 0.5) break;
- context.textAlign = i === 0 ? "left" : "center";
- context.fillText(
- fmtMD(series.rows[i].date),
- x(i),
- height - padding.bottom + 14,
+ for (let t = 0; t <= 6; t++) {
+ const i = ((n - 1) * t) / 6;
+ ctx.beginPath();
+ ctx.moveTo(x(i), floor);
+ ctx.lineTo(x(i) + dx, floor - dy);
+ ctx.strokeStyle = "#a6c8ff30";
+ ctx.stroke();
+ }
+ ctx.setLineDash([4, 5]);
+ ctx.beginPath();
+ ctx.moveTo(x(0), y(0));
+ ctx.lineTo(x(n - 1), y(0, n - 1));
+ ctx.strokeStyle = "#a6c8ff99";
+ ctx.stroke();
+ ctx.setLineDash([]);
+ const step = Math.max(1, Math.ceil((n - 1) / 420));
+ const indices = [];
+ for (let i = 0; i < n; i += step) indices.push(i);
+ if (indices.at(-1) !== n - 1) indices.push(n - 1);
+ const polygon = (points, fill) => {
+ ctx.beginPath();
+ points.forEach(([a, b], i) => (i ? ctx.lineTo(a, b) : ctx.moveTo(a, b)));
+ ctx.closePath();
+ ctx.fillStyle = fill;
+ ctx.fill();
+ };
+ if (p > 0.001) {
+ // 2D Canvas에 투영한 띠: WebGL 컨텍스트, 텍스처, 3D 모델 다운로드가 없다.
+ for (let k = 0; k < indices.length - 1; k++) {
+ const a = indices[k],
+ b = indices[k + 1];
+ const ax = x(a),
+ ay = y(series.cr[a], a),
+ bx = x(b),
+ by = y(series.cr[b], b);
+ polygon(
+ [
+ [ax, ay],
+ [bx, by],
+ [bx, by + 5 * p],
+ [ax, ay + 5 * p],
+ ],
+ `rgba(15,98,254,${p})`,
+ );
+ const light = clamp(67 + (ay - by) * 0.6, 55, 81);
+ polygon(
+ [
+ [ax, ay],
+ [bx, by],
+ [bx + dx, by - dy],
+ [ax + dx, ay - dy],
+ ],
+ `hsla(215,90%,${light}%,${p * 0.96})`,
+ );
+ }
+ ctx.beginPath();
+ indices.forEach((i, k) =>
+ k
+ ? ctx.lineTo(x(i) + dx, y(series.cr[i], i) - dy)
+ : ctx.moveTo(x(i) + dx, y(series.cr[i], i) - dy),
);
+ ctx.strokeStyle = `rgba(186,230,255,${p * 0.95})`;
+ ctx.stroke();
}
- context.textAlign = n > 1 ? "right" : "left";
- context.fillText(
- fmtMD(series.rows[n - 1].date),
- x(n - 1),
- height - padding.bottom + 14,
+ ctx.beginPath();
+ indices.forEach((i, k) =>
+ k
+ ? ctx.lineTo(x(i), y(series.cr[i], i))
+ : ctx.moveTo(x(i), y(series.cr[i], i)),
);
-
- const visible = Math.max(1, Math.ceil((n - 1) * chart.progress) + 1);
- const clipRight = n > 1 ? x(0) + (x(n - 1) - x(0)) * chart.progress : width;
- context.save();
- context.beginPath();
- context.rect(0, 0, clipRight + 1, height);
- context.clip();
-
- context.save();
- context.setLineDash([3, 4]);
- context.strokeStyle = inkAlpha(0.5);
- context.lineWidth = 1;
- context.beginPath();
- series.principal.forEach((v, i) => {
- if (i >= visible) return;
- const px = x(i);
- const py = scale.y(v);
- if (i === 0) context.moveTo(px, py);
- else {
- context.lineTo(px, scale.y(series.principal[i - 1]));
- context.lineTo(px, py);
- }
- });
- context.stroke();
- context.restore();
-
+ ctx.strokeStyle = "#bae6ff";
+ ctx.lineJoin = "round";
+ ctx.lineWidth = 1.8;
+ ctx.stroke();
+ if (n === 1) {
+ ctx.beginPath();
+ ctx.arc(x(0), y(series.cr[0]), 4, 0, Math.PI * 2);
+ ctx.fillStyle = "#bae6ff";
+ ctx.fill();
+ }
series.markers.forEach((m) => {
- if (m.index >= visible) return;
- const mx = x(m.index);
- context.strokeStyle = inkAlpha(0.28);
- context.beginPath();
- context.moveTo(mx, padding.top);
- context.lineTo(mx, height - padding.bottom);
- context.stroke();
- context.fillStyle = inkAlpha(0.7);
- context.font = `500 11.5px ${FONT}`;
- context.textAlign = mx > width * 0.8 ? "right" : "left";
- context.fillText(
- `${signedWon(m.amount)} 적립`,
- mx + (mx > width * 0.8 ? -5 : 5),
- padding.top - 8,
- );
- context.font = `400 11.5px ${FONT}`;
+ const mx = x(m.index),
+ my = y(series.cr[m.index], m.index);
+ ctx.beginPath();
+ ctx.moveTo(mx, my - 5);
+ ctx.lineTo(mx + 5, my);
+ ctx.lineTo(mx, my + 5);
+ ctx.lineTo(mx - 5, my);
+ ctx.closePath();
+ ctx.fillStyle = "#ffffff";
+ ctx.fill();
});
-
- context.strokeStyle = COLORS.ink;
- context.lineWidth = 1.6;
- context.lineJoin = "round";
- context.beginPath();
- series.value.forEach((v, i) => {
- if (i >= visible) return;
- const px = x(i);
- const py = scale.y(v);
- if (i === 0) context.moveTo(px, py);
- else context.lineTo(px, py);
+ const chosen = chart.selected ?? n - 1;
+ const cx = x(chosen),
+ cy = y(series.cr[chosen], chosen);
+ polygon(
+ [
+ [cx, cy],
+ [cx + dx, cy - dy],
+ [cx + dx, floor - dy],
+ [cx, floor],
+ ],
+ "rgba(255,255,255,.08)",
+ );
+ ctx.beginPath();
+ ctx.moveTo(cx, floor);
+ ctx.lineTo(cx, cy);
+ ctx.lineTo(cx + dx, cy - dy);
+ ctx.strokeStyle = "#ffffff";
+ ctx.lineWidth = 1;
+ ctx.stroke();
+ ctx.beginPath();
+ ctx.arc(cx, cy, 4, 0, Math.PI * 2);
+ ctx.fillStyle = "#ffffff";
+ ctx.fill();
+ ctx.strokeStyle = "#002d9c";
+ ctx.lineWidth = 2;
+ ctx.stroke();
+ ctx.fillStyle = "#ffffff";
+ ctx.textAlign = chosen > n * 0.75 ? "right" : "left";
+ ctx.fillText(
+ pct(series.cr[chosen]),
+ cx + (chosen > n * 0.75 ? -9 : 9),
+ cy - 15,
+ );
+ ctx.fillStyle = "#d0e2ff";
+ ctx.textAlign = "left";
+ ctx.fillText(fmtMD(series.rows[0].date), left, floor + 22);
+ ctx.textAlign = "right";
+ ctx.fillText(fmtMD(series.rows[n - 1].date), x(n - 1) + dx, floor + 22);
+ if ($("historyDetails").open) drawDrawdown(series);
+ }
+
+ function scheduleChartPaint() {
+ if (chart.paintFrame != null) return;
+ chart.paintFrame = requestAnimationFrame(() => {
+ chart.paintFrame = null;
+ drawChart();
});
- context.stroke();
- if (n <= 14) {
- series.value.forEach((v, i) => {
- if (i >= visible) return;
- context.beginPath();
- context.arc(x(i), scale.y(v), 2.5, 0, Math.PI * 2);
- context.fillStyle = COLORS.ink;
- context.fill();
- });
- }
- context.restore();
+ }
- if (chart.hover != null && chart.progress >= 1) {
- const i = chart.hover;
- const px = x(i);
- const py = scale.y(series.value[i]);
- context.save();
- context.setLineDash([2, 3]);
- context.strokeStyle = inkAlpha(0.5);
- context.beginPath();
- context.moveTo(px, padding.top);
- context.lineTo(px, height - padding.bottom);
- context.stroke();
- context.restore();
- context.beginPath();
- context.arc(px, py, 4.5, 0, Math.PI * 2);
- context.fillStyle = COLORS.ink;
- context.fill();
- context.strokeStyle = "#ffffff";
- context.lineWidth = 2;
- context.stroke();
+ function setChartView(view) {
+ chart.view = view;
+ $("performance").dataset.view = view;
+ $("chartView")
+ .querySelectorAll("button")
+ .forEach((b) =>
+ b.setAttribute("aria-pressed", String(b.dataset.view === view)),
+ );
+ if (chart.frame != null) cancelAnimationFrame(chart.frame);
+ const from = chart.perspective,
+ to = view === "ribbon" ? 1 : 0;
+ if (reducedMotion.matches || document.hidden) {
+ chart.frame = null;
+ chart.perspective = to;
+ drawChart();
+ return;
}
- drawDrawdown(series);
+ const began = performance.now();
+ const step = (now) => {
+ const t = clamp((now - began) / 360, 0, 1);
+ chart.perspective = from + (to - from) * (1 - Math.pow(1 - t, 3));
+ drawChart();
+ chart.frame = t < 1 ? requestAnimationFrame(step) : null;
+ };
+ chart.frame = requestAnimationFrame(step);
+ }
+
+ function selectHistory(index) {
+ const s = chart.series;
+ if (!s?.rows.length) return;
+ const i = clamp(index, 0, s.rows.length - 1);
+ chart.selected = i;
+ chart.hover = i;
+ const row = s.rows[i];
+ $("historyDate").textContent = fmtLong(row.date);
+ $("historyDate").dateTime = String(row.date).slice(0, 10);
+ $("historyTag").textContent =
+ i === s.rows.length - 1 ? "최근 기록" : "선택한 날짜";
+ $("historyReturn").textContent = pct(s.cr[i]);
+ $("historyReturn").className = `readout-return ${tone(s.cr[i])}`;
+ $("historyValue").textContent = won(s.value[i]);
+ $("historyPrincipal").textContent = won(s.principal[i]);
+ $("historyDrawdown").textContent = pct(s.dd[i] * 100);
+ const amount = s.markers
+ .filter((m) => m.index === i)
+ .reduce((sum, m) => sum + m.amount, 0);
+ $("historyEvent").textContent = amount
+ ? `적립금 ${signedWon(amount)} 기록`
+ : i === s.rows.length - 1
+ ? "날짜 눈금을 움직여 기록을 살펴보세요."
+ : "이 날짜의 계좌 기록을 보고 있습니다.";
+ const cursor = $("historyCursor");
+ cursor.value = String(i);
+ cursor.setAttribute(
+ "aria-valuetext",
+ `${fmtLong(row.date)}, 수익률 ${pct(s.cr[i])}, 평가금액 ${won(s.value[i])}`,
+ );
+ $("historyPosition").textContent = `${i + 1} / ${s.rows.length}개 기록`;
+ scheduleChartPaint();
+ }
+
+ function exportHistory() {
+ const s = chart.series;
+ if (!s?.rows.length || state.chartDataAccount !== state.chartAccount)
+ return;
+ const csv = [
+ "날짜,평가금액(원),투자원금(원),누적시간가중수익률(%),고점대비하락률(%)",
+ ...s.rows.map((r, i) =>
+ [
+ String(r.date).slice(0, 10),
+ s.value[i],
+ s.principal[i],
+ s.cr[i],
+ (s.dd[i] * 100).toFixed(6),
+ ].join(","),
+ ),
+ ].join("\r\n");
+ const url = URL.createObjectURL(
+ new Blob(["\ufeff", csv], { type: "text/csv;charset=utf-8" }),
+ );
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = `nungum-${state.chartAccount.replace(/[^a-z0-9_-]/gi, "-")}-${String(s.rows.at(-1).date).slice(0, 10)}.csv`;
+ a.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function drawDrawdown(series) {
@@ -1248,37 +1450,20 @@
}
}
- function hideChartTip() {
- if (chart.hover == null) return;
- chart.hover = null;
- el.chartTip.hidden = true;
- drawChart();
- }
function handleChartPointer(event) {
- const series = chart.series;
const g = chart.geometry;
- if (!series || !g || chart.progress < 1) return;
- const bounds = event.currentTarget.getBoundingClientRect();
- const px = event.clientX - bounds.left;
- const plotWidth = g.width - g.padding.left - g.padding.right;
- const index = clamp(
- Math.round(((px - g.padding.left) / Math.max(1, plotWidth)) * (g.n - 1)),
+ if (!chart.series || !g || $("performance").dataset.loading === "true")
+ return;
+ const px = event.clientX - event.currentTarget.getBoundingClientRect().left;
+ const i = clamp(
+ Math.round(
+ ((px - g.padding.left) / (g.width - g.padding.left - g.padding.right)) *
+ Math.max(1, g.n - 1),
+ ),
0,
g.n - 1,
);
- if (index === chart.hover) return;
- chart.hover = index;
- const row = series.rows[index];
- const tip = el.chartTip;
- tip.querySelector("time").textContent = fmtLong(row.date);
- tip.querySelector("strong").textContent = won(row.total_value);
- const twr = tip.querySelector("span");
- twr.textContent = `투자원금 ${won(series.principal[index])} · 시간가중 ${pct(row.cumulative_return)} · 낙폭 ${pct(series.dd[index] * 100, { digits: 1 })}`;
- twr.className = tone(row.cumulative_return);
- const tipX = clamp(g.x(index), 110, g.width - 110);
- tip.style.transform = `translate(${tipX}px, ${g.scale.y(series.value[index])}px) translate(-50%, calc(-100% - 12px))`;
- tip.hidden = false;
- drawChart();
+ if (chart.selected !== i) selectHistory(i);
}
function updateChart(snapshots) {
@@ -1299,15 +1484,30 @@
series.dd = series.rows.map(
(row, i) => historicalDD.get(row.date) ?? series.dd[i],
);
+ const wasLatest =
+ chart.selected == null ||
+ chart.selected === chart.series?.rows.length - 1;
+ const previousDate = chart.series?.rows[chart.selected]?.date;
+ const sameAccount = state.chartDataAccount === state.chartAccount;
chart.series = series;
+ state.chartDataAccount = state.chartAccount;
state.chartRows = series.rows;
chart.hover = null;
el.chartTip.hidden = true;
renderChartMaturity();
+ const cursor = $("historyCursor");
+ cursor.max = String(Math.max(0, series.rows.length - 1));
+ cursor.disabled = !series.rows.length;
+ $("exportHistory").disabled = !series.rows.length;
+ $("historyLatest").disabled = !series.rows.length;
+ $("historyStart").textContent = fmtMD(series.rows[0]?.date);
+ $("historyEnd").textContent = fmtMD(series.rows.at(-1)?.date);
if (!series.rows.length) {
el.chartWrap.hidden = true;
el.chartEmpty.hidden = false;
+ el.chartEmpty.innerHTML =
+ "선택한 기간에 기록이 없습니다.조회 기간을 넓히거나 모의투자 기록을 확인해 주세요.";
el.chartSummary.textContent = "선택한 기간에 자산 기록이 없습니다.";
el.chartRows.innerHTML = "";
el.monthStrip.innerHTML = "";
@@ -1323,6 +1523,11 @@
el.chartEmpty.hidden = true;
renderChartTable(series);
renderMonthStrip(series, history);
+ const previousIndex =
+ sameAccount && !wasLatest
+ ? series.rows.findIndex((r) => r.date === previousDate)
+ : -1;
+ selectHistory(previousIndex >= 0 ? previousIndex : series.rows.length - 1);
drawChart();
}
@@ -1331,19 +1536,61 @@
state.chartAccount = el.chartAccount.value;
updateOverview();
syncChartQuery();
+ const request = ++state.chartRequest;
+ const account = state.chartAccount,
+ days = state.chartDays;
+ $("performance").dataset.loading = "true";
+ $("performance").setAttribute("aria-busy", "true");
+ $("exportHistory").disabled = true;
+ $("historyCursor").disabled = true;
+ // 계좌가 바뀌면 이전 숫자를 남기지 않는다. 오래 걸린 이전 응답도 폐기한다.
+ if (state.chartDataAccount !== account) {
+ el.chartWrap.hidden = true;
+ el.chartEmpty.hidden = false;
+ el.chartEmpty.innerHTML =
+ "선택한 계좌를 불러오는 중입니다.날짜별 성과 기록을 확인하고 있습니다.";
+ }
try {
- const data = await fetchJson(
- `/api/snapshots?days=${state.chartDays}&account_key=${encodeURIComponent(state.chartAccount || "")}`,
- { timeout: 15_000, key: "chart" },
- );
- updateChart((data && data.snapshots) || []);
+ const basket = chartBasket();
+ if (
+ basket &&
+ (state.seriesStatus.get(account) !== "ready" ||
+ state.flowStatus.get(basket.basket) !== "ready")
+ )
+ throw new Error(
+ "수익률 계산에 필요한 전체 기록을 확인하지 못했습니다.",
+ );
+ const cached = state.series.get(account);
+ // 전체 기록은 갱신 주기에 한 번 읽는다. 기간·계좌 변경 때 같은 데이터를 다시 받지 않는다.
+ const cutoff = new Date();
+ cutoff.setDate(cutoff.getDate() - days);
+ cutoff.setHours(0, 0, 0, 0);
+ const data = cached
+ ? { snapshots: cached.filter((row) => parseDate(row.date) >= cutoff) }
+ : await fetchJson(
+ `/api/snapshots?days=${days}&account_key=${encodeURIComponent(account || "")}`,
+ { timeout: 15_000, key: "chart" },
+ );
+ if (
+ request !== state.chartRequest ||
+ account !== state.chartAccount ||
+ days !== state.chartDays
+ )
+ return;
+ updateChart(data?.snapshots || []);
} catch {
+ if (request !== state.chartRequest) return;
el.chartSummary.textContent =
- "성과 기록을 불러오지 못했습니다. 연결을 확인한 뒤 다시 시도하세요.";
+ "성과 기록을 읽지 못했습니다. 운영 영역의 다시 확인을 눌러주세요.";
el.chartWrap.hidden = true;
el.chartEmpty.hidden = false;
el.chartEmpty.innerHTML =
- "성과 기록을 읽지 못했습니다.계좌 기록은 바뀌지 않았습니다. 잠시 후 다시 확인하세요.";
+ "성과 기록을 읽지 못했습니다.계좌 기록은 바뀌지 않았습니다. 연결 상태를 확인해 주세요.";
+ } finally {
+ if (request === state.chartRequest) {
+ $("performance").dataset.loading = "false";
+ $("performance").setAttribute("aria-busy", "false");
+ }
}
}
@@ -1864,7 +2111,8 @@
'포트폴리오를 불러오지 못했습니다.이전 화면의 숫자는 최신이 아닐 수 있습니다.
';
} else if (
results.some((r) => r.status === "rejected") ||
- state.flowError
+ state.flowError ||
+ [...state.seriesStatus.values()].some((status) => status === "error")
) {
state.coreStatus = "partial";
} else {
@@ -1968,6 +2216,9 @@
$("depositCancelButton").addEventListener("click", closeDeposit);
el.depositBack.addEventListener("click", showDepositFields);
$("retryButton").addEventListener("click", () => refreshAll(true));
+ $("priorityNotice").addEventListener("click", () =>
+ el.decisionAction.click(),
+ );
el.decisionAction.addEventListener("click", () => {
const action = el.decisionAction.dataset.action;
@@ -1995,7 +2246,23 @@
});
el.chartEquity.addEventListener("pointermove", handleChartPointer);
el.chartEquity.addEventListener("pointerdown", handleChartPointer);
- el.chartEquity.addEventListener("pointerleave", hideChartTip);
+ $("historyCursor").addEventListener("input", (event) =>
+ selectHistory(Number(event.target.value)),
+ );
+ $("historyLatest").addEventListener("click", () =>
+ selectHistory((chart.series?.rows.length || 1) - 1),
+ );
+ $("exportHistory").addEventListener("click", exportHistory);
+ $("historyDetails").addEventListener("toggle", () => {
+ if ($("historyDetails").open && chart.series) drawDrawdown(chart.series);
+ });
+ $("chartView").addEventListener("click", (event) => {
+ const button = event.target.closest("button[data-view]");
+ if (button) setChartView(button.dataset.view);
+ });
+ reducedMotion.addEventListener("change", () => {
+ if (reducedMotion.matches) setChartView(chart.view);
+ });
document.querySelectorAll("[data-amount]").forEach((button) => {
button.addEventListener("click", () => {
@@ -2059,7 +2326,7 @@
wireEvents();
initScrollSpy();
if ("ResizeObserver" in window) {
- new ResizeObserver(() => drawChart()).observe(el.chartBox);
+ new ResizeObserver(scheduleChartPaint).observe(el.chartBox);
}
document
.querySelectorAll("#chartRange button")
@@ -2069,6 +2336,7 @@
String(Number(b.dataset.days) === state.chartDays),
),
);
+ document.fonts?.ready.then(scheduleChartPaint);
await refreshAll(true);
window.setInterval(() => refreshCore(), 30_000);
window.setInterval(() => refreshSlow(), 60_000);
diff --git a/monitoring/static/nungum-symbol.svg b/monitoring/static/nungum-symbol.svg
index a1fb743..bd42653 100644
--- a/monitoring/static/nungum-symbol.svg
+++ b/monitoring/static/nungum-symbol.svg
@@ -1,6 +1,6 @@
diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html
index 5d4714e..cb13e4f 100644
--- a/monitoring/templates/dashboard.html
+++ b/monitoring/templates/dashboard.html
@@ -6,7 +6,7 @@
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
-
+
눈금 | 투자 현황
본문으로 건너뛰기
-
-
-