` readiness gate와 KIS↔DB 동기화를 통과한 뒤에만 주문 계획과 실행으로 넘어간다. live 신규 진입 중 `requires_reconcile` 또는 `order_pending` 결과가 나오면 남은 BUY 후보와 같은 루프 재스캔을 중단하고, 다음 성공적인 KIS↔DB 동기화 전까지 미확정 체결분을 무시한 추가 진입을 막는다. **전략 레지스트리** 기반 `_get_strategy()`. |
diff --git a/docs/SAFETY_MODEL.md b/docs/SAFETY_MODEL.md
new file mode 100644
index 00000000..64181b10
--- /dev/null
+++ b/docs/SAFETY_MODEL.md
@@ -0,0 +1,145 @@
+# 안전 모델과 실전 운영 경계
+
+## 결론부터
+
+이 프로그램은 **원금 보전, 무손실, 수익 또는 주문 체결을 보장하지 않는다.** 안전장치는
+실수·중복 주문·장부 오염을 줄이고 손실 한도를 지키기 위한 방어선이지, 시장 위험을 없애는
+수단이 아니다. 갭, 급격한 변동, 거래정지, VI, 유동성 고갈, 슬리피지, 증권사·네트워크 장애,
+전략 성능 저하 때문에 설정한 손절가보다 큰 손실이나 주문 미체결이 발생할 수 있다.
+
+**현재 실전 판정은 NO-GO다.** 운영자는 최신 코드와 설정으로 만든 검증 증거, paper 운영
+게이트, KIS 연결·잔고 동기화, 명시적 계좌 라우팅, clean Git worktree를 모두 통과하기 전에는
+실전 주문을 열면 안 된다. 게이트 통과는 미래 수익을 뜻하지 않으며, 통과 후에도 소액으로
+별도 승인해야 한다.
+
+## 기본 원칙
+
+- 조회 실패, NaN/무한대, 상태 불명, 검증 증거 불일치는 성공으로 간주하지 않고 주문을 막는다.
+- 신규 BUY는 노출·현금·종목 수·1회 손실 예산 등 위험 한도를 주문 후 예상 상태로 검사한다.
+- 손절과 긴급 청산 SELL은 신규 BUY 차단과 분리해 열어 두되, 실제 체결은 보장하지 않는다.
+- 긴급 SELL 직전 같은 종목의 취소 가능한 BUY를 KIS에서 조회하고, 가능 수량을 취소한 뒤
+ 재조회에서 사라진 것이 확인돼야 SELL을 제출한다. 부분 체결이나 조회·취소 불명은 HALT한다.
+- 실계좌 KIS 주문 함수의 직접 호출은 차단하고, 주문 실행기의 가드가 승인한 짧은 구간에서만
+ 제출한다.
+- `use_mock=true`라도 실효 URL이 공식 VTS(`openapivts`)가 아니면 실돈 가능 endpoint로 취급해
+ kill switch, 실계좌 리스크 프로필과 주문 capability를 모두 요구한다.
+- 실전 주문 응답을 잃었을 때는 같은 주문을 재전송하지 않는다. 중복 체결보다 운영 중단과
+ 수동 대조를 우선한다.
+
+## paper/live 장부 격리
+
+`Position`과 `PortfolioSnapshot`은 `mode`를 포함해 각각
+`(mode, account_key, symbol)`, `(mode, account_key, date)` 단위로 유일하다. 관련 저장소 조회와
+쓰기에도 `paper` 또는 `live` 모드를 전달하므로 같은 전략·종목이라도 두 장부가 서로 덮어쓰지
+않는다.
+
+기존 DB의 mode 없는 행은 거래 이력의 mode가 하나로 일관될 때만 그 mode로 귀속한다. 거래
+이력이 없거나 paper/live가 섞인 행은 `legacy`로 격리하며 신규 paper/live 조회에 포함하지
+않는다. 이 이전은 브로커 잔고가 맞다는 증명이 아니다. 마이그레이션 전 백업을 보존하고,
+legacy 행은 KIS 체결·잔고와 수동 대조한 뒤 별도 처리한다.
+
+## 실전 진입 조건
+
+실전은 설정 파일의 기본 계좌로 조용히 폴백하지 않는다. 실행할 전략 키를
+`config/settings.yaml`의 `kis_api.accounts`에 먼저 선언하고 계좌번호는 `.env`로 주입한다.
+
+```yaml
+kis_api:
+ accounts:
+ scoring: "" # 실제 번호는 저장소에 커밋하지 않는다
+```
+
+```dotenv
+KIS_ACCOUNT_NO_SCORING=12345678-01
+```
+
+키에 `:` 또는 `-`가 있으면 환경변수 이름에서는 `_`로 바뀐다. 예를 들어
+`basket_rebalance:kr_pocket`은 `KIS_ACCOUNT_NO_BASKET_REBALANCE_KR_POCKET`이다. YAML에 키를
+선언하지 않은 `KIS_ACCOUNT_NO_*` 환경변수는 무시되며 경고가 남는다. 같은 계좌를 여러 전략이
+공유하려는 경우에도 각 전략 키에 의도적으로 같은 번호를 선언해야 한다.
+
+실전 명령은 다음 두 운영자 확인을 모두 요구하지만, 이것만으로 충분하지 않다.
+
+```powershell
+$env:ENABLE_LIVE_TRADING = "true"
+.venv\Scripts\python.exe main.py --mode live --strategy scoring --confirm-live
+```
+
+이후에도 다음 조건 중 하나라도 실패하면 진입하지 않는다.
+
+- 전략 레지스트리의 live 허용 상태와 최신 canonical 승격 증거
+- 증거의 commit/config hash, 최신성, paper 품질 및 blocker 판정
+- tracked/untracked 변경이 모두 없는 clean Git worktree; Git 상태 확인 실패도 차단
+- 단일 live 런타임 락 획득
+- KIS 인증·연결·잔고 조회 및 KIS↔DB 포지션 동기화
+- 전략별 `kis_api.accounts` 라우팅
+
+`--mode schedule`은 paper 전용이다. 게이트를 우회하는 `--force-live` 경로는 없다.
+
+## 실전 긴급 전량 청산
+
+실계좌를 대상으로 할 때는 `--liquidate-live`를 반드시 명시한다. 이 플래그가 없으면 CLI 청산은
+paper 장부를 대상으로 한다.
+
+```powershell
+$env:ENABLE_LIVE_TRADING = "true"
+.venv\Scripts\python.exe main.py --mode liquidate --liquidate-live --confirm-live
+```
+
+실전 청산은 브로커 동기화나 포지션 조회보다 먼저 영속 전역 HALT를 기록해 다른 프로세스의
+신규 BUY를 막는다. HALT는 청산 종료 후 자동 해제되지 않는다. KIS 체결·미체결·잔고와 DB를
+대조한 뒤에만 사유를 남겨 해제한다.
+
+```powershell
+.venv\Scripts\python.exe tools/clear_trading_halt.py --confirm --reason "KIS 체결·미체결·잔고 대조 완료"
+```
+
+손절, 트레일링 스탑, 갭다운, 블랙스완, 강제 청산 등 긴급 사유의 국내 주식 SELL은 KIS 시장가
+(`ORD_DVSN=01`, 가격 0)로 제출한다. 이는 오래 남는 지정가보다 체결 가능성을 우선하는 선택일
+뿐이다. 유동성이 부족하거나 거래가 정지되면 미체결·부분 체결될 수 있고, 급변장에서는 예상보다
+매우 불리한 가격에 체결될 수 있다. 시장가 주문도 손실 상한을 보장하지 않는다.
+
+긴급 SELL 전에 KIS `주식정정취소가능주문조회`로 같은 종목의 BUY 가능수량을 확인하고,
+`주식주문(정정취소)`로 그 수량을 취소한다. 취소 접수 응답만 믿지 않고 다시 조회해 BUY가
+사라졌을 때만 시장가 SELL로 넘어간다. 취소 응답 유실도 재전송하지 않고 재조회로 판정한다.
+기존 BUY가 일부라도 체결된 경우에는 DB 보유수량으로 매도 수량을 추측하지 않는다. 잔량 취소 후
+전역 HALT와 장기 주문 가드를 유지하고 KIS 잔고·체결·DB를 대조해야 한다.
+
+## 체결 불확실성과 장부 실패 대응
+
+다음 상태는 정상 완료가 아니라 **운영 사고 대응 상태**다.
+
+- 주문 응답 유실로 접수 여부를 모름
+- 부분 체결 또는 체결 수량·가격을 확정하지 못함
+- 브로커 체결 후 `TradeHistory`/`Position` 저장이 완료되지 않음
+
+이 경우 주문을 재전송하지 않고, 미완료 주문 기록과 종목 중복 주문 가드를 유지·연장한다.
+전역 HALT와 critical 알림을 남기고 장부 반영을 보류하거나 가능한 DB 기록을 되돌린다. 자동
+처리가 끝났다는 뜻이 아니며 다음 순서로 수동 복구해야 한다.
+
+1. live 프로세스와 신규 주문을 중지하고 HALT를 유지한다.
+2. KIS 주문·체결 내역, 미체결 수량, 현재 잔고를 주문번호 기준으로 확인한다.
+3. 동일한 `mode=live`와 `account_key`의 `OrderRecord`, `TradeHistory`, `Position`을 대조한다.
+4. 부분 체결 수량과 실제 평균 체결가를 확정해 승인된 reconcile 절차로 장부를 복구한다.
+5. 다시 잔고 동기화를 통과하고 미체결이 없음을 확인한 뒤에만 HALT를 명시적으로 해제한다.
+
+DB를 추측으로 직접 수정하거나, 응답이 없었다는 이유로 같은 주문을 다시 보내면 안 된다.
+
+## 알려진 한계
+
+- 긴급 국내주식 SELL과 충돌하는 BUY의 **취소**만 구현했다. 임의 주문 정정(replace), 해외주식
+ 자동 취소, 증권사 서버에 상주하는 native stop 주문은 구현하지 않았다. 손절은 프로그램이 실행
+ 중이고 시세·API가 정상일 때 감지해 주문하므로 프로세스 중단이나 통신 장애 사이의 손실을 막지
+ 못한다.
+- 취소 직전·직후에 체결이 경합할 수 있다. 부분 체결이나 취소 확인 실패는 자동으로 매도 수량을
+ 보정하지 않고 HALT하므로 KIS 체결·잔고와 DB를 수동 대조해야 한다.
+- 호출 제한기는 같은 Python 프로세스 안에서 동일한 KIS app key·도메인을 쓰는 인스턴스끼리
+ 초당/분당 예산을 공유한다. 여러 OS 프로세스·호스트 사이에는 공유되지 않는다.
+- 부분 체결분을 자동으로 최종 포지션에 합치는 완전한 broker reconciliation은 없다. HALT 후
+ 운영자 대조가 필요하다.
+- 중복 주문 가드는 사고 가능성을 낮추지만 브로커·DB·프로세스 전체를 아우르는 정확히 한 번
+ 체결(exactly-once)을 보장하지 않는다.
+- 백테스트와 paper 성과는 실전 성과가 아니다. 비용, 세금, 체결 지연, 시장 충격과 미래 시장
+ 구조 변화 때문에 결과가 달라질 수 있다.
+
+이 한계 중 하나라도 현재 운영 방식에 수용 불가능하면 실전은 계속 NO-GO다.
diff --git a/docs/images/dashboard-deposit.png b/docs/images/dashboard-deposit.png
new file mode 100644
index 00000000..36f5a7fa
Binary files /dev/null and b/docs/images/dashboard-deposit.png differ
diff --git a/docs/images/dashboard-operations.png b/docs/images/dashboard-operations.png
new file mode 100644
index 00000000..df083b50
Binary files /dev/null and b/docs/images/dashboard-operations.png differ
diff --git a/docs/images/dashboard-overview.png b/docs/images/dashboard-overview.png
new file mode 100644
index 00000000..fd9daa0d
Binary files /dev/null and b/docs/images/dashboard-overview.png differ
diff --git a/docs/images/dashboard-performance.png b/docs/images/dashboard-performance.png
new file mode 100644
index 00000000..70aa0504
Binary files /dev/null and b/docs/images/dashboard-performance.png differ
diff --git a/docs/images/dashboard-portfolio.png b/docs/images/dashboard-portfolio.png
new file mode 100644
index 00000000..6efc5219
Binary files /dev/null and b/docs/images/dashboard-portfolio.png differ
diff --git a/main.py b/main.py
index 502583c0..8f629d60 100644
--- a/main.py
+++ b/main.py
@@ -158,6 +158,7 @@ def run_backtest(args):
strategy_name=args.strategy,
strict_lookahead=args.strict_lookahead,
notify_overtrading=True,
+ symbol=args.symbol,
)
if not result:
@@ -653,6 +654,27 @@ def run_deploy_check(args) -> int:
def run_rebalance(args):
+ """Run a rebalance, serializing every order-capable live invocation."""
+ config = Config.get()
+ mode = str(config.trading.get("mode", "paper")).lower()
+ dry_run = bool(getattr(args, "dry_run", False))
+ if mode != "live" or dry_run:
+ return _run_rebalance_impl(args)
+
+ from core.runtime_lock import live_runtime_lock
+
+ project_root = Path(__file__).resolve().parent
+ with live_runtime_lock(project_root) as acquired:
+ if not acquired:
+ logger.error(
+ "실전 리밸런싱 중단: 다른 live 스케줄러/리밸런서가 "
+ "실행 중이거나 프로세스 락을 확인할 수 없습니다."
+ )
+ raise SystemExit(1)
+ return _run_rebalance_impl(args)
+
+
+def _run_rebalance_impl(args):
"""바스켓 포트폴리오 리밸런싱 모드."""
from datetime import datetime
from zoneinfo import ZoneInfo
@@ -660,6 +682,7 @@ def run_rebalance(args):
from core.notifier import Notifier
from core.cycle_observability import (
detect_snapshot_gaps_for_account,
+ unreported_snapshot_gaps,
format_gap_alert,
record_cycle_event,
)
@@ -670,6 +693,7 @@ def run_rebalance(args):
dry_run = getattr(args, "dry_run", False)
force_rebalance = getattr(args, "force_rebalance", False)
mode = str(config.trading.get("mode", "paper")).lower()
+ ledger_mode = "live" if mode == "live" else "paper"
live_rebalance_confirmed = False
if basket_name:
@@ -777,6 +801,42 @@ def run_rebalance(args):
except Exception as guard_exc:
logger.debug("바스켓 '{}' 당일 체결 판정 실패(가드 생략): {}", name, guard_exc)
+ # 리스크 청산(손절/익절/트레일링)을 리밸런싱보다 먼저 평가한다.
+ # 그동안 이 사이클은 비중 교정만 했고 손절/익절은 장중 스케줄러
+ # (core/scheduler.py)에만 있어서, 일 1회 리밸런싱으로만 굴러가는 바스켓
+ # 트랙은 손절선을 뚫어도 아무 일도 일어나지 않았다(2026-08-07 점검에서
+ # 9개 중 6개 포지션이 손절선 이탈 상태로 방치된 것을 확인).
+ #
+ # '1일 1매매 패스' 가드는 적용하지 않는다 — 그 가드는 회전율 상한 우회를
+ # 막으려는 것이고, 리스크 청산은 회전 예산이 아니라 손실 제한이다.
+ # 청산이 이미 끝났으면 포지션이 없어 재평가가 비어 자연히 멱등이다.
+ try:
+ # list로 좁힌다 — 평가 결과가 주문 목록이 아니면 청산을 시도하지 않는다
+ # (빈 목록과 '목록이 아닌 무언가'를 구분하지 않으면 유령 청산이 난다).
+ planned = rebalancer.plan_risk_exits()
+ exit_orders = list(planned) if isinstance(planned, (list, tuple)) else []
+ except Exception as exit_exc:
+ exit_orders = []
+ logger.error("바스켓 '{}' 리스크 청산 평가 실패: {}", name, exit_exc)
+ if exit_orders:
+ exit_result = rebalancer.execute(
+ exit_orders,
+ dry_run=dry_run,
+ live_confirmed=live_rebalance_confirmed,
+ )
+ exit_summary = (
+ f"🛡️ 바스켓 '{name}' 리스크 청산 {'(DRY RUN) ' if dry_run else ''}"
+ f"{exit_result['executed']}건 실행 / {exit_result['failed']}건 실패: "
+ + "; ".join(o.reason for o in exit_orders[:3])
+ )
+ logger.warning(exit_summary)
+ if not dry_run:
+ record_cycle_event(
+ "RISK_EXIT", exit_summary, severity="warning",
+ strategy=live_strategy_name, mode=mode,
+ )
+ notifier.send_message(exit_summary, critical=True)
+
executed = False
if already_traded_today:
logger.info(
@@ -858,6 +918,13 @@ def run_rebalance(args):
gaps = detect_snapshot_gaps_for_account(
config, live_strategy_name, now,
)
+ # 이미 알린 결측일은 거른다. 복구 불가능한 과거 결측은 매 사이클
+ # 다시 감지되므로, 거르지 않으면 같은 하루가 매일 경보를 울려
+ # 진짜 신호를 덮는다(8/18 결측 하나가 3주간 16건의 warning을 만든
+ # 것을 2026-08-26 점검에서 확인). 커버리지 집계는 별개로 전체를 본다.
+ gaps = unreported_snapshot_gaps(
+ live_strategy_name, gaps, mode=mode,
+ )
if gaps:
alert = format_gap_alert(name, gaps, today=now)
today_missing = now.date() in gaps
@@ -895,6 +962,7 @@ def run_rebalance(args):
from core.portfolio_manager import twr_period_return
snaps = get_portfolio_snapshots(
days=7, account_key=live_strategy_name,
+ mode=ledger_mode,
)
if snaps is not None and len(snaps) >= 2:
sdf = snaps.sort_values("date")
@@ -912,6 +980,7 @@ def run_rebalance(args):
try:
flow = get_cash_flow_total_between(
live_strategy_name, prev_boundary, datetime.now(),
+ mode=ledger_mode,
)
except Exception:
pass
@@ -1035,7 +1104,10 @@ def run_paper_trading(args):
max_holding_days = (config.risk_params.get("position_limits", {}) or {}).get("max_holding_days", 0)
if max_holding_days > 0:
today = datetime.now().date()
- for pos in get_all_positions(account_key=account_key if account_key else None):
+ for pos in get_all_positions(
+ account_key=account_key if account_key else None,
+ mode="paper",
+ ):
bought_at = getattr(pos, "bought_at", None)
if not bought_at:
continue
@@ -1089,7 +1161,11 @@ def run_paper_trading(args):
except Exception:
pass
- if signal_info["signal"] == "BUY" and not get_position(symbol, account_key=account_key):
+ if signal_info["signal"] == "BUY" and not get_position(
+ symbol,
+ account_key=account_key,
+ mode="paper",
+ ):
from core.market_regime import check_market_regime
regime_result = check_market_regime(config, collector)
if not regime_result["allow_buys"]:
@@ -1117,7 +1193,11 @@ def run_paper_trading(args):
)
if order_result.get("success"):
discord.send_trade_alert(order_result)
- elif signal_info["signal"] == "SELL" and get_position(symbol, account_key=account_key):
+ elif signal_info["signal"] == "SELL" and get_position(
+ symbol,
+ account_key=account_key,
+ mode="paper",
+ ):
avg_vol = float(df["volume"].rolling(20, min_periods=1).mean().iloc[-1]) if "volume" in df.columns and not df["volume"].empty else None
order_result = executor.execute_sell(
symbol=symbol,
@@ -1168,6 +1248,21 @@ def _check_live_readiness_gate(config, strategy_name: str) -> list[str]:
def run_live_trading(args):
+ """Run the canonical live workflow under the global live runtime lock."""
+ from core.runtime_lock import live_runtime_lock
+
+ project_root = Path(__file__).resolve().parent
+ with live_runtime_lock(project_root) as acquired:
+ if not acquired:
+ logger.error(
+ "실전 모드 진입 중단: 다른 live 스케줄러/리밸런서가 "
+ "실행 중이거나 프로세스 락을 확인할 수 없습니다."
+ )
+ raise SystemExit(1)
+ return _run_live_trading_impl(args)
+
+
+def _run_live_trading_impl(args):
"""
실전 매매 모드 실행.
4중 확인: 전략 코드 등록 → 환경변수 → --confirm-live → canonical live gate.
@@ -1219,9 +1314,23 @@ def run_live_trading(args):
config.enforce_live_auto_entry_policy()
try:
+ # live에서는 승인 단위(전략)와 실계좌 라우팅이 1:1로
+ # 명시되어야 한다. 기본 계좌 폴백은 장부별 노출 한도를 깨므로
+ # 모드 플립 직후, 인증/잔고 조회 전에 친절한 오류로 종료한다.
+ try:
+ live_account_no = config.get_account_no(strategy_name)
+ except ValueError as exc:
+ logger.error("🚫 실전 계좌 라우팅 검증 실패: {}", exc)
+ logger.error(
+ "config/settings.yaml의 kis_api.accounts에 '{}'의 실계좌를 "
+ "명시한 뒤 다시 시도하세요.",
+ strategy_name,
+ )
+ sys.exit(1)
+
# 토큰 사전 발급 (필수 환경변수 미설정 시 명확히 종료)
from api.kis_api import KISApi
- kis = KISApi()
+ kis = KISApi(account_no=live_account_no)
if not kis.authenticate():
logger.error(
"KIS API 인증 실패. 실전 모드를 사용하려면 "
@@ -1423,25 +1532,67 @@ def run_compare_paper_backtest(args):
def run_emergency_liquidate(args):
- """긴급 전 종목 매도 (CLI: --mode liquidate). 블랙스완 감지 외에도 수동 개입이 필요할 때 즉시 전 종목 매도."""
- logger.info("=" * 50)
- logger.info("🚨 긴급 전 종목 매도 모드")
- logger.info("=" * 50)
+ """긴급 청산 대상을 명시적으로 paper/live로 고정해 실행한다.
+ canonical live 실행은 YAML을 paper로 유지한 채 해당 프로세스 안에서만
+ mode를 live로 전환한다. 따라서 별도 청산 프로세스가 설정값만 보면 실제
+ 계좌가 아니라 paper 장부를 지울 수 있다. CLI에서는 ``--liquidate-live``를
+ 명시한 경우에만 live를 선택한다. 속성이 없는 기존 내부 호출은 하위
+ 호환을 위해 설정 mode를 따르되, argparse 경로에는 항상 속성이 존재한다.
+ """
config = Config.get()
- mode = str(config.trading.get("mode", "paper")).lower()
+ configured_mode = str(config.trading.get("mode", "paper")).lower()
+ explicit_live = getattr(args, "liquidate_live", None)
+ mode = configured_mode if explicit_live is None else ("live" if explicit_live else "paper")
+
if mode == "live":
_require_live_operator_confirmation(
args,
action_label="실전 긴급 청산",
- example="python main.py --mode liquidate --confirm-live",
+ example=(
+ "python main.py --mode liquidate --liquidate-live --confirm-live"
+ ),
+ )
+
+ old_mode = configured_mode
+ config.trading["mode"] = mode
+ try:
+ return _run_emergency_liquidate_impl(args, config=config, mode=mode)
+ finally:
+ config.trading["mode"] = old_mode
+
+
+def _run_emergency_liquidate_impl(args, *, config, mode: str):
+ """선택된 장부와 브로커에서 전 종목을 청산한다."""
+ logger.info("=" * 50)
+ logger.info("🚨 긴급 전 종목 매도 모드 ({})", mode)
+ logger.info("=" * 50)
+
+ if mode == "live":
+ # 운영자 확인 직후, broker sync나 포지션 조회보다 먼저 전역
+ # 영속 HALT를 남긴다. 청산 중/후 다른 프로세스가 신규 BUY를
+ # 시도해도 OrderExecutor가 동일 DB 상태를 읽고 fail-closed한다.
+ from database.repositories import set_trading_halt
+
+ halt_state = set_trading_halt(
+ "운영자 확인된 live 긴급 전량 청산 시작",
+ source="main.run_emergency_liquidate",
+ mode="live",
+ detail={
+ "action": "emergency_liquidate",
+ "confirm_live": bool(getattr(args, "confirm_live", False)),
+ },
+ )
+ logger.critical(
+ "전역 거래 HALT 영속화 완료 (event_id={}) — 명시적 운영자 해제 전까지 BUY 차단",
+ halt_state.get("event_id"),
)
_sync_live_positions_before_liquidation(config)
from database.repositories import get_all_positions
from core.order_executor import OrderExecutor
- positions = get_all_positions() # 긴급 청산은 모든 계좌 포지션 대상
+ positions = get_all_positions(mode=mode) # 선택한 장부의 모든 계좌 포지션 대상
summary = {
"attempted": len(positions),
"succeeded": 0,
@@ -1467,26 +1618,26 @@ def run_emergency_liquidate(args):
from api.kis_api import KISApi
account_no = config.get_account_no(ak)
kis = KISApi(account_no=account_no)
- price_info = kis.get_current_price(pos.symbol)
try:
- price = float((price_info or {}).get("price") or 0)
- except (TypeError, ValueError):
- price = 0.0
- if price <= 0:
- reason = "실전 긴급 청산 현재가 조회 실패"
- summary["failed"] += 1
- summary["details"].append({
- "symbol": pos.symbol,
- "account_key": ak,
- "status": "failed",
- "reason": reason,
- })
- logger.error(
- "{}: {} — 평균단가 fallback 매도를 실행하지 않습니다.",
- reason,
+ price_info = kis.get_current_price(pos.symbol)
+ current_price = float((price_info or {}).get("price") or 0)
+ if current_price > 0:
+ price = current_price
+ else:
+ logger.critical(
+ "실전 긴급 청산 현재가 누락: {} — 평균단가 {:,.0f}원은 "
+ "체결 참조가로만 쓰고 broker 시장가 매도를 시도합니다.",
+ pos.symbol,
+ price,
+ )
+ except Exception as exc:
+ logger.critical(
+ "실전 긴급 청산 현재가 조회 예외: {} ({}) — 평균단가 "
+ "{:,.0f}원을 체결 참조가로 broker 시장가 매도를 시도합니다.",
pos.symbol,
+ exc,
+ price,
)
- continue
result = executor.execute_sell(
pos.symbol,
price,
@@ -1732,12 +1883,15 @@ def _design_fraction(cfg: dict) -> float:
key = rebalance_live_strategy_id(name)
snap = (
session.query(PortfolioSnapshot)
- .filter(PortfolioSnapshot.account_key == key)
+ .filter(
+ PortfolioSnapshot.mode == "paper",
+ PortfolioSnapshot.account_key == key,
+ )
.order_by(PortfolioSnapshot.date.desc())
.first()
)
last_dates.append(snap.date if snap else None)
- positions_b = get_all_positions(account_key=key) or []
+ positions_b = get_all_positions(account_key=key, mode="paper") or []
position_count += len(positions_b)
# 배치율은 부가 신호 — 계산 실패(예: baskets.yaml에 float 불가한
# target_stock_weight 오타)가 핵심 신호인 결측/staleness 감지를
@@ -1781,6 +1935,48 @@ def _design_fraction(cfg: dict) -> float:
None if (not last_dates or any(d is None for d in last_dates))
else min(last_dates)
)
+ # 적립 계획 이행 점검 — 적립식 트랙은 입금이 멈추면 '주문 실패 0건'인 채로
+ # 설계가 굴러가지 않는다(잔고가 1주 단위를 못 넘겨 배치율이 수렴 불가).
+ contribution_notes: list[str] = []
+ try:
+ from core.operator_health import summarize_contribution_plan
+ from database.repositories import get_cash_flows
+
+ for name in enabled_baskets:
+ cfg_b = baskets_cfg.get(name) or {}
+ plan = cfg_b.get("contribution_plan")
+ if not plan:
+ continue
+ key = _rebalance_live_strategy_id(name)
+ flows = get_cash_flows(account_key=key, mode="paper")
+ last_flow = max(
+ (getattr(f, "occurred_at", None) for f in flows if
+ getattr(f, "occurred_at", None) is not None), default=None,
+ )
+ # 트랙 개시일 = 이 계정의 첫 스냅샷. 개시 직후에는 아직 적립 시점이
+ # 오지 않았을 수 있으므로 판정에 필요하다.
+ sess = get_session()
+ try:
+ row = (
+ sess.query(PortfolioSnapshot.date)
+ .filter(
+ PortfolioSnapshot.mode == "paper",
+ PortfolioSnapshot.account_key == key,
+ )
+ .order_by(PortfolioSnapshot.date.asc())
+ .first()
+ )
+ finally:
+ sess.close()
+ first_snap = row[0] if row else None
+ plan_state = summarize_contribution_plan(
+ name, plan, last_flow, first_snap, date.today(),
+ )
+ if plan_state["note"]:
+ contribution_notes.append(plan_state["note"])
+ except Exception as plan_exc:
+ logger.debug("적립 계획 점검 생략: {}", plan_exc)
+
basket_operation = {
"enabled_baskets": enabled_baskets,
"last_snapshot_date": oldest_last,
@@ -1789,6 +1985,7 @@ def _design_fraction(cfg: dict) -> float:
"deployment_ratio": worst_dep_ratio,
"design_fraction": worst_design,
"deployment_tolerance": worst_tolerance,
+ "contribution_notes": contribution_notes,
}
except Exception as exc:
logger.warning("바스켓 운영 상태 조회 실패: {}", exc)
@@ -1819,6 +2016,28 @@ def _design_fraction(cfg: dict) -> float:
return {"OK": 0, "ATTENTION": 1, "BLOCKED": 2}.get(health["verdict"], 1)
+def _fetch_benchmark_closes(start, end) -> dict:
+ """벤치마크(KS11) 종가를 {date: close}로. 실패하면 빈 dict.
+
+ 일간 수익률이 아니라 **종가 레벨**을 준다 — 스냅샷이 빠진 날이 있으면 NAV 수익률은
+ 여러 날 구간이 되므로, 벤치마크도 같은 구간으로 다시 계산해야 비교가 성립한다
+ (core.performance_lens.aligned_returns 참고).
+ """
+ from datetime import timedelta
+
+ try:
+ import FinanceDataReader as fdr
+
+ s = (start.date() if hasattr(start, "date") else start) - timedelta(days=7)
+ e = end.date() if hasattr(end, "date") else end
+ df = fdr.DataReader("KS11", s.isoformat(), e.isoformat())
+ if df is None or df.empty or "Close" not in df.columns:
+ return {}
+ return {idx.date(): float(v) for idx, v in df["Close"].items()}
+ except Exception:
+ return {}
+
+
def run_weekly_report() -> int:
"""주간 요약 리포트 — 판단 주기(주 1회) 다이제스트를 Discord로 발송.
@@ -1858,7 +2077,10 @@ def _d(v):
try:
snaps = (
session.query(PortfolioSnapshot)
- .filter(PortfolioSnapshot.account_key == key)
+ .filter(
+ PortfolioSnapshot.mode == "paper",
+ PortfolioSnapshot.account_key == key,
+ )
.order_by(PortfolioSnapshot.date.asc())
.all()
)
@@ -1897,7 +2119,12 @@ def _d(v):
try:
ref_boundary = ref.created_at or ref.date
last_boundary = snaps[-1].created_at or now_kst
- flow = get_cash_flow_total_between(key, ref_boundary, last_boundary)
+ flow = get_cash_flow_total_between(
+ key,
+ ref_boundary,
+ last_boundary,
+ mode="paper",
+ )
except Exception:
pass
week_change = twr_period_return(ref_val, last_val, flow) * 100
@@ -1911,10 +2138,40 @@ def _d(v):
except Exception:
missing_days = 0
+ # 국면 분해 + 리스크 지표 — 수익률 한 숫자로는 '방어의 대가'가 안 보인다
+ # (docs/OPERATING_PRINCIPLES.md 원칙 9). 스냅샷의 daily_return과 같은 날의
+ # 벤치마크 일간 수익률을 짝지어 상승/하락 국면을 나눠 잰다.
+ regime = risk = None
+ try:
+ from core.performance_lens import (
+ aligned_returns, daily_returns_from_nav, risk_metrics,
+ split_by_regime,
+ )
+
+ # 스냅샷의 daily_return 열이 아니라 NAV 시계열에서 직접 뽑는다 —
+ # 그 열은 2026-08-10 이전 전 구간이 0.0이라(값을 안 넘기던 버그)
+ # 그대로 쓰면 변동성이 0으로 깔려 없는 안정성을 주장하게 된다.
+ nav_points = [(s.date, s.total_value) for s in snaps]
+ daily = daily_returns_from_nav(nav_points)
+ risk = risk_metrics([r for _, r in daily])
+ if len(nav_points) >= 2:
+ closes = _fetch_benchmark_closes(
+ nav_points[0][0], nav_points[-1][0],
+ )
+ if closes:
+ pairs = [
+ (m, b) for _d, m, b in aligned_returns(nav_points, closes)
+ ]
+ if pairs:
+ regime = split_by_regime(pairs)
+ except Exception as lens_exc:
+ logger.debug("국면/리스크 지표 생략: {}", lens_exc)
+
summary = build_weekly_summary(
basket_name=basket_name, eval_result=eval_result,
week_nav_change_pct=week_change,
missing_days=missing_days, cycle_errors=cycle_errors,
+ regime=regime, risk=risk,
)
logger.info("\n{}", summary["text"])
try:
@@ -2100,6 +2357,14 @@ def main():
"--confirm-live", action="store_true",
help="실전 모드 진입 시 필수. 미지정 시 live 모드 진입 거부.",
)
+ parser.add_argument(
+ "--liquidate-live",
+ action="store_true",
+ help=(
+ "[liquidate 모드] 실계좌 청산 대상을 명시. "
+ "ENABLE_LIVE_TRADING=true 및 --confirm-live도 함께 필요"
+ ),
+ )
# --force-live 제거됨 (감사 C-1 대응): hard gate는 우회 불가
parser.add_argument(
"--output-dir", type=str, default="reports",
diff --git a/monitoring/dashboard.py b/monitoring/dashboard.py
index 09e8edbc..37923b7a 100644
--- a/monitoring/dashboard.py
+++ b/monitoring/dashboard.py
@@ -23,6 +23,11 @@ class Dashboard:
def __init__(self, config: Config = None):
self.config = config or Config.get()
+ self.ledger_mode = (
+ "live"
+ if str(self.config.trading.get("mode", "paper")).lower() == "live"
+ else "paper"
+ )
self.initial_capital = self.config.risk_params.get(
"position_sizing", {}
).get("initial_capital", 10000000)
@@ -77,7 +82,7 @@ def show_recent_snapshots(self, days: int = 7):
Args:
days: 조회 기간 (일)
"""
- snapshots = get_portfolio_snapshots(days)
+ snapshots = get_portfolio_snapshots(days, mode=self.ledger_mode)
if snapshots.empty:
print(" 스냅샷 데이터 없음")
diff --git a/monitoring/liquidate_trigger.py b/monitoring/liquidate_trigger.py
index 59481638..b6c1aa50 100644
--- a/monitoring/liquidate_trigger.py
+++ b/monitoring/liquidate_trigger.py
@@ -111,7 +111,11 @@ def _run_liquidate() -> tuple[bool, str]:
from main import run_emergency_liquidate
- args = Namespace(confirm_live=_env_truthy("LIQUIDATE_TRIGGER_CONFIRM_LIVE"))
+ confirmed_live = _env_truthy("LIQUIDATE_TRIGGER_CONFIRM_LIVE")
+ args = Namespace(
+ confirm_live=confirmed_live,
+ liquidate_live=confirmed_live,
+ )
try:
result = run_emergency_liquidate(args)
except SystemExit as exc:
diff --git a/monitoring/paper_monitor.py b/monitoring/paper_monitor.py
index 04d08e27..8900b521 100644
--- a/monitoring/paper_monitor.py
+++ b/monitoring/paper_monitor.py
@@ -245,6 +245,7 @@ def _get_snapshots(self, start: datetime, end: datetime) -> list:
session = get_session()
try:
q = session.query(PortfolioSnapshot).filter(
+ PortfolioSnapshot.mode == self.mode,
PortfolioSnapshot.date >= start,
PortfolioSnapshot.date <= end,
)
@@ -317,6 +318,7 @@ def check(self) -> dict:
try:
# 스냅샷 기간 확인
snapshots = session.query(PortfolioSnapshot).filter(
+ PortfolioSnapshot.mode == "paper",
PortfolioSnapshot.account_key == (self.account_key or ""),
).order_by(PortfolioSnapshot.date).all()
diff --git a/monitoring/static/dashboard.css b/monitoring/static/dashboard.css
new file mode 100644
index 00000000..1bd0ae3a
--- /dev/null
+++ b/monitoring/static/dashboard.css
@@ -0,0 +1,1711 @@
+:root {
+ color-scheme: light;
+ --paper: #f4f0e7;
+ --paper-deep: #e9e3d7;
+ --surface: #fffdf8;
+ --surface-muted: #f8f5ee;
+ --ink: #18201f;
+ --ink-soft: #34413e;
+ --muted: #5d6966;
+ --faint: #626d69;
+ --line: #d8d5cc;
+ --line-strong: #b9b7af;
+ --oxide: #b84a2a;
+ --oxide-dark: #8f351d;
+ --oxide-soft: #f3dfd6;
+ --positive: #2d6f5e;
+ --positive-soft: #dfece6;
+ --negative: #a52e43;
+ --negative-soft: #f4dfe3;
+ --warning: #8a5a14;
+ --warning-soft: #f2e6cf;
+ --info: #4c668f;
+ --info-soft: #e2e8f1;
+ --focus: #2f65bd;
+ --shadow-dialog: 0 24px 70px rgb(24 32 31 / 22%);
+ --radius-xs: 4px;
+ --radius-sm: 8px;
+ --radius-md: 12px;
+ --radius-lg: 20px;
+ --content-width: 1240px;
+ --font-sans: "IBM Plex Sans KR", Pretendard, "Segoe UI", system-ui, sans-serif;
+ --font-mono: "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+html {
+ scroll-behavior: smooth;
+ scrollbar-color: var(--line-strong) var(--paper);
+ background: var(--paper);
+}
+
+body {
+ margin: 0;
+ min-width: 320px;
+ min-height: 100dvh;
+ overflow-x: hidden;
+ background: var(--paper);
+ color: var(--ink);
+ font-family: var(--font-sans);
+ font-size: 16px;
+ line-height: 1.55;
+ letter-spacing: -0.012em;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -webkit-tap-highlight-color: rgb(184 74 42 / 14%);
+}
+
+button,
+select,
+input {
+ font: inherit;
+}
+
+button,
+a,
+select,
+summary {
+ touch-action: manipulation;
+}
+
+button,
+select,
+summary,
+a {
+ cursor: pointer;
+}
+
+button:focus-visible,
+select:focus-visible,
+input:focus-visible,
+summary:focus-visible,
+a:focus-visible,
+[tabindex="0"]:focus-visible {
+ outline: 3px solid var(--focus);
+ outline-offset: 3px;
+}
+
+[hidden] {
+ display: none !important;
+}
+
+.skip-link {
+ position: fixed;
+ top: 10px;
+ left: 10px;
+ z-index: 1000;
+ padding: 10px 14px;
+ transform: translateY(-160%);
+ background: var(--ink);
+ color: #fff;
+ border-radius: var(--radius-sm);
+ font-weight: 700;
+ transition: transform 160ms ease-out;
+}
+
+.skip-link:focus {
+ transform: translateY(0);
+}
+
+.site-header {
+ position: sticky;
+ top: 0;
+ z-index: 50;
+ border-bottom: 1px solid rgb(24 32 31 / 12%);
+ background: rgb(244 240 231 / 94%);
+ backdrop-filter: blur(16px) saturate(1.1);
+}
+
+.header-inner {
+ width: min(100%, var(--content-width));
+ min-height: 72px;
+ margin: 0 auto;
+ padding: 12px 24px;
+ display: grid;
+ grid-template-columns: minmax(180px, auto) 1fr auto;
+ align-items: center;
+ gap: 24px;
+}
+
+.brand {
+ display: inline-flex;
+ align-items: center;
+ gap: 11px;
+ min-height: 44px;
+ min-width: 0;
+ color: var(--ink);
+ text-decoration: none;
+}
+
+.brand img {
+ flex: 0 0 auto;
+}
+
+.brand-copy {
+ display: flex;
+ align-items: baseline;
+ gap: 8px;
+ min-width: 0;
+}
+
+.brand-copy strong {
+ font-size: 1.2rem;
+ font-weight: 700;
+ letter-spacing: -0.045em;
+}
+
+.brand-copy span {
+ color: var(--muted);
+ font-family: var(--font-mono);
+ font-size: 0.67rem;
+ font-weight: 600;
+ letter-spacing: 0.13em;
+}
+
+.header-state {
+ justify-self: end;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+
+.header-state time {
+ color: var(--faint);
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.sync-mark {
+ width: 9px;
+ height: 9px;
+ flex: 0 0 auto;
+ border: 1px solid currentColor;
+ background: transparent;
+}
+
+.sync-mark[data-state="ok"] {
+ color: var(--positive);
+ background: var(--positive);
+}
+
+.sync-mark[data-state="partial"] {
+ color: var(--warning);
+ background: var(--warning);
+}
+
+.sync-mark[data-state="error"] {
+ color: var(--negative);
+ background: var(--negative);
+}
+
+.sync-mark[data-state="loading"] {
+ color: var(--info);
+ animation: sync-blink 1.2s steps(2, jump-none) infinite;
+}
+
+@keyframes sync-blink {
+ 50% { opacity: 0.35; }
+}
+
+.header-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.mode-badge {
+ min-height: 32px;
+ display: inline-flex;
+ align-items: center;
+ padding: 5px 9px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-xs);
+ background: var(--surface);
+ color: var(--ink-soft);
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+ white-space: nowrap;
+}
+
+.mode-badge[data-mode="paper"] {
+ border-color: #9ab0a8;
+ background: var(--positive-soft);
+ color: #1f584a;
+}
+
+.mode-badge[data-mode="live"] {
+ border-color: #c9808c;
+ background: var(--negative-soft);
+ color: #7d1f31;
+}
+
+.button {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 9px 14px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ font-weight: 650;
+ line-height: 1;
+ transition: background-color 160ms ease-out, border-color 160ms ease-out, color 160ms ease-out, transform 120ms ease-out;
+}
+
+.button:active {
+ transform: translateY(1px);
+}
+
+.button:disabled {
+ cursor: not-allowed;
+ opacity: 0.48;
+}
+
+.button-primary {
+ border-color: var(--oxide);
+ background: var(--oxide);
+ color: #fff;
+}
+
+.button-primary:hover:not(:disabled) {
+ border-color: var(--oxide-dark);
+ background: var(--oxide-dark);
+}
+
+.button-secondary {
+ border-color: var(--line-strong);
+ background: var(--surface);
+ color: var(--ink);
+}
+
+.button-secondary:hover:not(:disabled) {
+ border-color: var(--ink-soft);
+ background: var(--surface-muted);
+}
+
+.page-shell {
+ width: min(100%, var(--content-width));
+ margin: 0 auto;
+ padding: 36px 24px 64px;
+}
+
+.decision-panel {
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(0, 1.15fr) minmax(430px, 0.85fr);
+ gap: 36px;
+ align-items: end;
+ min-height: 310px;
+ padding: 40px;
+ overflow: hidden;
+ border: 1px solid var(--ink);
+ border-radius: var(--radius-md);
+ background: var(--ink);
+ color: var(--paper);
+}
+
+.decision-panel::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 86px;
+ height: 8px;
+ background: var(--oxide);
+}
+
+.eyebrow,
+.section-index {
+ margin: 0 0 10px;
+ color: var(--oxide);
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.13em;
+ text-transform: uppercase;
+}
+
+.decision-panel .eyebrow {
+ color: #e68b6d;
+}
+
+.decision-copy h1 {
+ max-width: 16ch;
+ margin: 0;
+ font-size: clamp(2rem, 4vw, 4rem);
+ font-weight: 600;
+ line-height: 1.08;
+ letter-spacing: -0.055em;
+ text-wrap: balance;
+ word-break: keep-all;
+}
+
+.decision-copy > p:not(.eyebrow) {
+ max-width: 54ch;
+ margin: 18px 0 0;
+ color: #c9cfcb;
+ font-size: 1rem;
+ line-height: 1.7;
+ text-wrap: pretty;
+}
+
+.decision-meta {
+ min-height: 24px;
+ margin-top: 18px;
+ color: #9eaaa5;
+ font-size: 0.8rem;
+}
+
+.decision-meta strong {
+ color: var(--paper);
+}
+
+.text-action {
+ min-height: 44px;
+ margin-top: 18px;
+ padding: 0;
+ border: 0;
+ border-bottom: 1px solid #df8b6f;
+ background: transparent;
+ color: #f3ad94;
+ font-weight: 650;
+}
+
+.text-action:hover {
+ color: #fff;
+ border-color: #fff;
+}
+
+.summary-ledger {
+ margin: 0;
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ border-top: 1px solid #4d5754;
+ border-left: 1px solid #4d5754;
+}
+
+.summary-ledger > div {
+ min-height: 104px;
+ padding: 18px;
+ border-right: 1px solid #4d5754;
+ border-bottom: 1px solid #4d5754;
+}
+
+.summary-ledger dt {
+ margin-bottom: 12px;
+ color: #9eaaa5;
+ font-size: 0.74rem;
+}
+
+.summary-ledger dd {
+ margin: 0;
+ color: #fff;
+ font-family: var(--font-mono);
+ font-size: clamp(1.05rem, 2vw, 1.42rem);
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.035em;
+}
+
+.summary-ledger dd.positive { color: #7fc2ad; }
+.summary-ledger dd.negative { color: #f093a2; }
+
+.section-nav {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px 24px;
+ margin: 18px 0 0;
+ padding: 13px 0;
+ border-bottom: 1px solid var(--line-strong);
+}
+
+.section-nav a {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ color: var(--muted);
+ font-size: 0.82rem;
+ font-weight: 600;
+ text-decoration: none;
+ text-underline-offset: 5px;
+}
+
+.section-nav a:hover {
+ color: var(--oxide);
+ text-decoration: underline;
+}
+
+.content-section {
+ scroll-margin-top: 94px;
+ padding-top: 74px;
+}
+
+.section-heading {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 24px;
+ margin-bottom: 24px;
+}
+
+.section-heading h2 {
+ margin: 0;
+ font-size: clamp(1.55rem, 2.8vw, 2.4rem);
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: -0.045em;
+ text-wrap: balance;
+}
+
+.section-heading p:not(.section-index) {
+ max-width: 62ch;
+ margin: 8px 0 0;
+ color: var(--muted);
+ font-size: 0.9rem;
+ text-wrap: pretty;
+}
+
+.section-asof {
+ color: var(--muted);
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+
+.track-list {
+ display: grid;
+ gap: 14px;
+}
+
+.track-card {
+ position: relative;
+ display: grid;
+ grid-template-columns: minmax(260px, 0.9fr) minmax(0, 1.45fr);
+ gap: 32px;
+ padding: 26px 28px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+}
+
+.track-card[data-primary="true"] {
+ border-left: 6px solid var(--oxide);
+ padding-left: 23px;
+}
+
+.track-card[data-primary="false"] {
+ background: var(--surface-muted);
+}
+
+.track-role {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+.role-label,
+.status-label {
+ display: inline-flex;
+ min-height: 26px;
+ align-items: center;
+ padding: 3px 7px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-xs);
+ color: var(--muted);
+ font-family: var(--font-mono);
+ font-size: 0.65rem;
+ font-weight: 600;
+ letter-spacing: 0.04em;
+}
+
+.role-label.primary {
+ border-color: #d39781;
+ background: var(--oxide-soft);
+ color: var(--oxide-dark);
+}
+
+.track-title {
+ margin: 0;
+ font-size: 1.08rem;
+ font-weight: 650;
+ letter-spacing: -0.025em;
+}
+
+.track-id {
+ display: block;
+ margin-top: 4px;
+ color: var(--faint);
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+}
+
+.track-value {
+ margin-top: 22px;
+ font-family: var(--font-mono);
+ font-size: clamp(1.75rem, 4vw, 2.8rem);
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+ letter-spacing: -0.06em;
+ line-height: 1;
+}
+
+.track-value small {
+ margin-left: 5px;
+ color: var(--muted);
+ font-family: var(--font-sans);
+ font-size: 0.8rem;
+ font-weight: 500;
+}
+
+.track-principal,
+.track-date {
+ margin-top: 8px;
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+
+.track-date {
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
+}
+
+.track-plan {
+ margin: 14px 0 0;
+ padding-top: 12px;
+ border-top: 1px solid var(--line);
+ color: var(--ink-soft);
+ font-size: 0.76rem;
+ font-weight: 600;
+}
+
+.track-metrics {
+ margin: 0;
+ display: grid;
+ grid-template-columns: repeat(4, minmax(110px, 1fr));
+ border-top: 1px solid var(--line);
+ border-left: 1px solid var(--line);
+}
+
+.track-metrics > div {
+ min-width: 0;
+ min-height: 88px;
+ padding: 14px;
+ border-right: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+}
+
+.track-metrics dt {
+ margin-bottom: 8px;
+ color: var(--muted);
+ font-size: 0.7rem;
+}
+
+.track-metrics dd {
+ margin: 0;
+ overflow-wrap: anywhere;
+ font-family: var(--font-mono);
+ font-size: 0.92rem;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+}
+
+.positive { color: var(--positive) !important; }
+.negative { color: var(--negative) !important; }
+.warning { color: var(--warning) !important; }
+.muted { color: var(--muted) !important; }
+
+.allocation {
+ margin-top: 16px;
+}
+
+.allocation-copy {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 7px;
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
+.allocation-copy strong {
+ color: var(--ink-soft);
+ font-family: var(--font-mono);
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+}
+
+.allocation-bar {
+ height: 8px;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ background: var(--paper-deep);
+}
+
+.allocation-bar span {
+ display: block;
+ height: 100%;
+ background: var(--ink-soft);
+ transition: width 260ms ease-out;
+}
+
+.allocation-note {
+ margin: 8px 0 0;
+ color: var(--muted);
+ font-size: 0.75rem;
+ line-height: 1.55;
+}
+
+.holdings {
+ margin: 16px 0 0;
+ padding: 0;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+ list-style: none;
+}
+
+.holdings li {
+ padding: 5px 8px;
+ border-left: 2px solid var(--line-strong);
+ background: var(--surface-muted);
+ color: var(--ink-soft);
+ font-family: var(--font-mono);
+ font-size: 0.75rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.recent-flow {
+ margin: 14px 0 0;
+ color: var(--muted);
+ font-size: 0.73rem;
+}
+
+.performance-layout {
+ display: grid;
+ grid-template-columns: minmax(0, 1.7fr) minmax(310px, 0.7fr);
+ gap: 16px;
+ align-items: start;
+}
+
+.module {
+ min-width: 0;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+}
+
+.module-header {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 24px;
+ padding: 22px 22px 18px;
+ border-bottom: 1px solid var(--line);
+}
+
+.module-header h3 {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 650;
+ letter-spacing: -0.02em;
+}
+
+.module-header p {
+ margin: 5px 0 0;
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+
+.chart-controls {
+ display: grid;
+ grid-template-columns: auto minmax(150px, auto);
+ align-items: center;
+ gap: 7px 10px;
+}
+
+.chart-controls > label {
+ color: var(--muted);
+ font-size: 0.7rem;
+ font-weight: 600;
+}
+
+select,
+input {
+ min-height: 44px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ color: var(--ink);
+ font-size: 0.9rem;
+}
+
+select {
+ padding: 8px 34px 8px 10px;
+}
+
+.range-group,
+.preset-group {
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.range-group {
+ grid-column: 1 / -1;
+ display: flex;
+ justify-content: flex-end;
+ gap: 4px;
+}
+
+.range-group legend,
+.preset-group legend {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.range-group button {
+ min-height: 44px;
+ padding: 5px 8px;
+ border: 1px solid transparent;
+ border-radius: var(--radius-xs);
+ background: transparent;
+ color: var(--muted);
+ font-size: 0.7rem;
+ font-weight: 600;
+ transition: background-color 150ms ease-out, border-color 150ms ease-out, color 150ms ease-out;
+}
+
+.range-group button:hover,
+.range-group button[aria-pressed="true"] {
+ border-color: var(--line-strong);
+ background: var(--surface-muted);
+ color: var(--ink);
+}
+
+.chart-figure {
+ margin: 0;
+ padding: 18px 22px 14px;
+}
+
+.chart-wrap {
+ min-height: 340px;
+ position: relative;
+}
+
+.chart-wrap canvas {
+ display: block;
+ max-width: 100%;
+}
+
+.chart-maturity {
+ margin-top: 7px !important;
+ color: var(--oxide) !important;
+ font-size: 0.75rem !important;
+ font-weight: 600;
+}
+
+.chart-figure figcaption {
+ margin-top: 12px;
+ color: var(--faint);
+ font-size: 0.75rem;
+}
+
+.empty-state {
+ min-height: 340px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 32px;
+ border: 1px dashed var(--line-strong);
+ color: var(--muted);
+ text-align: center;
+}
+
+.empty-state strong {
+ margin-bottom: 6px;
+ color: var(--ink);
+}
+
+.data-disclosure {
+ margin: 0 22px 22px;
+ border-top: 1px solid var(--line);
+}
+
+.data-disclosure summary {
+ min-height: 44px;
+ display: flex;
+ align-items: center;
+ color: var(--muted);
+ font-size: 0.74rem;
+ font-weight: 600;
+}
+
+.review-module {
+ scroll-margin-top: 94px;
+}
+
+.review-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.review-item {
+ padding: 18px 22px;
+ border-bottom: 1px solid var(--line);
+}
+
+.review-item:last-child {
+ border-bottom: 0;
+}
+
+.review-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ font-size: 0.82rem;
+}
+
+.review-head strong {
+ overflow-wrap: anywhere;
+}
+
+.review-state {
+ color: var(--muted);
+ font-family: var(--font-mono);
+ font-size: 0.67rem;
+ white-space: nowrap;
+}
+
+.review-progress {
+ height: 7px;
+ margin-top: 12px;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ background: var(--paper-deep);
+}
+
+.review-progress span {
+ display: block;
+ height: 100%;
+ background: var(--info);
+ transition: width 260ms ease-out;
+}
+
+.review-detail {
+ display: flex;
+ justify-content: space-between;
+ gap: 10px;
+ margin-top: 8px;
+ color: var(--muted);
+ font-family: var(--font-mono);
+ font-size: 0.66rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.review-issues {
+ margin: 10px 0 0;
+ padding-left: 17px;
+ color: var(--negative);
+ font-size: 0.7rem;
+}
+
+.module-note {
+ margin: 0;
+ padding: 16px 22px;
+ border-top: 1px solid var(--line);
+ background: var(--surface-muted);
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
+.trust-grid {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.trust-grid.compact {
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
+}
+
+.trust-card {
+ min-height: 104px;
+ padding: 16px;
+ border-top: 3px solid var(--line-strong);
+ background: var(--surface);
+}
+
+.trust-card[data-state="ok"] { border-color: var(--positive); }
+.trust-card[data-state="warning"] { border-color: var(--warning); }
+.trust-card[data-state="error"] { border-color: var(--negative); }
+.trust-card[data-state="info"] { border-color: var(--info); }
+
+.trust-card .label {
+ display: block;
+ margin-bottom: 9px;
+ color: var(--muted);
+ font-size: 0.7rem;
+}
+
+.trust-card .value {
+ display: block;
+ overflow-wrap: anywhere;
+ font-family: var(--font-mono);
+ font-size: 0.94rem;
+ font-weight: 500;
+ font-variant-numeric: tabular-nums;
+}
+
+.trust-card .support {
+ display: block;
+ margin-top: 7px;
+ color: var(--faint);
+ font-size: 0.75rem;
+}
+
+.runtime-meta {
+ min-height: 20px;
+ margin: 12px 0 0;
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
+.halt-guidance {
+ margin-top: 18px;
+ padding: 20px;
+ display: grid;
+ grid-template-columns: minmax(220px, 0.8fr) minmax(300px, 1.2fr) auto;
+ align-items: center;
+ gap: 24px;
+ border: 1px solid var(--negative);
+ border-left-width: 5px;
+ background: var(--negative-soft);
+}
+
+.halt-guidance[hidden] {
+ display: none;
+}
+
+.halt-guidance h3,
+.halt-guidance p,
+.halt-guidance ol {
+ margin: 0;
+}
+
+.halt-guidance h3 {
+ margin-top: 5px;
+ font-size: 1rem;
+}
+
+.halt-guidance #haltGuidanceReason {
+ margin-top: 8px;
+ color: var(--ink-soft);
+ font-size: 0.8rem;
+}
+
+.halt-guidance ol {
+ padding-left: 20px;
+ color: var(--ink-soft);
+ font-size: 0.78rem;
+ line-height: 1.7;
+}
+
+.halt-guidance a {
+ min-height: 44px;
+ display: inline-flex;
+ align-items: center;
+ color: var(--negative);
+ font-size: 0.78rem;
+ font-weight: 700;
+ text-underline-offset: 4px;
+}
+
+.primary-inline {
+ display: inline-block;
+ margin-left: 7px;
+ padding: 2px 5px;
+ border: 1px solid var(--oxide);
+ color: var(--oxide);
+ font-size: 0.62rem;
+ font-weight: 700;
+ vertical-align: 1px;
+}
+
+.disclosure-stack {
+ display: grid;
+ gap: 10px;
+ margin-top: 24px;
+}
+
+.disclosure {
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+}
+
+.disclosure > summary {
+ min-height: 72px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 20px;
+ padding: 14px 18px;
+ list-style: none;
+}
+
+.disclosure > summary::-webkit-details-marker {
+ display: none;
+}
+
+.disclosure > summary::after {
+ content: "+";
+ margin-left: auto;
+ color: var(--oxide);
+ font-family: var(--font-mono);
+ font-size: 1.2rem;
+ font-weight: 500;
+}
+
+.disclosure[open] > summary::after {
+ content: "−";
+}
+
+.disclosure > summary:hover {
+ background: var(--surface-muted);
+}
+
+.disclosure summary span:first-child {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.disclosure summary strong {
+ color: var(--ink);
+ font-size: 0.86rem;
+}
+
+.disclosure summary small {
+ color: var(--muted);
+ font-size: 0.68rem;
+}
+
+.summary-count {
+ margin-left: auto;
+ color: var(--muted);
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ font-variant-numeric: tabular-nums;
+}
+
+.disclosure-body {
+ padding: 18px;
+ border-top: 1px solid var(--line);
+}
+
+.disclosure-body h4 {
+ margin: 26px 0 12px;
+ font-size: 0.86rem;
+}
+
+.table-scroll {
+ max-width: 100%;
+ overflow-x: auto;
+ overscroll-behavior-inline: contain;
+}
+
+table {
+ width: 100%;
+ min-width: 640px;
+ border-collapse: collapse;
+ font-size: 0.78rem;
+}
+
+caption {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+th,
+td {
+ padding: 11px 10px;
+ border-bottom: 1px solid var(--line);
+ text-align: left;
+ white-space: nowrap;
+}
+
+th {
+ color: var(--muted);
+ font-size: 0.67rem;
+ font-weight: 600;
+}
+
+td {
+ font-variant-numeric: tabular-nums;
+}
+
+.num {
+ text-align: right;
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
+}
+
+.empty-inline,
+.error-inline,
+.loading-line,
+.loading-card {
+ margin: 0;
+ padding: 18px;
+ color: var(--muted);
+ font-size: 0.78rem;
+}
+
+.error-inline {
+ border-left: 3px solid var(--negative);
+ background: var(--negative-soft);
+ color: #7d1f31;
+}
+
+.loading-card {
+ min-height: 140px;
+ display: flex;
+ align-items: center;
+ border: 1px solid var(--line-strong);
+ background: var(--surface);
+}
+
+.site-footer {
+ display: grid;
+ grid-template-columns: auto 1fr minmax(280px, 0.8fr);
+ gap: 14px;
+ align-items: center;
+ margin-top: 80px;
+ padding: 28px 0 0;
+ border-top: 1px solid var(--line-strong);
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
+.site-footer p {
+ margin: 0;
+}
+
+.site-footer strong {
+ color: var(--ink);
+}
+
+.footer-note {
+ justify-self: end;
+ max-width: 52ch;
+ text-align: right;
+}
+
+.deposit-dialog {
+ width: min(520px, calc(100vw - 32px));
+ max-height: min(760px, calc(100dvh - 32px));
+ margin: auto;
+ padding: 0;
+ overflow: auto;
+ overscroll-behavior: contain;
+ border: 1px solid var(--ink);
+ border-radius: var(--radius-md);
+ background: var(--surface);
+ color: var(--ink);
+ box-shadow: var(--shadow-dialog);
+}
+
+.deposit-dialog::backdrop {
+ background: rgb(24 32 31 / 60%);
+ backdrop-filter: blur(3px);
+}
+
+.deposit-dialog form {
+ padding: 24px;
+}
+
+.dialog-header {
+ display: flex;
+ align-items: start;
+ justify-content: space-between;
+ gap: 24px;
+}
+
+.dialog-header h2 {
+ margin: 0;
+ font-size: 1.45rem;
+ letter-spacing: -0.035em;
+}
+
+.icon-button {
+ width: 44px;
+ height: 44px;
+ display: inline-grid;
+ place-items: center;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ color: var(--ink);
+ transition: background-color 150ms ease-out, border-color 150ms ease-out;
+}
+
+.icon-button:hover {
+ border-color: var(--ink);
+ background: var(--surface-muted);
+}
+
+.dialog-description {
+ margin: 14px 0 22px;
+ color: var(--muted);
+ font-size: 0.82rem;
+ text-wrap: pretty;
+}
+
+.field {
+ margin-bottom: 18px;
+}
+
+.field > label {
+ display: block;
+ margin-bottom: 7px;
+ color: var(--ink-soft);
+ font-size: 0.78rem;
+ font-weight: 600;
+}
+
+.field > label span {
+ color: var(--faint);
+ font-weight: 400;
+}
+
+.field input,
+.field select {
+ width: 100%;
+ padding: 10px 12px;
+ font-size: 16px;
+}
+
+.field small {
+ display: block;
+ margin-top: 6px;
+ color: var(--muted);
+ font-size: 0.7rem;
+}
+
+.money-input {
+ position: relative;
+}
+
+.money-input input {
+ padding-right: 42px;
+ font-family: var(--font-mono);
+ font-variant-numeric: tabular-nums;
+}
+
+.money-input > span {
+ position: absolute;
+ top: 50%;
+ right: 13px;
+ transform: translateY(-50%);
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+
+.preset-group {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 7px;
+ margin-top: 10px;
+}
+
+.preset-group button {
+ min-height: 44px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-sm);
+ background: var(--surface);
+ color: var(--ink-soft);
+ font-size: 0.78rem;
+ font-weight: 600;
+ transition: background-color 150ms ease-out, border-color 150ms ease-out, color 150ms ease-out;
+}
+
+.preset-group button:hover,
+.preset-group button[aria-pressed="true"] {
+ border-color: var(--oxide);
+ background: var(--oxide-soft);
+ color: var(--oxide-dark);
+}
+
+.deposit-confirm {
+ padding: 16px;
+ border: 1px solid var(--line-strong);
+ background: var(--surface-muted);
+}
+
+.deposit-confirm > p {
+ margin: 0 0 12px;
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+
+.deposit-confirm dl {
+ margin: 0;
+}
+
+.deposit-confirm dl > div {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 8px 0;
+ border-top: 1px solid var(--line);
+}
+
+.deposit-confirm dt {
+ color: var(--muted);
+ font-size: 0.74rem;
+}
+
+.deposit-confirm dd {
+ margin: 0;
+ font-family: var(--font-mono);
+ font-size: 0.78rem;
+ font-weight: 500;
+ text-align: right;
+}
+
+.form-error {
+ margin: 14px 0 0;
+ padding: 10px 12px;
+ border-left: 3px solid var(--negative);
+ background: var(--negative-soft);
+ color: #7d1f31;
+ font-size: 0.76rem;
+}
+
+.dialog-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ margin-top: 24px;
+}
+
+.toast {
+ position: fixed;
+ left: 50%;
+ bottom: max(24px, env(safe-area-inset-bottom));
+ z-index: 200;
+ max-width: min(560px, calc(100vw - 32px));
+ padding: 12px 16px;
+ transform: translate(-50%, 18px);
+ border: 1px solid var(--ink);
+ border-radius: var(--radius-sm);
+ background: var(--ink);
+ color: #fff;
+ box-shadow: 0 12px 32px rgb(24 32 31 / 18%);
+ font-size: 0.8rem;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 180ms ease-out, transform 180ms ease-out;
+}
+
+.toast.show {
+ transform: translate(-50%, 0);
+ opacity: 1;
+}
+
+.toast[data-kind="error"] {
+ border-color: var(--negative);
+ background: #6d1d2c;
+}
+
+@media (max-width: 1040px) {
+ .header-inner {
+ grid-template-columns: auto 1fr;
+ }
+
+ .header-state {
+ order: 3;
+ grid-column: 1 / -1;
+ justify-self: stretch;
+ padding-top: 8px;
+ border-top: 1px solid var(--line);
+ }
+
+ .header-actions {
+ justify-self: end;
+ }
+
+ .decision-panel {
+ grid-template-columns: 1fr;
+ }
+
+ .summary-ledger {
+ max-width: 720px;
+ }
+
+ .performance-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .track-metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .trust-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+
+ .halt-guidance {
+ grid-template-columns: 1fr;
+ gap: 14px;
+ }
+}
+
+@media (max-width: 760px) {
+ .site-header {
+ position: relative;
+ backdrop-filter: none;
+ }
+
+ .header-inner {
+ grid-template-columns: 1fr;
+ gap: 10px;
+ padding: 12px 16px 14px;
+ }
+
+ .brand,
+ .header-actions,
+ .header-state {
+ justify-self: stretch;
+ }
+
+ .header-actions {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ }
+
+ .header-actions .button {
+ width: 100%;
+ }
+
+ .header-state {
+ grid-column: auto;
+ order: initial;
+ flex-wrap: wrap;
+ }
+
+ .header-state time {
+ margin-left: 17px;
+ flex-basis: 100%;
+ }
+
+ .page-shell {
+ padding: 20px 16px 48px;
+ }
+
+ .decision-panel {
+ min-height: 0;
+ padding: 32px 22px 22px;
+ gap: 28px;
+ }
+
+ .decision-copy h1 {
+ max-width: 18ch;
+ font-size: clamp(2rem, 10vw, 3rem);
+ }
+
+ .summary-ledger > div {
+ min-height: 92px;
+ padding: 14px;
+ }
+
+ .section-nav {
+ gap: 4px 14px;
+ }
+
+ .content-section {
+ scroll-margin-top: 20px;
+ padding-top: 56px;
+ }
+
+ .section-heading {
+ align-items: start;
+ flex-direction: column;
+ gap: 14px;
+ }
+
+ .section-heading .button {
+ width: 100%;
+ }
+
+ .track-card {
+ grid-template-columns: 1fr;
+ gap: 24px;
+ padding: 22px 18px;
+ }
+
+ .track-card[data-primary="true"] {
+ padding-left: 13px;
+ }
+
+ .track-metrics {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .module-header {
+ flex-direction: column;
+ }
+
+ .chart-controls {
+ width: 100%;
+ grid-template-columns: 1fr;
+ }
+
+ .chart-controls > label,
+ .chart-controls > select {
+ width: 100%;
+ }
+
+ .range-group {
+ grid-column: auto;
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ .range-group button {
+ min-height: 44px;
+ }
+
+ .chart-figure {
+ padding: 16px 12px 12px;
+ }
+
+ .chart-wrap,
+ .empty-state {
+ min-height: 280px;
+ }
+
+ .data-disclosure {
+ margin: 0 12px 16px;
+ }
+
+ .trust-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+
+ .disclosure > summary {
+ min-height: 78px;
+ padding: 14px;
+ }
+
+ .disclosure-body {
+ padding: 12px;
+ }
+
+ .site-footer {
+ grid-template-columns: auto 1fr;
+ }
+
+ .footer-note {
+ grid-column: 1 / -1;
+ justify-self: start;
+ text-align: left;
+ }
+
+ .deposit-dialog form {
+ padding: 20px 16px;
+ }
+}
+
+@media (max-width: 420px) {
+ .brand-copy span {
+ display: none;
+ }
+
+ .summary-ledger > div {
+ min-height: 78px;
+ }
+
+ .trust-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .track-metrics > div {
+ min-height: 76px;
+ }
+
+ .range-group {
+ grid-template-columns: repeat(2, 1fr);
+ }
+
+ .dialog-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .dialog-actions .button-primary {
+ grid-column: 1 / -1;
+ grid-row: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ html {
+ scroll-behavior: auto;
+ }
+
+ *,
+ *::before,
+ *::after {
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ }
+}
+
+@media (prefers-contrast: more) {
+ :root {
+ --line: #999b95;
+ --line-strong: #666b68;
+ --muted: #3f4946;
+ --faint: #505b57;
+ }
+}
diff --git a/monitoring/static/dashboard.js b/monitoring/static/dashboard.js
new file mode 100644
index 00000000..19f93879
--- /dev/null
+++ b/monitoring/static/dashboard.js
@@ -0,0 +1,1234 @@
+"use strict";
+
+const $ = (id) => document.getElementById(id);
+const won = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 0 });
+const compactWon = new Intl.NumberFormat("ko-KR", {
+ notation: "compact",
+ maximumFractionDigits: 1,
+});
+const dateOnly = new Intl.DateTimeFormat("ko-KR", {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+});
+const dateShort = new Intl.DateTimeFormat("ko-KR", {
+ month: "short",
+ day: "numeric",
+});
+const dateTime = new Intl.DateTimeFormat("ko-KR", {
+ month: "numeric",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+});
+
+const query = new URLSearchParams(window.location.search);
+const requestedDays = Number(query.get("days"));
+const allowedDays = [30, 90, 365, 3650];
+
+const state = {
+ mode: "unknown",
+ baskets: null,
+ evaluations: null,
+ runtime: null,
+ legacy: null,
+ flows: new Map(),
+ flowStatus: new Map(),
+ flowError: false,
+ chartRows: [],
+ chartDays: allowedDays.includes(requestedDays) ? requestedDays : 90,
+ chartAccount: query.get("account") || null,
+ lastCoreSuccess: null,
+ coreError: null,
+ coreStatus: "loading",
+ runtimeStatus: "loading",
+ activeRequests: new Map(),
+ depositConfirming: false,
+ depositRequestId: null,
+};
+
+const elements = {
+ basketTracks: $("basketTracks"),
+ portfolioSummary: $("portfolioSummary"),
+ portfolioAsOf: $("portfolioAsOf"),
+ chartAccount: $("chartAccount"),
+ chartSummary: $("chartSummary"),
+ chartMaturity: $("chartMaturity"),
+ chartWrap: $("chartWrap"),
+ chartEmpty: $("chartEmpty"),
+ chartRows: $("chartDataRows"),
+ basketEval: $("basketEval"),
+ runtimeOps: $("runtimeOps"),
+ runtimeMeta: $("runtimeMeta"),
+ syncMark: $("syncMark"),
+ syncStatus: $("syncStatus"),
+ lastUpdate: $("lastUpdate"),
+ modeBadge: $("modeBadge"),
+ decisionTitle: $("decisionTitle"),
+ decisionDescription: $("decisionDescription"),
+ decisionMeta: $("decisionMeta"),
+ decisionAction: $("decisionAction"),
+ openDeposit: $("openDepositButton"),
+ depositAvailability: $("depositAvailability"),
+ depositDialog: $("depositDialog"),
+ depositForm: $("depositForm"),
+ depositFields: $("depositFields"),
+ depositConfirm: $("depositConfirm"),
+ depositError: $("depositError"),
+ depositSubmit: $("depositSubmitButton"),
+ depositBack: $("depositBackButton"),
+ haltGuidance: $("haltGuidance"),
+};
+
+function escapeHtml(value) {
+ const node = document.createElement("div");
+ node.textContent = value == null ? "" : String(value);
+ return node.innerHTML
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function formatWon(value) {
+ const number = Number(value);
+ return Number.isFinite(number) ? `${won.format(number)}원` : "—";
+}
+
+function formatPercent(value, { sign = true } = {}) {
+ const number = Number(value);
+ if (!Number.isFinite(number)) return "—";
+ const prefix = sign && number > 0 ? "+" : "";
+ return `${prefix}${number.toFixed(2)}%`;
+}
+
+function toneFor(value) {
+ const number = Number(value);
+ if (!Number.isFinite(number) || number === 0) return "muted";
+ return number > 0 ? "positive" : "negative";
+}
+
+function parseDate(value) {
+ if (!value) return null;
+ const text = String(value);
+ const normalized = /^\d{4}-\d{2}-\d{2}$/.test(text)
+ ? `${text}T00:00:00+09:00`
+ : text;
+ const parsed = new Date(normalized);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+
+function formatDate(value, formatter = dateOnly) {
+ const parsed = parseDate(value);
+ return parsed ? formatter.format(parsed) : "—";
+}
+
+function localIsoDate(value = new Date()) {
+ const year = value.getFullYear();
+ const month = String(value.getMonth() + 1).padStart(2, "0");
+ const day = String(value.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
+}
+
+function calendarAgeDays(value) {
+ const parsed = parseDate(value);
+ if (!parsed) return null;
+ const now = new Date();
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
+ const target = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
+ return Math.max(0, Math.floor((today - target) / 86_400_000));
+}
+
+async function fetchJson(url, { timeout = 15_000, options = {}, key = url } = {}) {
+ const previous = state.activeRequests.get(key);
+ if (previous) previous.abort();
+
+ const controller = new AbortController();
+ state.activeRequests.set(key, controller);
+ const timer = window.setTimeout(() => controller.abort(), timeout);
+ try {
+ const response = await fetch(url, { ...options, signal: controller.signal });
+ let payload;
+ try {
+ payload = await response.json();
+ } catch {
+ payload = null;
+ }
+ if (!response.ok) {
+ const reason = payload && payload.error ? payload.error : `HTTP ${response.status}`;
+ throw new Error(reason);
+ }
+ return payload;
+ } finally {
+ window.clearTimeout(timer);
+ if (state.activeRequests.get(key) === controller) {
+ state.activeRequests.delete(key);
+ }
+ }
+}
+
+function setSyncState(kind, label) {
+ elements.syncMark.dataset.state = kind;
+ elements.syncStatus.textContent = label;
+}
+
+function updateSyncIndicator() {
+ if (state.coreStatus === "loading") {
+ setSyncState("loading", "장부 데이터 확인 중…");
+ } else if (state.coreStatus === "error") {
+ setSyncState("error", "장부 연결 실패");
+ } else if (state.runtimeStatus === "error") {
+ setSyncState("partial", "장부 정상 · 안전 상태 확인 불가");
+ } else if (state.coreStatus === "partial") {
+ setSyncState("partial", "일부 장부 데이터 지연");
+ } else {
+ setSyncState("ok", "장부 연결 정상");
+ }
+ updateDepositAvailability();
+}
+
+function canRecordDeposit() {
+ const baskets = state.baskets;
+ const halt = state.runtime && state.runtime.trading_halt;
+ return (
+ state.coreStatus === "ready"
+ && state.runtimeStatus === "ready"
+ && ["paper", "live"].includes(state.mode)
+ && Array.isArray(baskets)
+ && baskets.length > 0
+ && baskets.every((basket) => state.flowStatus.get(basket.basket) === "ready")
+ && halt
+ && halt.halted === false
+ );
+}
+
+function updateDepositAvailability() {
+ const available = canRecordDeposit();
+ elements.openDeposit.disabled = !available;
+ elements.depositAvailability.textContent = available
+ ? "적립금을 기록할 수 있습니다."
+ : "장부와 거래 안전 상태가 정상으로 확인된 뒤 적립금을 기록할 수 있습니다.";
+}
+
+function markCoreSuccess(timestamp) {
+ const parsed = parseDate(timestamp) || new Date();
+ state.lastCoreSuccess = parsed;
+ state.coreError = null;
+ elements.lastUpdate.dateTime = parsed.toISOString();
+ elements.lastUpdate.textContent = `마지막 성공 ${dateTime.format(parsed)}`;
+}
+
+function setMode(mode) {
+ const normalized = String(mode || "unknown").toLowerCase();
+ state.mode = ["paper", "live"].includes(normalized) ? normalized : "unknown";
+ elements.modeBadge.dataset.mode = state.mode;
+ if (state.mode === "paper") {
+ elements.modeBadge.textContent = "모의 운용 · 실제 주문 없음";
+ } else if (state.mode === "live") {
+ elements.modeBadge.textContent = "실전 운용 · 실계좌 주문 가능";
+ } else {
+ elements.modeBadge.textContent = "운용 모드 확인 불가";
+ }
+ updateDepositCopy();
+}
+
+function sortedBaskets() {
+ return [...(state.baskets || [])].sort((a, b) => {
+ const primaryDelta = Number(Boolean(b.is_primary)) - Number(Boolean(a.is_primary));
+ if (primaryDelta) return primaryDelta;
+ return String(a.basket).localeCompare(String(b.basket));
+ });
+}
+
+function primaryBasket() {
+ return sortedBaskets().find((basket) => basket.is_primary) || null;
+}
+
+function renderPortfolioSummary() {
+ const basket = primaryBasket() || sortedBaskets().find((item) => item.snapshot) || null;
+ const assetLabel = state.mode === "live" ? "현재 실전 자산" : "현재 모의 자산";
+ if (!basket || !basket.snapshot) {
+ elements.portfolioSummary.innerHTML = [
+ [assetLabel, "기록 없음"],
+ ["누적 원금", "기록 없음"],
+ ["원금 대비 손익", "기록 없음"],
+ ["현금 비중", "기록 없음"],
+ ].map(([label, value]) => `${label}${value}`).join("");
+ return;
+ }
+
+ const totalValue = Number(basket.snapshot.total_value || 0);
+ const principal = Number(basket.principal || 0);
+ const cash = Number(basket.snapshot.cash || 0);
+ const profit = totalValue - principal;
+ const cashRatio = totalValue > 0 ? (cash / totalValue) * 100 : null;
+ elements.portfolioSummary.setAttribute(
+ "aria-label",
+ `${basket.display_name} 주력 포트폴리오 요약`,
+ );
+
+ const rows = [
+ [assetLabel, formatWon(totalValue), ""],
+ ["누적 원금", formatWon(principal), ""],
+ ["원금 대비 손익", `${profit > 0 ? "+" : ""}${formatWon(profit)}`, toneFor(profit)],
+ ["현금 비중", cashRatio == null ? "—" : formatPercent(cashRatio, { sign: false }), ""],
+ ];
+ elements.portfolioSummary.innerHTML = rows.map(([label, value, tone]) => (
+ `${label}${escapeHtml(value)}`
+ )).join("");
+}
+
+function positionLabel(position) {
+ const name = position.name ? `${position.name} · ` : "";
+ return `${name}${position.symbol} ${won.format(Number(position.quantity || 0))}주`;
+}
+
+function renderBasketTracks(data) {
+ const baskets = (data && data.baskets) || [];
+ state.baskets = baskets;
+ elements.basketTracks.setAttribute("aria-busy", "false");
+ setMode(data && data.mode);
+ markCoreSuccess(data && data.timestamp);
+
+ if (!baskets.length) {
+ elements.basketTracks.innerHTML = `
+
+ 활성화된 포트폴리오가 없습니다.
+ config/baskets.yaml에서 모의 운용 포트폴리오를 먼저 선택하세요.
+
`;
+ renderPortfolioSummary();
+ elements.portfolioAsOf.textContent = "기준일 없음";
+ elements.portfolioAsOf.dateTime = "";
+ return;
+ }
+
+ elements.basketTracks.innerHTML = sortedBaskets().map((basket) => {
+ const snapshot = basket.snapshot;
+ const twr = snapshot ? Number(snapshot.cumulative_return) : null;
+ const profit = basket.profit_vs_principal == null ? null : Number(basket.profit_vs_principal);
+ const deployment = basket.deployment_ratio == null ? null : Number(basket.deployment_ratio) * 100;
+ const target = basket.design_fraction == null ? null : Number(basket.design_fraction) * 100;
+ const primary = Boolean(basket.is_primary);
+ const flowItems = state.flows.get(basket.basket) || [];
+ const flowState = state.flowStatus.get(basket.basket);
+ const latestFlow = flowItems[0];
+ const holdings = (basket.positions || []).length
+ ? `${basket.positions.map((position) => `- ${escapeHtml(positionLabel(position))}
`).join("")}
`
+ : '아직 보유 종목이 없습니다.
';
+ const allocationWidth = deployment == null ? 0 : Math.max(0, Math.min(100, deployment));
+ const role = basket.purpose || (primary ? "월 적립 중심" : "장기 관찰용");
+ const plan = basket.contribution_plan || {};
+ const planCopy = primary && plan.enabled && Number(plan.amount) > 0
+ ? `운용 기준 · 월 ${formatWon(plan.amount)} 적립 · 큰 비중 이탈 때만 리밸런싱`
+ : "";
+ const allocationGap = deployment == null || target == null ? null : target - deployment;
+ const allocationNote = allocationGap != null && allocationGap > 1
+ ? (primary
+ ? `목표보다 ${Math.round(allocationGap)}%p 낮음 · ETF 1주 단위라 다음 적립 때 조정될 수 있습니다.`
+ : `목표보다 ${Math.round(allocationGap)}%p 낮습니다. 관찰 기준에 따라 큰 이탈만 점검합니다.`)
+ : "목표 범위에 가깝게 운용 중입니다.";
+
+ return `
+
+
+
+ ${primary ? "주력 포트폴리오" : "관찰 포트폴리오"}
+ ${escapeHtml(role)}
+
+
${escapeHtml(basket.display_name)}
+
${escapeHtml(basket.basket)}
+
${snapshot ? won.format(Number(snapshot.total_value)) : "기록 없음"}${snapshot ? "원" : ""}
+
누적 원금 ${formatWon(basket.principal)}${basket.deposits_total > 0 ? ` · 적립 ${formatWon(basket.deposits_total)}` : ""}
+
${snapshot ? `자산 기준 ${formatDate(snapshot.date)}` : "모의 운용을 실행하면 첫 기록이 만들어집니다."}
+ ${planCopy ? `
${escapeHtml(planCopy)}
` : ""}
+
+
+
+ - 입출금 제외 수익 (TWR)
- ${twr == null ? "—" : formatPercent(twr)}
+ - 원금 대비 손익
- ${profit == null ? "—" : `${profit > 0 ? "+" : ""}${formatWon(profit)}`}
+ - 고점 대비 최대 하락 (MDD)
- ${snapshot ? formatPercent(-Math.abs(Number(snapshot.mdd || 0)), { sign: false }) : "—"}
+ - 현금
- ${snapshot ? formatWon(snapshot.cash) : "—"}
+
+
+
투자 배치율${deployment == null ? "—" : `${Math.round(deployment)}%`} / 목표 ${target == null ? "—" : `${Math.round(target)}%`}
+
+
${escapeHtml(allocationNote)}
+
+ ${holdings}
+ ${latestFlow ? `
최근 적립 · ${formatDate(latestFlow.occurred_at, dateShort)} · +${formatWon(latestFlow.amount)}${latestFlow.note ? ` · ${escapeHtml(latestFlow.note)}` : ""}
` : ""}
+ ${flowState === "error" ? '
적립 기록을 확인할 수 없습니다. 새 기록을 추가하지 말고 다시 확인하세요.
' : ""}
+
+ `;
+ }).join("");
+
+ const referenceBasket = primaryBasket() || sortedBaskets().find((basket) => basket.snapshot);
+ const referenceDate = referenceBasket && referenceBasket.snapshot && referenceBasket.snapshot.date;
+ elements.portfolioAsOf.dateTime = referenceDate || "";
+ elements.portfolioAsOf.textContent = referenceDate
+ ? `주력 기준 ${formatDate(referenceDate)}`
+ : "첫 기록 대기 중";
+ renderPortfolioSummary();
+}
+
+async function refreshFlows() {
+ const baskets = state.baskets || [];
+ const tasks = baskets.map(async (basket) => {
+ state.flowStatus.set(basket.basket, "loading");
+ try {
+ const data = await fetchJson(`/api/cash_flows?basket=${encodeURIComponent(basket.basket)}`, {
+ timeout: 10_000,
+ key: `flows:${basket.basket}`,
+ });
+ state.flows.set(basket.basket, (data && data.flows) || []);
+ state.flowStatus.set(basket.basket, "ready");
+ return true;
+ } catch {
+ state.flowStatus.set(basket.basket, "error");
+ return false;
+ }
+ });
+ const results = await Promise.all(tasks);
+ state.flowError = results.some((ok) => !ok);
+ if (state.baskets) renderBasketTracks({ baskets: state.baskets, mode: state.mode, timestamp: state.lastCoreSuccess });
+ renderDecision();
+ return !state.flowError;
+}
+
+function ensureChartAccountOptions() {
+ const wanted = sortedBaskets().map((basket) => ({
+ value: basket.account_key,
+ label: basket.display_name,
+ }));
+ wanted.push({ value: "", label: "레거시 기본 계정" });
+ const signature = JSON.stringify(wanted);
+ if (elements.chartAccount.dataset.signature === signature) return;
+
+ const previous = state.chartAccount;
+ elements.chartAccount.dataset.signature = signature;
+ elements.chartAccount.innerHTML = wanted.map((option) => (
+ ``
+ )).join("");
+ const exists = previous !== null && wanted.some((option) => option.value === previous);
+ elements.chartAccount.value = exists ? previous : (wanted[0] ? wanted[0].value : "");
+ state.chartAccount = elements.chartAccount.value;
+}
+
+function syncChartQuery() {
+ const params = new URLSearchParams(window.location.search);
+ params.set("days", String(state.chartDays));
+ if (state.chartAccount) params.set("account", state.chartAccount);
+ else params.delete("account");
+ const queryString = params.toString();
+ history.replaceState(null, "", `${window.location.pathname}${queryString ? `?${queryString}` : ""}${window.location.hash}`);
+}
+
+function renderChartTable(snapshots) {
+ elements.chartRows.innerHTML = snapshots.map((snapshot) => `
+
+ |
+ ${escapeHtml(formatWon(snapshot.total_value))} |
+ ${escapeHtml(formatPercent(snapshot.cumulative_return))} |
+
`).join("");
+}
+
+function renderChartMaturity() {
+ const basket = (state.baskets || []).find((item) => item.account_key === state.chartAccount);
+ if (!basket) {
+ elements.chartMaturity.textContent = "레거시 기본 계정은 모의 운용 검증 트랙과 분리됩니다.";
+ return;
+ }
+ const evaluation = (state.evaluations || []).find((item) => item.basket === basket.basket);
+ if (!evaluation) {
+ elements.chartMaturity.textContent = "운용 기록 성숙도를 확인하고 있습니다.";
+ return;
+ }
+ const days = Number(evaluation.progress_days || 0);
+ const minimum = Math.max(1, Number(evaluation.min_trading_days || 60));
+ if (days < 20) {
+ elements.chartMaturity.textContent = `운용 ${days}영업일차 · 장기 추세를 판단하기 전입니다.`;
+ } else if (days < minimum) {
+ elements.chartMaturity.textContent = `운용 ${days}영업일차 · ${minimum}영업일까지 기록 무결성을 우선 확인합니다.`;
+ } else {
+ elements.chartMaturity.textContent = `운용 ${days}영업일차 · 장기 기록 검토가 가능한 구간입니다.`;
+ }
+}
+
+function drawChart(rows) {
+ if (!rows.length || elements.chartWrap.hidden) return;
+ const canvas = $("chartEquity");
+ const width = Math.max(280, elements.chartWrap.clientWidth);
+ const height = Math.max(280, elements.chartWrap.clientHeight);
+ const ratio = Math.min(2, window.devicePixelRatio || 1);
+ canvas.width = Math.round(width * ratio);
+ canvas.height = Math.round(height * ratio);
+ canvas.style.width = `${width}px`;
+ canvas.style.height = `${height}px`;
+
+ const context = canvas.getContext("2d");
+ context.setTransform(ratio, 0, 0, ratio, 0, 0);
+ context.clearRect(0, 0, width, height);
+
+ const padding = { top: 22, right: 18, bottom: 38, left: width < 480 ? 58 : 72 };
+ const plotWidth = width - padding.left - padding.right;
+ const plotHeight = height - padding.top - padding.bottom;
+ const values = rows.map((row) => Number(row.total_value || 0));
+ let minimum = Math.min(...values);
+ let maximum = Math.max(...values);
+ const spread = Math.max(1, maximum - minimum);
+ minimum -= spread * 0.08;
+ maximum += spread * 0.08;
+
+ const x = (index) => padding.left + (plotWidth * index) / Math.max(1, rows.length - 1);
+ const y = (value) => padding.top + ((maximum - value) / (maximum - minimum)) * plotHeight;
+
+ context.font = '10px "IBM Plex Mono", monospace';
+ context.fillStyle = "#5d6966";
+ context.strokeStyle = "#e5e1d8";
+ context.lineWidth = 1;
+ context.textAlign = "right";
+ context.textBaseline = "middle";
+ for (let index = 0; index <= 4; index += 1) {
+ const value = maximum - ((maximum - minimum) * index) / 4;
+ const lineY = padding.top + (plotHeight * index) / 4;
+ context.beginPath();
+ context.moveTo(padding.left, lineY);
+ context.lineTo(width - padding.right, lineY);
+ context.stroke();
+ context.fillText(`${compactWon.format(value)}원`, padding.left - 9, lineY);
+ }
+
+ context.strokeStyle = "#18201f";
+ context.lineWidth = 2;
+ context.beginPath();
+ rows.forEach((row, index) => {
+ const pointX = x(index);
+ const pointY = y(values[index]);
+ if (index === 0) context.moveTo(pointX, pointY);
+ else context.lineTo(pointX, pointY);
+ });
+ context.stroke();
+
+ const tickStep = Math.max(1, Math.ceil((rows.length - 1) / 5));
+ context.textAlign = "center";
+ context.textBaseline = "top";
+ rows.forEach((row, index) => {
+ if (index % tickStep !== 0 && index !== rows.length - 1) return;
+ context.fillStyle = "#5d6966";
+ context.fillText(formatDate(row.date, dateShort), x(index), height - padding.bottom + 12);
+ });
+
+ if (rows.length <= 14) {
+ rows.forEach((row, index) => {
+ context.beginPath();
+ context.arc(x(index), y(values[index]), 3, 0, Math.PI * 2);
+ context.fillStyle = "#b84a2a";
+ context.fill();
+ context.strokeStyle = "#fffdf8";
+ context.lineWidth = 1;
+ context.stroke();
+ });
+ }
+}
+
+function updateChart(snapshots) {
+ const rows = Array.isArray(snapshots) ? snapshots : [];
+ state.chartRows = rows;
+ renderChartTable(rows);
+ renderChartMaturity();
+
+ if (!rows.length) {
+ elements.chartWrap.hidden = true;
+ elements.chartEmpty.hidden = false;
+ elements.chartSummary.textContent = "아직 선택한 기간의 자산 기록이 없습니다.";
+ return;
+ }
+
+ const first = rows[0];
+ const last = rows.at(-1);
+ const change = Number(last.total_value || 0) - Number(first.total_value || 0);
+ elements.chartSummary.textContent = `${formatDate(first.date, dateShort)}부터 ${formatDate(last.date, dateShort)}까지 ${change >= 0 ? "+" : ""}${formatWon(change)} · 최근 TWR ${formatPercent(last.cumulative_return)}`;
+ elements.chartWrap.hidden = false;
+ elements.chartEmpty.hidden = true;
+ drawChart(rows);
+}
+
+async function refreshChart() {
+ ensureChartAccountOptions();
+ state.chartAccount = elements.chartAccount.value;
+ syncChartQuery();
+ try {
+ const data = await fetchJson(`/api/snapshots?days=${state.chartDays}&account_key=${encodeURIComponent(state.chartAccount || "")}`, {
+ timeout: 15_000,
+ key: "chart",
+ });
+ updateChart((data && data.snapshots) || []);
+ } catch {
+ elements.chartSummary.textContent = "성과 기록을 불러오지 못했습니다. 연결을 확인한 뒤 다시 시도하세요.";
+ elements.chartWrap.hidden = true;
+ elements.chartEmpty.hidden = false;
+ elements.chartEmpty.innerHTML = "성과 데이터를 확인할 수 없습니다.기존 장부는 변경되지 않았습니다. 잠시 후 다시 확인하세요.";
+ }
+}
+
+const verdictCopy = {
+ PASS_CANDIDATE: ["검토 준비", "positive"],
+ FAIL_REVIEW: ["재점검 필요", "negative"],
+ WAIT: ["관찰 중", "muted"],
+};
+
+function renderEvaluations(evaluations) {
+ const items = Array.isArray(evaluations) ? evaluations : [];
+ state.evaluations = items;
+ elements.basketEval.setAttribute("aria-busy", "false");
+ if (!items.length) {
+ elements.basketEval.innerHTML = '검토 중인 포트폴리오가 없습니다.
';
+ renderDecision();
+ return;
+ }
+
+ const basketByName = new Map((state.baskets || []).map((basket) => [basket.basket, basket]));
+ const ordered = [...items].sort((left, right) => (
+ Number(Boolean(basketByName.get(right.basket)?.is_primary))
+ - Number(Boolean(basketByName.get(left.basket)?.is_primary))
+ ));
+ elements.basketEval.innerHTML = `${ordered.map((item) => {
+ const basket = basketByName.get(item.basket);
+ const displayName = basket?.display_name || item.basket;
+ const isPrimary = Boolean(basket?.is_primary);
+ const progressDays = Number(item.progress_days || 0);
+ const minimumDays = Math.max(1, Number(item.min_trading_days || 60));
+ const progress = Math.min(100, Math.round((progressDays / minimumDays) * 100));
+ const coverage = item.snapshot_coverage == null ? null : Math.round(Number(item.snapshot_coverage) * 100);
+ const [copy, tone] = verdictCopy[item.verdict] || ["관찰 중", "muted"];
+ const issues = (item.issues || []).slice(0, 3);
+ return `-
+
${escapeHtml(displayName)}${isPrimary ? '주력' : ""}${copy}
+
+ ${progressDays}/${minimumDays} 영업일${coverage == null ? "기록 누락 확인 중" : (coverage >= 100 ? "기록 누락 없음" : `기록 누락 ${Math.max(0, 100 - coverage)}%`)}
+ ${issues.length ? `${issues.map((issue) => `- ${escapeHtml(issue)}
`).join("")}
` : ""}
+ `;
+ }).join("")}
`;
+ renderChartMaturity();
+ renderDecision();
+}
+
+function trustCard(label, value, stateName = "info", support = "") {
+ return `${escapeHtml(label)}${value}${support ? `${escapeHtml(support)}` : ""}
`;
+}
+
+function marketCopy(regime) {
+ const value = String(regime || "").toLowerCase();
+ if (value === "bullish") return ["안정", "ok", "신규 매수 허용 구간"];
+ if (value === "bearish") return ["방어", "warning", "신규 매수 제한 가능"];
+ if (value === "caution") return ["주의", "warning", "포지션 규모 축소 구간"];
+ return ["확인 불가", "warning", "시장 상태 데이터 없음"];
+}
+
+function strategyCopy(strategy) {
+ const value = String(strategy || "").toLowerCase();
+ if (value === "scoring") return "종합 점수형";
+ if (value === "basket_rebalance") return "포트폴리오 리밸런싱";
+ return strategy || "—";
+}
+
+function signalCopy(signal) {
+ const value = String(signal || "").toUpperCase();
+ return ({ BUY: "매수", SELL: "매도", HOLD: "대기" })[value] || signal || "—";
+}
+
+function signalSourceCopy(source) {
+ const value = String(source || "").toLowerCase();
+ return ({ pre_market: "장 시작 전", intraday: "장중", post_market: "장 마감 후" })[value] || source || "—";
+}
+
+function formatAgeMinutes(minutes) {
+ if (minutes == null) return "기록 없음";
+ if (minutes < 60) return `${minutes}분 전`;
+ if (minutes < 1_440) return `${Math.round(minutes / 60)}시간 전`;
+ return `${Math.round(minutes / 1_440)}일 전`;
+}
+
+function renderRuntime(runtime) {
+ state.runtime = runtime || null;
+ state.runtimeStatus = runtime && runtime.trading_halt ? "ready" : "error";
+ updateSyncIndicator();
+ elements.runtimeOps.setAttribute("aria-busy", "false");
+ if (!runtime) {
+ elements.haltGuidance.hidden = true;
+ elements.runtimeOps.innerHTML = [
+ trustCard("거래 안전 상태", "확인 불가", "warning", "거래 중지 상태를 읽지 못함"),
+ trustCard("시장 환경", "확인 불가", "warning", "최근 데이터 없음"),
+ trustCard("자동 운용", "확인 불가", "warning", "스케줄러 상태 없음"),
+ trustCard("증권사 연결", "확인 불가", "warning", "요청 통계 없음"),
+ trustCard("데이터 기준", "확인 불가", "warning", "잠시 후 다시 확인"),
+ ].join("");
+ elements.runtimeMeta.textContent = "일부 운영 정보를 불러오지 못했습니다. 자산 장부는 변경되지 않았습니다.";
+ renderSignals(null, null);
+ renderWsGap(null);
+ renderDecision();
+ return;
+ }
+
+ const halt = runtime.trading_halt;
+ const haltKnown = Boolean(halt && typeof halt.halted === "boolean");
+ const halted = Boolean(halt && halt.halted);
+ const [market, marketState, marketSupport] = marketCopy(runtime.market_regime && runtime.market_regime.regime);
+ const loop = runtime.loop_metrics;
+ const kis = runtime.kis_stats;
+ const updatedAt = runtime.runtime_file_updated_at;
+ const ageMinutes = updatedAt ? Math.max(0, Math.round((Date.now() - (parseDate(updatedAt)?.getTime() || Date.now())) / 60_000)) : null;
+ const freshnessState = ageMinutes == null ? "warning" : (ageMinutes > 30 ? "warning" : "ok");
+ const runtimeIsFresh = ageMinutes != null && ageMinutes <= 720;
+ const loopElapsed = loop && loop.recent_avg_elapsed_s != null
+ ? `${Number(loop.recent_avg_elapsed_s).toFixed(1)}초`
+ : null;
+ const loopValue = loopElapsed
+ ? (runtimeIsFresh ? loopElapsed : "기록 오래됨")
+ : "기록 없음";
+ const loopSupport = loopElapsed
+ ? (runtimeIsFresh ? "최근 루프 평균" : `최근 루프 ${loopElapsed} · ${formatAgeMinutes(ageMinutes)}`)
+ : "스케줄러 상태 없음";
+ const kisValue = kis && kis.minute_utilization_pct != null
+ ? `${Number(kis.minute_utilization_pct).toFixed(1)}%`
+ : null;
+ const kisCard = state.mode === "paper"
+ ? trustCard("증권사 연결", "모의 운용", "info", "실계좌 연결 대상 아님")
+ : trustCard("증권사 연결", kisValue ? (runtimeIsFresh ? kisValue : "기록 오래됨") : "기록 없음", kis && runtimeIsFresh ? "info" : "warning", kisValue ? (runtimeIsFresh ? "분당 요청 한도 사용률" : `최근 사용률 ${kisValue} · ${formatAgeMinutes(ageMinutes)}`) : "요청 통계 없음");
+
+ elements.runtimeOps.innerHTML =
+ trustCard("거래 안전 상태", !haltKnown ? "확인 불가" : (halted ? "거래 중지" : "운용 가능"), !haltKnown ? "warning" : (halted ? "error" : "ok"), !haltKnown ? "거래 중지 상태를 읽지 못함" : (halted ? (halt.reason || "운영자 확인 필요") : "거래 중지 없음")) +
+ trustCard("시장 환경", market, marketState, marketSupport) +
+ trustCard("자동 운용", loopValue, loop && runtimeIsFresh ? "ok" : "warning", loopSupport) +
+ kisCard +
+ trustCard("데이터 기준", formatAgeMinutes(ageMinutes), freshnessState, updatedAt ? formatDate(updatedAt, dateTime) : "스케줄러 데이터 없음");
+
+ const meta = [];
+ if (runtime.strategy) meta.push(`운용 전략 ${strategyCopy(runtime.strategy)}`);
+ if (updatedAt) meta.push(`스케줄러 마지막 기록 ${formatDate(updatedAt, dateTime)}`);
+ elements.runtimeMeta.textContent = meta.join(" · ");
+ elements.haltGuidance.hidden = !halted;
+ if (halted) {
+ $("haltGuidanceReason").textContent = halt.reason || "신규 매수는 명시적으로 해제하기 전까지 차단됩니다.";
+ }
+ renderSignals(runtime.signals_today, runtime.signals_date);
+ renderWsGap(runtime);
+ renderDecision();
+}
+
+function renderSignals(signals, signalsDate) {
+ const table = $("signalsTableWrap");
+ const empty = $("signalEmpty");
+ const error = $("signalError");
+ const count = $("signalCount");
+ if (signals == null) {
+ table.hidden = true;
+ empty.hidden = true;
+ error.hidden = false;
+ count.textContent = "확인 불가";
+ return;
+ }
+ const isToday = !signalsDate || signalsDate === localIsoDate();
+ const rows = isToday && Array.isArray(signals) ? signals : [];
+ count.textContent = `${won.format(rows.length)}건`;
+ table.hidden = !rows.length;
+ empty.hidden = Boolean(rows.length);
+ error.hidden = true;
+ empty.textContent = isToday
+ ? "오늘 기록된 신호가 없습니다. 이상 상태가 아닙니다."
+ : `오늘 기록된 신호가 없습니다. 마지막 신호 기록은 ${formatDate(signalsDate)}입니다.`;
+ $("signalRows").innerHTML = rows.map((signal) => `
+
+ |
+ ${escapeHtml(signal.symbol || "—")} |
+ ${escapeHtml(signalCopy(signal.signal))} |
+ ${Number.isFinite(Number(signal.score)) ? Number(signal.score).toFixed(2) : "—"} |
+ ${escapeHtml(signalSourceCopy(signal.source))} |
+
`).join("");
+}
+
+function renderWsGap(runtime) {
+ const gap = runtime && runtime.ws_gap;
+ const summary = $("wsGapSummary");
+ const table = $("wsGapTableWrap");
+ const empty = $("wsGapEmpty");
+ const unavailable = $("wsGapNA");
+ if (!gap || !gap.available) {
+ summary.innerHTML = trustCard("웹소켓", "정보 없음", "warning", "스케줄러 기록 대기");
+ table.hidden = true;
+ empty.hidden = true;
+ unavailable.hidden = false;
+ return;
+ }
+
+ const gaps = gap.recent_gaps || [];
+ unavailable.hidden = true;
+ summary.innerHTML =
+ trustCard("웹소켓 상태", gap.is_connected ? "연결됨" : "연결 끊김", gap.is_connected ? "ok" : "error") +
+ trustCard("최근 공백", `${won.format(Number(gap.total_gap_count || 0))}건`, gap.total_gap_count > 0 ? "warning" : "ok");
+ table.hidden = !gaps.length;
+ empty.hidden = Boolean(gaps.length);
+ $("wsGapRows").innerHTML = [...gaps].reverse().map((item) => `
+
+ | ${escapeHtml(formatDate(item.disconnect_at, dateTime))} |
+ ${escapeHtml(formatDate(item.reconnect_at, dateTime))} |
+ ${escapeHtml(`${Number(item.gap_seconds || 0).toFixed(1)}초`)} |
+ ${escapeHtml((item.affected_symbols || []).join(", ") || "—")} |
+ ${item.rest_backfill_performed ? `${won.format(Number(item.rest_backfill_count || 0))}건` : "미수행"} |
+ ${item.blackswan_cooldown_triggered ? '안전 정지' : (item.blackswan_checked ? "정상" : "—")} |
+
`).join("");
+}
+
+function renderLegacy(portfolio) {
+ state.legacy = portfolio || null;
+ if (!portfolio) {
+ $("summary").innerHTML = trustCard("레거시 계정", "확인 불가", "warning");
+ $("positionsWrap").hidden = true;
+ $("noPositions").hidden = false;
+ return;
+ }
+ $("summary").innerHTML =
+ trustCard("총 평가금", formatWon(portfolio.total_value), "info") +
+ trustCard("총 수익률", formatPercent(portfolio.total_return), Number(portfolio.total_return) >= 0 ? "ok" : "error") +
+ trustCard("현금", formatWon(portfolio.cash), "info") +
+ trustCard("실현 손익", formatWon(portfolio.realized_pnl), Number(portfolio.realized_pnl) >= 0 ? "ok" : "error") +
+ trustCard("최대 낙폭", formatPercent(-Math.abs(Number(portfolio.mdd || 0)), { sign: false }), "warning") +
+ trustCard("보유 종목", `${won.format(Number(portfolio.position_count || 0))}개`, "info");
+
+ const positions = portfolio.positions || [];
+ $("positionsWrap").hidden = !positions.length;
+ $("noPositions").hidden = Boolean(positions.length);
+ $("positions").innerHTML = positions.map((position) => `
+
+ | ${escapeHtml(position.symbol || "—")} |
+ ${won.format(Number(position.quantity || 0))} |
+ ${escapeHtml(formatWon(position.avg_price))} |
+ ${escapeHtml(formatWon(position.current_price))} |
+ ${escapeHtml(formatWon(position.current_value))} |
+ ${escapeHtml(formatPercent(position.pnl_rate))} |
+
`).join("");
+}
+
+function latestSnapshotDate() {
+ const basket = primaryBasket() || sortedBaskets().find((item) => item.snapshot);
+ return basket && basket.snapshot ? basket.snapshot.date : null;
+}
+
+function currentMonthContributionState(basket) {
+ if (!basket || !basket.contribution_plan?.enabled) return "not-planned";
+ if (state.flowStatus.get(basket.basket) !== "ready") return "unknown";
+ const now = new Date();
+ const recorded = (state.flows.get(basket.basket) || []).some((flow) => {
+ const when = parseDate(flow.occurred_at);
+ return when && when.getFullYear() === now.getFullYear() && when.getMonth() === now.getMonth();
+ });
+ return recorded ? "recorded" : "empty";
+}
+
+function runtimeAgeMinutes() {
+ const updatedAt = state.runtime && state.runtime.runtime_file_updated_at;
+ const parsed = parseDate(updatedAt);
+ return parsed ? Math.max(0, Math.round((Date.now() - parsed.getTime()) / 60_000)) : null;
+}
+
+function setDecision({ title, description, meta = "", action = null, actionLabel = "확인하기" }) {
+ elements.decisionTitle.textContent = title;
+ elements.decisionDescription.textContent = description;
+ elements.decisionMeta.innerHTML = meta;
+ elements.decisionAction.hidden = !action;
+ elements.decisionAction.dataset.action = action || "";
+ elements.decisionAction.textContent = actionLabel;
+}
+
+function renderDecision() {
+ const baskets = state.baskets;
+ const halt = state.runtime && state.runtime.trading_halt;
+ const latest = latestSnapshotDate();
+ const ageDays = calendarAgeDays(latest);
+ const issues = (state.evaluations || []).flatMap((item) => item.issues || []);
+ const primary = primaryBasket();
+ const contributionState = currentMonthContributionState(primary);
+ const modeCopy = state.mode === "live" ? "실전 운용" : (state.mode === "paper" ? "모의 운용" : "모드 확인 불가");
+ const meta = `${escapeHtml(modeCopy)}${latest ? ` · 최근 자산 기록 ${escapeHtml(formatDate(latest))}` : " · 자산 기록 없음"}`;
+
+ if (halt && halt.halted) {
+ setDecision({
+ title: "거래가 안전하게 중지되어 있습니다",
+ description: halt.reason || "체결 또는 장부 상태를 확인하기 전까지 신규 주문을 막고 있습니다.",
+ meta,
+ action: "operations",
+ actionLabel: "운용 상태 보기",
+ });
+ return;
+ }
+ if (state.coreError) {
+ setDecision({
+ title: "자산 데이터를 확인할 수 없습니다",
+ description: "이전 화면을 최신 데이터로 표시하지 않았습니다. 연결을 확인한 뒤 다시 시도하세요.",
+ meta: state.lastCoreSuccess ? `마지막 성공 ${escapeHtml(dateTime.format(state.lastCoreSuccess))}` : "아직 성공한 갱신이 없습니다.",
+ action: "retry",
+ actionLabel: "지금 다시 확인",
+ });
+ return;
+ }
+ if (state.runtimeStatus === "loading") {
+ setDecision({
+ title: "거래 안전 상태를 확인하고 있습니다",
+ description: "거래 중지 여부와 자동 운용 기록을 확인한 뒤 오늘의 판단을 표시합니다.",
+ meta,
+ });
+ return;
+ }
+ if (state.runtimeStatus === "error") {
+ setDecision({
+ title: "거래 안전 상태를 확인할 수 없습니다",
+ description: "거래 중지 여부가 확인되기 전에는 적립 기록이나 운용 판단을 진행하지 마세요.",
+ meta,
+ action: "retry",
+ actionLabel: "안전 상태 다시 확인",
+ });
+ return;
+ }
+ if (baskets && !baskets.length) {
+ setDecision({
+ title: "첫 모의 운용 포트폴리오를 연결하세요",
+ description: "안전 기본값을 유지한 채 포트폴리오를 활성화하고 첫 모의 운용 기록을 만들어야 합니다.",
+ meta: "설정 → 모의 운용 1회 → 첫 자산 기록 확인",
+ action: "portfolio",
+ actionLabel: "시작 순서 보기",
+ });
+ return;
+ }
+ if (baskets && baskets.length && !latest) {
+ setDecision({
+ title: "첫 모의 운용 기록을 기다리고 있습니다",
+ description: "모의 운용을 한 번 실행하면 원금, 자산, 운용 수익률을 분리해 볼 수 있습니다.",
+ meta,
+ action: "portfolio",
+ actionLabel: "포트폴리오 확인",
+ });
+ return;
+ }
+ if (ageDays != null && ageDays > 4) {
+ setDecision({
+ title: "자산 기록이 오래되었습니다",
+ description: `마지막 자산 기록이 ${ageDays}일 전입니다. 자동 운용이 중단됐을 수 있으니 상태를 확인하세요.`,
+ meta,
+ action: "operations",
+ actionLabel: "운용 상태 확인",
+ });
+ return;
+ }
+ const runtimeAge = runtimeAgeMinutes();
+ if (runtimeAge == null || runtimeAge > 720) {
+ setDecision({
+ title: "자동 운용 기록이 오래되었습니다",
+ description: runtimeAge == null
+ ? "최근 스케줄러 기록이 없습니다. 오늘 사이클이 실행됐는지 먼저 확인하세요."
+ : `마지막 자동 운용 기록이 ${formatAgeMinutes(runtimeAge)}입니다. 적립보다 실행 상태를 먼저 확인하세요.`,
+ meta,
+ action: "operations",
+ actionLabel: "운용 기록 확인",
+ });
+ return;
+ }
+ if (issues.length) {
+ setDecision({
+ title: "확인할 운영 항목이 있습니다",
+ description: `${issues.length}개 항목을 검토해야 합니다. 실전 전환은 계속 잠긴 상태입니다.`,
+ meta,
+ action: "review",
+ actionLabel: "검토 항목 보기",
+ });
+ return;
+ }
+ if (contributionState === "unknown") {
+ setDecision({
+ title: "적립 기록을 확인할 수 없습니다",
+ description: "조회 상태가 확인되기 전에는 같은 적립금을 다시 기록하지 마세요.",
+ meta,
+ action: "retry",
+ actionLabel: "적립 기록 다시 확인",
+ });
+ return;
+ }
+ if (contributionState === "empty") {
+ const plannedAmount = Number(primary.contribution_plan?.amount || 0);
+ setDecision({
+ title: "이번 달 실제 입금 여부를 확인하세요",
+ description: `${plannedAmount > 0 ? `운용 기준은 월 ${formatWon(plannedAmount)}입니다. ` : ""}실제 입금 또는 모의 적립이 완료된 경우에만 장부에 기록하세요.`,
+ meta,
+ action: "deposit",
+ actionLabel: "적립금 기록",
+ });
+ return;
+ }
+ setDecision({
+ title: "오늘은 할 일이 없습니다",
+ description: "계획을 유지하며 기록을 더 쌓는 중입니다. 매일 시세를 확인하거나 전략을 바꿀 필요가 없습니다.",
+ meta,
+ });
+}
+
+function updateDepositCopy() {
+ const description = $("depositDescription");
+ if (!description) return;
+ description.textContent = state.mode === "live"
+ ? "실제 계좌에 입금이 완료된 뒤 같은 금액을 장부에 기록하세요. 주문은 실행되지 않지만 실전 성과 계산에 반영됩니다."
+ : "모의 적립금은 수익이 아니므로 입출금 제외 수익률 계산에서 분리됩니다. 기록 전 포트폴리오와 금액을 다시 살펴보세요.";
+}
+
+function resetDepositForm() {
+ state.depositConfirming = false;
+ state.depositRequestId = null;
+ elements.depositForm.reset();
+ elements.depositFields.hidden = false;
+ elements.depositConfirm.hidden = true;
+ elements.depositBack.hidden = true;
+ elements.depositSubmit.textContent = "내용 확인";
+ elements.depositSubmit.disabled = false;
+ elements.depositError.hidden = true;
+ elements.depositError.textContent = "";
+ document.querySelectorAll("[data-amount]").forEach((button) => button.setAttribute("aria-pressed", "false"));
+}
+
+function openDeposit() {
+ if (!canRecordDeposit()) {
+ showToast("장부와 거래 안전 상태를 먼저 다시 확인하세요.", "error");
+ return;
+ }
+ resetDepositForm();
+ const select = $("depBasket");
+ const baskets = sortedBaskets();
+ select.innerHTML = baskets.map((basket) => ``).join("");
+ const primary = primaryBasket();
+ if (primary) select.value = primary.basket;
+ updateDepositCopy();
+ if (!elements.depositDialog.open) elements.depositDialog.showModal();
+ window.setTimeout(() => select.focus(), 0);
+}
+
+function closeDeposit() {
+ if (elements.depositDialog.open) elements.depositDialog.close();
+}
+
+function showDepositError(message, field = null) {
+ elements.depositError.textContent = message;
+ elements.depositError.hidden = false;
+ if (field) field.focus();
+}
+
+function depositValues() {
+ const basket = $("depBasket").value;
+ const amount = Number($("depAmount").value);
+ const note = $("depNote").value.trim();
+ return { basket, amount, note };
+}
+
+function showDepositConfirmation(values) {
+ state.depositRequestId = window.crypto?.randomUUID
+ ? window.crypto.randomUUID()
+ : `deposit-${Date.now()}-${Math.random().toString(16).slice(2)}`;
+ const selected = $("depBasket").selectedOptions[0];
+ $("confirmBasket").textContent = selected ? selected.textContent : values.basket;
+ $("confirmAmount").textContent = formatWon(values.amount);
+ $("confirmMode").textContent = state.mode === "live" ? "실전 장부" : "모의 장부";
+ elements.depositFields.hidden = true;
+ elements.depositConfirm.hidden = false;
+ elements.depositBack.hidden = false;
+ elements.depositSubmit.textContent = state.mode === "live" ? "실전 입금 기록" : "모의 적립금 기록";
+ elements.depositError.hidden = true;
+ state.depositConfirming = true;
+ elements.depositBack.focus();
+}
+
+function showDepositFields() {
+ state.depositConfirming = false;
+ state.depositRequestId = null;
+ elements.depositFields.hidden = false;
+ elements.depositConfirm.hidden = true;
+ elements.depositBack.hidden = true;
+ elements.depositSubmit.textContent = "내용 확인";
+ $("depBasket").focus();
+}
+
+async function submitDeposit(values) {
+ elements.depositSubmit.disabled = true;
+ elements.depositSubmit.textContent = "기록하는 중…";
+ elements.depositError.hidden = true;
+ try {
+ const data = await fetchJson("/api/deposit", {
+ timeout: 15_000,
+ key: "deposit",
+ options: {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ "X-Requested-With": "quant-dashboard",
+ "Idempotency-Key": state.depositRequestId,
+ },
+ body: JSON.stringify(values),
+ },
+ });
+ if (!data || !data.ok) throw new Error((data && data.error) || "기록에 실패했습니다.");
+ closeDeposit();
+ showToast(`${state.mode === "live" ? "실전 입금" : "모의 적립금"} ${formatWon(data.amount)}을 기록했습니다.`);
+ await refreshCore();
+ } catch (error) {
+ showDepositError(`기록하지 못했습니다. ${error.message || "연결을 확인한 뒤 다시 시도하세요."}`);
+ elements.depositSubmit.disabled = false;
+ elements.depositSubmit.textContent = state.mode === "live" ? "실전 입금 기록" : "모의 적립금 기록";
+ }
+}
+
+let toastTimer = null;
+function showToast(message, kind = "ok") {
+ const toast = $("toast");
+ window.clearTimeout(toastTimer);
+ toast.textContent = message;
+ toast.dataset.kind = kind;
+ toast.classList.add("show");
+ toastTimer = window.setTimeout(() => toast.classList.remove("show"), 5_000);
+}
+
+async function refreshCore() {
+ if (document.visibilityState === "hidden") return;
+ state.coreStatus = "loading";
+ updateSyncIndicator();
+ const basketTask = fetchJson("/api/baskets", { timeout: 12_000, key: "baskets" })
+ .then(async (data) => {
+ renderBasketTracks(data);
+ await Promise.allSettled([refreshFlows(), refreshChart()]);
+ return data;
+ });
+ const legacyTask = fetchJson("/api/portfolio", { timeout: 15_000, key: "legacy" })
+ .then(renderLegacy);
+
+ const results = await Promise.allSettled([basketTask, legacyTask]);
+ const basketResult = results[0];
+ if (basketResult.status === "rejected") {
+ state.coreError = basketResult.reason || new Error("바스켓 조회 실패");
+ state.coreStatus = "error";
+ elements.basketTracks.setAttribute("aria-busy", "false");
+ elements.basketTracks.innerHTML = '포트폴리오를 불러오지 못했습니다. 이전 데이터를 최신으로 표시하지 않았습니다.
';
+ } else if (results.some((result) => result.status === "rejected") || state.flowError) {
+ state.coreStatus = "partial";
+ } else {
+ state.coreStatus = "ready";
+ }
+ updateSyncIndicator();
+ renderDecision();
+}
+
+async function refreshSlow() {
+ if (document.visibilityState === "hidden") return;
+ if (!state.runtime) {
+ state.runtimeStatus = "loading";
+ updateSyncIndicator();
+ }
+ const evalTask = fetchJson("/api/basket_evaluation", { timeout: 30_000, key: "evaluation" })
+ .then((data) => renderEvaluations((data && data.evaluations) || []))
+ .catch(() => {
+ elements.basketEval.setAttribute("aria-busy", "false");
+ elements.basketEval.innerHTML = '모의 운용 검증 상태를 불러오지 못했습니다. 잠시 후 다시 확인하세요.
';
+ });
+ const runtimeTask = fetchJson("/api/runtime", { timeout: 30_000, key: "runtime" })
+ .then(renderRuntime)
+ .catch(() => renderRuntime(null));
+ await Promise.allSettled([evalTask, runtimeTask]);
+}
+
+async function refreshAll() {
+ await Promise.allSettled([refreshCore(), refreshSlow()]);
+}
+
+function wireEvents() {
+ elements.openDeposit.addEventListener("click", openDeposit);
+ $("closeDepositButton").addEventListener("click", closeDeposit);
+ $("depositCancelButton").addEventListener("click", closeDeposit);
+ elements.depositBack.addEventListener("click", showDepositFields);
+ $("retryButton").addEventListener("click", refreshAll);
+
+ elements.decisionAction.addEventListener("click", () => {
+ const action = elements.decisionAction.dataset.action;
+ if (action === "deposit") openDeposit();
+ else if (action === "retry") refreshAll();
+ else if (action) document.getElementById(action)?.scrollIntoView({ behavior: "smooth", block: "start" });
+ });
+
+ elements.chartAccount.addEventListener("change", () => {
+ state.chartAccount = elements.chartAccount.value;
+ refreshChart();
+ });
+
+ $("chartRange").addEventListener("click", (event) => {
+ const button = event.target.closest("button[data-days]");
+ if (!button) return;
+ state.chartDays = Number(button.dataset.days);
+ document.querySelectorAll("#chartRange button").forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
+ refreshChart();
+ });
+
+ document.querySelectorAll("[data-amount]").forEach((button) => {
+ button.addEventListener("click", () => {
+ $("depAmount").value = button.dataset.amount;
+ document.querySelectorAll("[data-amount]").forEach((item) => item.setAttribute("aria-pressed", String(item === button)));
+ $("depAmount").focus();
+ });
+ });
+
+ elements.depositForm.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ if (!canRecordDeposit()) {
+ showDepositError("장부와 거래 안전 상태를 최신으로 확인한 뒤 다시 시도하세요.");
+ updateDepositAvailability();
+ return;
+ }
+ const values = depositValues();
+ if (!state.depositConfirming) {
+ if (!values.basket) {
+ showDepositError("포트폴리오를 선택하세요.", $("depBasket"));
+ return;
+ }
+ if (!Number.isFinite(values.amount) || values.amount <= 0) {
+ showDepositError("0원보다 큰 금액을 입력하세요.", $("depAmount"));
+ return;
+ }
+ showDepositConfirmation(values);
+ return;
+ }
+ await submitDeposit(values);
+ });
+
+ elements.depositDialog.addEventListener("close", resetDepositForm);
+ elements.depositDialog.addEventListener("click", (event) => {
+ if (event.target !== elements.depositDialog) return;
+ const bounds = elements.depositDialog.getBoundingClientRect();
+ const inside = event.clientX >= bounds.left && event.clientX <= bounds.right && event.clientY >= bounds.top && event.clientY <= bounds.bottom;
+ if (!inside) closeDeposit();
+ });
+
+ window.addEventListener("online", refreshAll);
+ window.addEventListener("offline", () => {
+ state.coreError = new Error("오프라인");
+ state.coreStatus = "error";
+ updateSyncIndicator();
+ renderDecision();
+ });
+
+ document.addEventListener("visibilitychange", () => {
+ if (document.visibilityState === "visible") refreshAll();
+ });
+}
+
+async function boot() {
+ wireEvents();
+ if ("ResizeObserver" in window) {
+ new ResizeObserver(() => drawChart(state.chartRows)).observe(elements.chartWrap);
+ }
+ document.querySelectorAll("#chartRange button").forEach((button) => {
+ button.setAttribute("aria-pressed", String(Number(button.dataset.days) === state.chartDays));
+ });
+ await refreshAll();
+ window.setInterval(refreshCore, 30_000);
+ window.setInterval(refreshSlow, 60_000);
+}
+
+boot();
diff --git a/monitoring/static/nungum-symbol.svg b/monitoring/static/nungum-symbol.svg
new file mode 100644
index 00000000..a1fb743b
--- /dev/null
+++ b/monitoring/static/nungum-symbol.svg
@@ -0,0 +1,6 @@
+
diff --git a/monitoring/templates/dashboard.html b/monitoring/templates/dashboard.html
new file mode 100644
index 00000000..ba270063
--- /dev/null
+++ b/monitoring/templates/dashboard.html
@@ -0,0 +1,314 @@
+
+
+
+
+
+
+
+ 눈금 NUNGUM — 오래 투자하기 위한 기준과 기록
+
+
+
+
+
+
+
+
+ 본문으로 바로가기
+
+
+
+
+
+
+
오늘의 운용 판단
+
현재 상태를 확인하고 있습니다
+
자산 기록과 안전 상태를 불러오는 중입니다.
+
+
+
+
+
+
+
- 현재 자산
+ - —
+
+
+
- 누적 원금
+ - —
+
+
+
- 원금 대비 손익
+ - —
+
+
+
- 현금 비중
+ - —
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 시스템이 처리한 오늘의 기록
+ 매매 신호와 자동 운용 이력
+
+ 0건
+
+
+
+
오늘 기록된 신호가 없습니다. 이상 상태가 아닙니다.
+
운용 기록을 불러오지 못했습니다. 잠시 후 다시 확인하세요.
+
+
+
+
+
+
+ 고급 진단
+ KIS 요청, 웹소켓, 레거시 계정
+
+
+
+
+
+
최근 연결 공백이 없습니다.
+
웹소켓 정보를 아직 받지 못했습니다.
+
+
레거시 기본 계정
+
+
+
레거시 계정에 보유 종목이 없습니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/monitoring/web_dashboard.py b/monitoring/web_dashboard.py
index ee78eec0..2b9c48e4 100644
--- a/monitoring/web_dashboard.py
+++ b/monitoring/web_dashboard.py
@@ -1,12 +1,17 @@
"""
-실시간 웹 대시보드
-- 콘솔 대시보드(monitoring/dashboard.py)를 확장한 웹 UI
-- 포트폴리오 요약·포지션·스냅샷 추이를 실시간(폴링)으로 표시
+눈금(NUNGUM) 웹 대시보드.
+
+대시보드는 장부와 런타임 상태를 읽어 사용자가 오늘 해야 할 일, 장기 성과,
+실전 전환 준비도를 한 화면에서 이해하도록 돕는다. 웹에서 가능한 쓰기는
+적립금 기록뿐이며 매매와 설정 변경은 의도적으로 제공하지 않는다.
"""
from __future__ import annotations
from datetime import datetime
+import ipaddress
+from pathlib import Path
+import re
from typing import Optional
try:
@@ -16,14 +21,17 @@
from loguru import logger
from config.config_loader import Config
-from monitoring.dashboard import Dashboard
from database.repositories import get_portfolio_snapshots
+from monitoring.dashboard import Dashboard
-# 기본 바인드 주소·포트 (settings.yaml dashboard 섹션으로 오버라이드 가능)
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8080
+_DASHBOARD_DIR = Path(__file__).resolve().parent
+_TEMPLATE_PATH = _DASHBOARD_DIR / "templates" / "dashboard.html"
+_STATIC_PATH = _DASHBOARD_DIR / "static"
+
def _require_aiohttp_web():
if web is None:
@@ -31,33 +39,37 @@ def _require_aiohttp_web():
return web
-def _serialize_snapshots(df):
- """DataFrame 스냅샷을 JSON 직렬화 가능한 리스트로 변환.
+def _active_ledger_mode(config=None) -> str:
+ """현재 설정의 장부 모드를 paper/live 두 값으로 정규화한다."""
+ cfg = config or Config.get()
+ return "live" if str(cfg.trading.get("mode", "paper")).lower() == "live" else "paper"
+
- 날짜형은 컬럼을 특정하지 않고 전부 문자열화한다 — 'date'만 처리하던 시절
- created_at 컬럼 추가(일간 수익률 경계용)로 pd.Timestamp가 그대로 새어나가
- /api/snapshots가 매 폴링 500이 나고 수익률 차트가 조용히 죽었다(빈 DF만
- 쓰는 테스트는 통과해서 못 잡던 회귀).
- """
+def _serialize_snapshots(df):
+ """DataFrame 스냅샷을 JSON 직렬화 가능한 리스트로 변환한다."""
if df.empty:
return []
out = []
for _, row in df.iterrows():
- d = row.to_dict()
- for k, v in d.items():
- if hasattr(v, "strftime"): # date/datetime/pd.Timestamp
- d[k] = v.strftime("%Y-%m-%d %H:%M:%S") if k != "date" else v.strftime("%Y-%m-%d")
- elif hasattr(v, "item"): # numpy 타입 → Python 네이티브
- d[k] = v.item()
- out.append(d)
+ item = row.to_dict()
+ for key, value in item.items():
+ if hasattr(value, "strftime"):
+ item[key] = (
+ value.strftime("%Y-%m-%d")
+ if key == "date"
+ else value.strftime("%Y-%m-%d %H:%M:%S")
+ )
+ elif hasattr(value, "item"):
+ item[key] = value.item()
+ out.append(item)
return out
-_DASH = None # 폴링(10초)마다 Dashboard/PortfolioManager를 새로 만들면 초기화 INFO가 스팸이 된다
+_DASH = None
def get_portfolio_json(current_prices: Optional[dict] = None) -> dict:
- """현재 포트폴리오 요약을 JSON 친화적 dict로 반환"""
+ """레거시 기본 계정의 현재 포트폴리오 요약을 반환한다."""
global _DASH
config = Config.get()
if _DASH is None:
@@ -66,6 +78,7 @@ def get_portfolio_json(current_prices: Optional[dict] = None) -> dict:
summary = dash.portfolio_manager.get_portfolio_summary(current_prices or {})
return {
"timestamp": datetime.now().isoformat(),
+ "mode": _active_ledger_mode(config),
"initial_capital": dash.initial_capital,
"total_value": summary["total_value"],
"cash": summary["cash"],
@@ -81,26 +94,30 @@ def get_portfolio_json(current_prices: Optional[dict] = None) -> dict:
def get_snapshots_json(days: int = 30, account_key: Optional[str] = None) -> dict:
- """최근 N일 스냅샷을 JSON으로 반환"""
- df = get_portfolio_snapshots(days=days, account_key=account_key)
- return {"snapshots": _serialize_snapshots(df), "days": days}
+ """최근 N일 스냅샷을 활성 장부 모드에서 반환한다."""
+ config = Config.get()
+ ledger_mode = _active_ledger_mode(config)
+ df = get_portfolio_snapshots(
+ days=days,
+ account_key=account_key,
+ mode=ledger_mode,
+ )
+ return {
+ "snapshots": _serialize_snapshots(df),
+ "days": days,
+ "mode": ledger_mode,
+ }
def get_baskets_json() -> dict:
- """enabled 바스켓별 '내 돈' 요약 — 최신 스냅샷·원금(입금 포함)·배치율·보유 (DB 전용).
-
- 대시보드는 10초 폴링이므로 네트워크 조회를 섞지 않는다 — 평가금·수익률은
- 일일 사이클이 저장한 최신 스냅샷 값(TWR 반영), 보유는 DB 포지션(평균단가 기준).
- 적립식 계정(kr_pocket)의 핵심 질문 "내가 넣은 돈 대비 얼마"에 답하는 화면 데이터다.
- """
+ """활성 바스켓별 원금·평가금·배치율·보유 현황을 DB에서만 읽는다."""
+ from core.basket_deploy import effective_stock_fraction
from core.basket_rebalancer import BasketRebalancer, rebalance_live_strategy_id
- from database.repositories import (
- get_all_positions,
- get_cash_flow_total,
- )
from database.models import PortfolioSnapshot, get_session
+ from database.repositories import get_all_positions, get_cash_flow_total
config = Config.get()
+ ledger_mode = _active_ledger_mode(config)
baskets_cfg = BasketRebalancer._load_baskets_config()
global_capital = (config.risk_params.get("position_sizing") or {}).get(
"initial_capital", 10_000_000
@@ -108,80 +125,123 @@ def get_baskets_json() -> dict:
out = []
for name in BasketRebalancer.get_enabled_baskets():
- cfg = baskets_cfg.get(name) or {}
- key = rebalance_live_strategy_id(name)
- initial = float(cfg.get("initial_capital") or global_capital)
- deposits = float(get_cash_flow_total(account_key=key) or 0)
- principal = initial + deposits
+ basket_config = baskets_cfg.get(name) or {}
+ account_key = rebalance_live_strategy_id(name)
+ initial_capital = float(basket_config.get("initial_capital") or global_capital)
+ deposits_total = float(
+ get_cash_flow_total(account_key=account_key, mode=ledger_mode) or 0
+ )
+ principal = initial_capital + deposits_total
- # 최신 스냅샷 (mdd 포함해 직접 조회 — get_latest_snapshot_summary는 TWR용 최소 필드)
session = get_session()
try:
- snap = (
+ latest = (
session.query(PortfolioSnapshot)
- .filter(PortfolioSnapshot.account_key == key)
+ .filter(
+ PortfolioSnapshot.mode == ledger_mode,
+ PortfolioSnapshot.account_key == account_key,
+ )
.order_by(PortfolioSnapshot.date.desc())
.first()
)
snapshot = None
deployment_ratio = None
- if snap is not None:
- total = float(snap.total_value or 0)
- cash = float(snap.cash or 0)
- # 음수 클램프 — 헬스(run_health_check)와 동일 규칙(현금>총액 이상치 방어)
- deployment_ratio = (max(0.0, (total - cash) / total)) if total > 0 else None
+ if latest is not None:
+ total_value = float(latest.total_value or 0)
+ cash = float(latest.cash or 0)
+ deployment_ratio = (
+ max(0.0, (total_value - cash) / total_value)
+ if total_value > 0
+ else None
+ )
snapshot = {
- "date": str(snap.date)[:10],
- "total_value": total,
+ "date": str(latest.date)[:10],
+ "total_value": total_value,
"cash": cash,
- "cumulative_return": float(snap.cumulative_return or 0),
- "mdd": float(snap.mdd or 0),
+ "cumulative_return": float(latest.cumulative_return or 0),
+ "mdd": float(latest.mdd or 0),
}
finally:
session.close()
- # 설계 비중은 리밸런서·평가·헬스와 같은 단일 규칙을 쓴다 — 여기만 다르게
- # 계산하면 대시보드와 디스코드 카드가 서로 다른 '설계 %'를 보여준다.
- from core.basket_deploy import effective_stock_fraction
- design_fraction = effective_stock_fraction(cfg, config.risk_params)
-
+ holding_names = basket_config.get("holding_names") or {}
positions = [
{
- "symbol": p.symbol,
- "quantity": int(p.quantity or 0),
- "avg_price": float(p.avg_price or 0),
- "invested": float((p.quantity or 0) * (p.avg_price or 0)),
+ "symbol": position.symbol,
+ "name": holding_names.get(position.symbol),
+ "quantity": int(position.quantity or 0),
+ "avg_price": float(position.avg_price or 0),
+ "invested": float(
+ (position.quantity or 0) * (position.avg_price or 0)
+ ),
}
- for p in (get_all_positions(account_key=key) or [])
- if (p.quantity or 0) > 0
+ for position in (
+ get_all_positions(account_key=account_key, mode=ledger_mode) or []
+ )
+ if (position.quantity or 0) > 0
]
- out.append({
- "basket": name,
- "account_key": key,
- "display_name": cfg.get("name") or name,
- "initial_capital": initial,
- "deposits_total": deposits,
- "principal": principal,
- "snapshot": snapshot,
- "profit_vs_principal": (
- (snapshot["total_value"] - principal) if snapshot else None
- ),
- "deployment_ratio": deployment_ratio,
- "design_fraction": design_fraction,
- "positions": positions,
- })
- return {"baskets": out, "timestamp": datetime.now().isoformat()}
+ is_primary = bool(
+ basket_config.get("primary", name == "kr_pocket")
+ )
+ plan_config = basket_config.get("contribution_plan") or {}
+ contribution_plan = {
+ "enabled": bool(plan_config.get("enabled", False)),
+ "cadence": str(plan_config.get("cadence") or ""),
+ "amount": float(plan_config.get("amount") or 0),
+ }
+ out.append(
+ {
+ "basket": name,
+ "account_key": account_key,
+ "display_name": basket_config.get("name") or name,
+ "purpose": basket_config.get("purpose")
+ or ("월 적립 중심" if is_primary else "장기 관찰용"),
+ "is_primary": is_primary,
+ "contribution_plan": contribution_plan,
+ "initial_capital": initial_capital,
+ "deposits_total": deposits_total,
+ "principal": principal,
+ "snapshot": snapshot,
+ "profit_vs_principal": (
+ snapshot["total_value"] - principal if snapshot else None
+ ),
+ "deployment_ratio": deployment_ratio,
+ "design_fraction": effective_stock_fraction(
+ basket_config, config.risk_params
+ ),
+ "positions": positions,
+ }
+ )
+
+ return {
+ "baskets": out,
+ "mode": ledger_mode,
+ "timestamp": datetime.now().isoformat(),
+ }
+
+
+def _get_trading_halt_json() -> Optional[dict]:
+ """전역 HALT를 DB에서 매번 새로 읽어 JSON 형태로 반환한다."""
+ try:
+ from database.repositories import get_trading_halt_state
+
+ halt_state = get_trading_halt_state()
+ created_at = halt_state.get("created_at")
+ if hasattr(created_at, "isoformat"):
+ halt_state["created_at"] = created_at.isoformat()
+ return halt_state
+ except Exception as exc:
+ logger.debug("get_runtime_json trading_halt: {}", exc)
+ return None
def get_runtime_json() -> dict:
- """
- 시장 국면(실시간 조회) + 스케줄러가 기록한 신호·루프·블랙스완·KIS 통계(JSON 파일).
- 각 항목 실패 시 해당 필드만 null — 프론트에서 '조회 불가' 표시.
- """
+ """시장·스케줄러 상태를 수집한다. HALT는 응답 직전 다시 확인한다."""
out: dict = {
"timestamp": datetime.now().isoformat(),
"market_regime": None,
+ "trading_halt": None,
"signals_today": None,
"signals_date": None,
"strategy": None,
@@ -193,631 +253,121 @@ def get_runtime_json() -> dict:
"runtime_file_updated_at": None,
}
+ out["trading_halt"] = _get_trading_halt_json()
+
try:
- cfg = Config.get()
- from core.market_regime import check_market_regime
from core.data_collector import DataCollector
+ from core.market_regime import check_market_regime
- mr = check_market_regime(cfg, DataCollector())
+ config = Config.get()
+ regime = check_market_regime(config, DataCollector())
out["market_regime"] = {
- "regime": mr.get("regime"),
- "position_scale": mr.get("position_scale"),
- "allow_buys": mr.get("allow_buys"),
+ "regime": regime.get("regime"),
+ "position_scale": regime.get("position_scale"),
+ "allow_buys": regime.get("allow_buys"),
}
- except Exception as e:
- logger.debug("get_runtime_json market_regime: {}", e)
+ except Exception as exc:
+ logger.debug("get_runtime_json market_regime: {}", exc)
try:
from monitoring.dashboard_runtime_state import read_state
- st = read_state()
- out["runtime_file_updated_at"] = st.get("updated_at")
- _raw_sigs = st.get("signals_today")
- out["signals_today"] = _raw_sigs if isinstance(_raw_sigs, list) else []
- out["signals_date"] = st.get("signals_date")
- out["strategy"] = st.get("strategy")
- out["loop_metrics"] = st.get("loop_metrics")
- out["blackswan"] = st.get("blackswan")
- out["ws_gap"] = st.get("ws_gap")
- if st.get("kis_stats") is not None:
- out["kis_stats"] = st.get("kis_stats")
+ runtime_state = read_state()
+ out["runtime_file_updated_at"] = runtime_state.get("updated_at")
+ raw_signals = runtime_state.get("signals_today")
+ out["signals_today"] = raw_signals if isinstance(raw_signals, list) else []
+ out["signals_date"] = runtime_state.get("signals_date")
+ out["strategy"] = runtime_state.get("strategy")
+ out["loop_metrics"] = runtime_state.get("loop_metrics")
+ out["blackswan"] = runtime_state.get("blackswan")
+ out["ws_gap"] = runtime_state.get("ws_gap")
+ if runtime_state.get("kis_stats") is not None:
+ out["kis_stats"] = runtime_state.get("kis_stats")
out["kis_stats_source"] = "scheduler_file"
- except Exception as e:
- logger.debug("get_runtime_json read_state: {}", e)
+ except Exception as exc:
+ logger.debug("get_runtime_json read_state: {}", exc)
out["signals_today"] = None
- # KIS 통계 폴백(대시보드 프로세스에서 KISApi 신규 생성) 제거 — 레이트리미터
- # 상태가 인스턴스별이라 항상 0(아무것도 측정 안 함)에 폴링마다 초기화 로그만
- # 남겼다. 스케줄러 파일에 없으면 정직하게 '조회 불가'로 둔다.
return out
def _html_page() -> str:
- """대시보드 단일 페이지 HTML — 2026-07 UI 개편(벤토 그리드·다크 글래스·Pretendard).
-
- 원칙: ① 내 돈(바스켓 트랙)이 첫 화면 ② 웹의 쓰기 권한은 '입금 기록' 하나
- (매매·설정 변경은 웹에 두지 않는다) ③ 폴링 경로에 네트워크 조회 없음(DB 전용 API).
- """
- return """
-
-
-
-
- 퀀트 트레이더
-
-
-
-
-
-
-
-
퀀트 트레이더paper 운영
-
갱신 -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 웹소켓 갭 · 레거시 기본 계정
-
-
-
-
- | 끊김 | 재연결 | 갭(초) | 영향 종목 | REST 보충 | 블랙스완 |
-
-
-
-
갭 이벤트 없음
-
웹소켓 정보 없음
-
-
-
보유 종목 없음
-
-
-
-
-
-
-
-
-
-
적립 입금 기록
-
paper는 기록 = 입금. 입금은 수익률(TWR)이 중화하므로 성과가 왜곡되지 않습니다. 다음 사이클이 새 현금을 흡수합니다.
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-"""
+ """파일 기반 템플릿을 읽어 UI와 Python 데이터 계층을 분리한다."""
+ return _TEMPLATE_PATH.read_text(encoding="utf-8")
+
+
+def _api_error(label: str, exc: Exception, message: str) -> web.Response:
+ """내부 예외는 로그에만 남기고 브라우저에는 고정 문구만 반환한다."""
+ logger.exception("{}: {}", label, exc)
+ return web.json_response({"error": message}, status=500)
+
+
+async def _security_headers(request: web.Request, handler):
+ response = await handler(request)
+ response.headers["Cache-Control"] = "no-store"
+ 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; "
+ "img-src 'self' data:; connect-src 'self'; "
+ "object-src 'none'; base-uri 'none'; form-action 'self'; "
+ "frame-ancestors 'none'"
+ )
+ response.headers["Referrer-Policy"] = "no-referrer"
+ response.headers["X-Content-Type-Options"] = "nosniff"
+ response.headers["X-Frame-Options"] = "DENY"
+ return response
async def handle_index(_request: web.Request) -> web.Response:
- # aiohttp 3.13+: content_type에 charset을 섞으면 ValueError — 분리 인자로 전달.
- # (기존 표기는 메인 페이지 '/'를 500으로 죽이는 운영 결함이었다 — API만 검증하고
- # 페이지 서빙은 검증하지 않아 가려져 있었다.)
- return web.Response(text=_html_page(), content_type="text/html", charset="utf-8")
+ return web.Response(
+ text=_html_page(),
+ content_type="text/html",
+ charset="utf-8",
+ )
async def handle_api_portfolio(_request: web.Request) -> web.Response:
- # live 모드에서는 KIS 잔고 조회(동기 네트워크)가 섞일 수 있다 — 스레드로 격리.
import asyncio
try:
data = await asyncio.to_thread(get_portfolio_json)
return web.json_response(data)
- except Exception as e:
- logger.exception("API /api/portfolio 오류: {}", e)
- return web.json_response({"error": str(e)}, status=500)
+ except Exception as exc:
+ return _api_error(
+ "API /api/portfolio 오류", exc, "포트폴리오를 불러오지 못했습니다"
+ )
async def handle_api_baskets(_request: web.Request) -> web.Response:
- """바스켓 트랙 '내 돈' 요약 — DB 전용(네트워크 조회 없음), 10초 폴링 안전."""
try:
return web.json_response(get_baskets_json())
- except Exception as e:
- logger.exception("API /api/baskets 오류: {}", e)
- return web.json_response({"error": str(e)}, status=500)
+ except Exception as exc:
+ return _api_error(
+ "API /api/baskets 오류", exc, "포트폴리오를 불러오지 못했습니다"
+ )
async def handle_api_deposit(request: web.Request) -> web.Response:
- """적립 입금 기록 (POST {basket, amount, note?}) — CLI와 동일한 단일 검증 경로.
-
- 웹에서 가능한 쓰기는 이것 하나다(기록·조회까지가 웹의 권한 — 매매·설정 변경은
- 웹에 두지 않는다). occurred_at은 서버 시각 고정이라 소급 조작이 불가능하고,
- 금액 양수·바스켓 존재·TWR 체인 보호(마지막 스냅샷 이후) 검증은 공유 함수가 한다.
-
- CSRF 방어: 커스텀 헤더(X-Requested-With) 필수 — 루프백 바인딩이어도 브라우저
- 경유 cross-site 요청은 막지 못한다(악성 페이지가 text/plain fetch로 127.0.0.1에
- POST 가능, aiohttp request.json()은 Content-Type을 보지 않음). 커스텀 헤더는
- CORS preflight를 강제하는데 이 서버는 preflight에 응답하지 않으므로 외부
- 오리진에서는 실을 수 없다. 대시보드 프론트만 이 헤더를 보낸다.
- """
+ """적립금 기록. 커스텀 헤더로 cross-site 브라우저 요청을 차단한다."""
if request.headers.get("X-Requested-With") != "quant-dashboard":
return web.json_response(
- {"ok": False, "error": "대시보드 외 요청 차단(CSRF 방어)"}, status=403,
+ {"ok": False, "error": "대시보드 외 요청 차단(CSRF 방어)"},
+ status=403,
+ )
+ request_id = str(request.headers.get("Idempotency-Key") or "").strip()
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{15,63}", request_id):
+ return web.json_response(
+ {"ok": False, "error": "유효한 입금 요청 키가 필요합니다"}, status=400
)
try:
body = await request.json()
except Exception:
- return web.json_response({"ok": False, "error": "JSON 본문이 필요합니다"}, status=400)
+ return web.json_response(
+ {"ok": False, "error": "JSON 본문이 필요합니다"}, status=400
+ )
+
try:
from tools.record_deposit import record_basket_deposit
@@ -825,78 +375,103 @@ async def handle_api_deposit(request: web.Request) -> web.Response:
str(body.get("basket") or ""),
body.get("amount"),
note=str(body.get("note") or ""),
+ request_id=request_id,
)
if not result.get("ok"):
return web.json_response(result, status=400)
logger.info(
"웹 입금 기록: {} +{:,.0f}원 (누적 입금 {:,.0f}원)",
- result["account_key"], result["amount"], result["deposits_total"],
+ result["account_key"],
+ result["amount"],
+ result["deposits_total"],
)
return web.json_response(result)
- except Exception as e:
- logger.exception("API /api/deposit 오류: {}", e)
- return web.json_response({"ok": False, "error": str(e)}, status=500)
+ except Exception as exc:
+ logger.exception("API /api/deposit 오류: {}", exc)
+ return web.json_response(
+ {"ok": False, "error": "적립금을 기록하지 못했습니다"}, status=500
+ )
async def handle_api_cash_flows(request: web.Request) -> web.Response:
- """바스켓 입금 내역 (GET ?basket=) — 최근 12건."""
+ """선택한 바스켓의 최근 적립금 기록을 활성 장부 모드에서 반환한다."""
try:
from core.basket_rebalancer import rebalance_live_strategy_id
from database.repositories import get_recent_cash_flows
basket = request.query.get("basket") or ""
if not basket:
- return web.json_response({"error": "basket 파라미터 필요"}, status=400)
- key = rebalance_live_strategy_id(basket)
- return web.json_response({"basket": basket, "flows": get_recent_cash_flows(key)})
- except Exception as e:
- logger.exception("API /api/cash_flows 오류: {}", e)
- return web.json_response({"error": str(e)}, status=500)
+ return web.json_response(
+ {"error": "basket 파라미터 필요"}, status=400
+ )
+ ledger_mode = _active_ledger_mode()
+ account_key = rebalance_live_strategy_id(basket)
+ return web.json_response(
+ {
+ "basket": basket,
+ "mode": ledger_mode,
+ "flows": get_recent_cash_flows(
+ account_key, mode=ledger_mode
+ ),
+ }
+ )
+ except Exception as exc:
+ return _api_error(
+ "API /api/cash_flows 오류", exc, "적립 기록을 불러오지 못했습니다"
+ )
+
+
+_RUNTIME_CACHE: dict = {"at": 0.0, "data": None}
+_RUNTIME_TTL_SEC = 60.0
async def handle_api_runtime(_request: web.Request) -> web.Response:
- # get_runtime_json은 시장 국면 실시간 조회(동기 네트워크, 수십 초 가능)를 포함한다 —
- # 이벤트 루프에서 직접 부르면 그동안 '/'·내 자산·차트까지 전부 멈춘다(첫 로드 체감 저하).
- # 스레드로 내려 다른 엔드포인트는 즉시 응답하게 한다.
+ """느린 외부 시장 조회를 이벤트 루프 밖에서 실행하고 60초간 캐시한다."""
import asyncio
+ import time as _time
try:
- data = await asyncio.to_thread(get_runtime_json)
+ now = _time.monotonic()
+ if (
+ _RUNTIME_CACHE["data"] is not None
+ and now - _RUNTIME_CACHE["at"] < _RUNTIME_TTL_SEC
+ ):
+ cached = _RUNTIME_CACHE["data"]
+ else:
+ cached = await asyncio.to_thread(get_runtime_json)
+ _RUNTIME_CACHE["at"] = now
+ _RUNTIME_CACHE["data"] = cached
+ # HALT는 안전 판단의 현재값이므로 느린 시장 상태 캐시와 분리한다.
+ data = dict(cached)
+ data["trading_halt"] = await asyncio.to_thread(_get_trading_halt_json)
return web.json_response(data)
- except Exception as e:
- logger.exception("API /api/runtime 오류: {}", e)
- return web.json_response({"error": str(e)}, status=500)
+ except Exception as exc:
+ return _api_error(
+ "API /api/runtime 오류", exc, "안전 상태를 불러오지 못했습니다"
+ )
async def handle_api_snapshots(request: web.Request) -> web.Response:
try:
- days = int(request.query.get("days", 30))
- # 파라미터 '존재'와 '빈 값'을 구분한다: account_key=(빈)은 기본 계정('')의
- # 시계열을 뜻한다 — `or None`으로 강등하면 전 계정이 무필터로 섞여
- # 10M/30만 스케일이 한 차트에 뒤엉킨 톱니가 나온다.
+ requested_days = int(request.query.get("days", 30))
+ days = max(1, min(3650, requested_days))
raw_key = request.query.get("account_key")
account_key = raw_key if raw_key is not None else None
- data = get_snapshots_json(days=days, account_key=account_key)
- return web.json_response(data)
- except Exception as e:
- logger.exception("API /api/snapshots 오류: {}", e)
- return web.json_response({"error": str(e)}, status=500)
+ return web.json_response(
+ get_snapshots_json(days=days, account_key=account_key)
+ )
+ except Exception as exc:
+ return _api_error(
+ "API /api/snapshots 오류", exc, "성과 기록을 불러오지 못했습니다"
+ )
-# 평가 결과 캐시 (TTL 60초) — 수집기가 호출마다 TradingHours를 새로 만들어
-# 10초 폴링이면 INFO 로그 2줄×8,640회/일 스팸이 되고, holidays.yaml이 사라진
-# 환경에서는 pykrx 네트워크 갱신이 sync-in-async 핸들러를 매 폴링 블로킹할 수
-# 있다(잠재). 진행률은 하루 단위로 변하는 값이라 60초 캐시는 충분히 신선하다.
_BASKET_EVAL_CACHE: dict = {"at": 0.0, "data": None}
_BASKET_EVAL_TTL_SEC = 60.0
async def handle_api_basket_evaluation(_request: web.Request) -> web.Response:
- """바스켓 paper 운영 평가(승격 진행률) — 게이트와 같은 수집기라 판정이 동일하다.
-
- read-only. include_benchmark=False로 네트워크(KS11 조회)를 피한다 — 대시보드는
- 10초 폴링이므로 외부 조회를 섞으면 안 된다. 결과는 60초 TTL 캐시.
- """
+ """바스켓 paper 운영 평가를 읽기 전용으로 반환한다."""
import asyncio
import time as _time
@@ -904,20 +479,23 @@ def _collect_all() -> dict:
from core.basket_evaluation import collect_basket_paper_evaluation
from core.basket_rebalancer import BasketRebalancer
- out = []
+ evaluations = []
for name in BasketRebalancer.get_enabled_baskets():
result, basket_name = collect_basket_paper_evaluation(
- include_benchmark=False, basket_name=name,
+ include_benchmark=False,
+ basket_name=name,
)
- out.append({
- "basket": basket_name,
- "verdict": result.get("verdict"),
- "progress_days": result.get("progress_days"),
- "min_trading_days": result.get("min_trading_days"),
- "snapshot_coverage": result.get("snapshot_coverage"),
- "issues": result.get("issues", []),
- })
- return {"evaluations": out}
+ evaluations.append(
+ {
+ "basket": basket_name,
+ "verdict": result.get("verdict"),
+ "progress_days": result.get("progress_days"),
+ "min_trading_days": result.get("min_trading_days"),
+ "snapshot_coverage": result.get("snapshot_coverage"),
+ "issues": result.get("issues", []),
+ }
+ )
+ return {"evaluations": evaluations}
try:
now = _time.monotonic()
@@ -927,20 +505,21 @@ def _collect_all() -> dict:
):
return web.json_response(_BASKET_EVAL_CACHE["data"])
- # 수집기는 TradingHours 초기화·(잠재) pykrx 갱신 등 동기 작업 — 스레드로 내려
- # 캐시 미스 시에도 이벤트 루프가 다른 요청을 계속 처리하게 한다.
payload = await asyncio.to_thread(_collect_all)
_BASKET_EVAL_CACHE["at"] = now
_BASKET_EVAL_CACHE["data"] = payload
return web.json_response(payload)
- except Exception as e:
- logger.exception("API /api/basket_evaluation 오류: {}", e)
- return web.json_response({"error": str(e)}, status=500)
+ except Exception as exc:
+ return _api_error(
+ "API /api/basket_evaluation 오류",
+ exc,
+ "모의 운용 검증 상태를 불러오지 못했습니다",
+ )
def create_app() -> web.Application:
web_mod = _require_aiohttp_web()
- app = web_mod.Application()
+ app = web_mod.Application(middlewares=[web_mod.middleware(_security_headers)])
app.router.add_get("/", handle_index)
app.router.add_get("/api/portfolio", handle_api_portfolio)
app.router.add_get("/api/runtime", handle_api_runtime)
@@ -949,6 +528,12 @@ def create_app() -> web.Application:
app.router.add_post("/api/deposit", handle_api_deposit)
app.router.add_get("/api/cash_flows", handle_api_cash_flows)
app.router.add_get("/api/basket_evaluation", handle_api_basket_evaluation)
+ app.router.add_static(
+ "/static/",
+ path=str(_STATIC_PATH),
+ name="dashboard_static",
+ show_index=False,
+ )
return app
@@ -959,37 +544,69 @@ def _config_settings_dict(config) -> dict:
return settings if isinstance(settings, dict) else {}
-def resolve_dashboard_bind(host: Optional[str] = None, port: Optional[int] = None) -> tuple[str, int]:
+def resolve_dashboard_bind(
+ host: Optional[str] = None,
+ port: Optional[int] = None,
+) -> tuple[str, int]:
"""대시보드 바인드 주소를 해석한다. 기본은 로컬 루프백이다."""
try:
- cfg = Config.get()
- settings = _config_settings_dict(cfg)
- dash_cfg = (settings.get("dashboard") or {}) if isinstance(settings, dict) else {}
- host = host or str(dash_cfg.get("host") or "").strip() or DEFAULT_HOST
- port = port if port is not None else dash_cfg.get("port") or DEFAULT_PORT
+ config = Config.get()
+ settings = _config_settings_dict(config)
+ dashboard_config = settings.get("dashboard") or {}
+ host = (
+ host
+ or str(dashboard_config.get("host") or "").strip()
+ or DEFAULT_HOST
+ )
+ port = (
+ port
+ if port is not None
+ else dashboard_config.get("port") or DEFAULT_PORT
+ )
except Exception:
host = host or DEFAULT_HOST
port = port if port is not None else DEFAULT_PORT
- return host, int(port)
+ normalized_host = str(host).strip().lower()
+ try:
+ is_loopback = (
+ normalized_host == "localhost"
+ or ipaddress.ip_address(normalized_host).is_loopback
+ )
+ except ValueError:
+ is_loopback = False
+ if not is_loopback:
+ raise ValueError(
+ "웹 대시보드는 인증을 제공하지 않으므로 loopback 주소에만 바인딩할 수 있습니다"
+ )
+ return str(host), int(port)
-def run_web_dashboard(host: Optional[str] = None, port: Optional[int] = None):
- """웹 대시보드 서버 실행 (블로킹). host/port 미지정 시 config dashboard 섹션 또는 기본값 사용."""
+def run_web_dashboard(
+ host: Optional[str] = None,
+ port: Optional[int] = None,
+):
+ """웹 대시보드 서버를 실행한다."""
host, port = resolve_dashboard_bind(host=host, port=port)
web_mod = _require_aiohttp_web()
- app = create_app()
- logger.info("웹 대시보드 서버 시작: http://{}:{}/", host, port)
- web_mod.run_app(app, host=host, port=port)
+ logger.info("눈금 웹 대시보드 시작: http://{}:{}/", host, port)
+ web_mod.run_app(create_app(), host=host, port=port)
if __name__ == "__main__":
import argparse
- p = argparse.ArgumentParser(description="퀀트 트레이더 웹 대시보드")
- p.add_argument("--host", default=None, help="바인드 주소 (기본: config 또는 127.0.0.1)")
- p.add_argument("--port", type=int, default=None, help="포트 (기본: config 또는 8080)")
- args = p.parse_args()
+
+ parser = argparse.ArgumentParser(description="눈금 NUNGUM 웹 대시보드")
+ parser.add_argument(
+ "--host", default=None, help="바인드 주소 (기본: config 또는 127.0.0.1)"
+ )
+ parser.add_argument(
+ "--port", type=int, default=None, help="포트 (기본: config 또는 8080)"
+ )
+ args = parser.parse_args()
+
from database.models import init_database
from monitoring.logger import setup_logger
+
setup_logger()
init_database()
run_web_dashboard(host=args.host, port=args.port)
diff --git a/strategies/volatility_condition.py b/strategies/volatility_condition.py
index b48e630d..3b7e8b7d 100644
--- a/strategies/volatility_condition.py
+++ b/strategies/volatility_condition.py
@@ -48,7 +48,11 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
close = result["close"].astype(float)
ret = close.pct_change().dropna()
# 롤링 표준편차 * sqrt(252) = 연율화 변동성 (% 단위로 하려면 *100)
- vol = ret.rolling(lookback, min_periods=min(10, lookback)).std() * np.sqrt(252) * 100
+ vol = (
+ ret.rolling(lookback, min_periods=min(10, lookback)).std()
+ * np.sqrt(252)
+ * 100
+ ).reindex(result.index)
result["realized_vol_pct"] = vol
# 스코어: 낮은 변동성 = 양수, 높은 변동성 = 음수 (정규화 -1~1 수준)
@@ -56,8 +60,15 @@ def analyze(self, df: pd.DataFrame) -> pd.DataFrame:
result["strategy_score"] = np.clip((mid - vol) / max(mid, 1), -2, 2)
result["signal"] = self.HOLD
- result.loc[vol <= low_max, "signal"] = self.BUY
- result.loc[vol >= high_min, "signal"] = self.SELL
+ # pct_change().dropna() removes the first row, so rolling volatility has
+ # a shorter index unless it is explicitly aligned. Pandas 3 treats an
+ # unaligned boolean Series passed to .loc as labels and can raise
+ # ``TypeError: unhashable type: 'Series'``. Keep masks on result.index
+ # on every supported pandas version.
+ low_vol_mask = vol.le(low_max).reindex(result.index, fill_value=False)
+ high_vol_mask = vol.ge(high_min).reindex(result.index, fill_value=False)
+ result.loc[low_vol_mask, "signal"] = self.BUY
+ result.loc[high_vol_mask, "signal"] = self.SELL
return result
def generate_signal(self, df: pd.DataFrame, **kwargs) -> dict:
diff --git a/tests/conftest.py b/tests/conftest.py
index 93c47b79..c65fc522 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -69,3 +69,46 @@ def _isolate_sector_map_cache(tmp_path, monkeypatch):
monkeypatch.setattr(_dc, "SECTOR_MAP_CACHE_PATH", tmp_path / "sector_map_cache.json")
except Exception:
pass
+
+
+# ---------------------------------------------------------------------------
+# 전역 거래 HALT 격리 (DB·캐시 격리와 같은 원리)
+#
+# HALT는 OperationEvent에 append-only로 쌓이고 '최신 이벤트가 이긴다'. 테스트 세션은
+# 임시 DB 하나를 공유하므로, 한 테스트가 켠 HALT는 정리하지 않으면 그 뒤 모든 테스트의
+# 신규 BUY를 막는다. 실제로 CI에서 live SELL 체결 미확인 테스트가 켠 HALT가 뒤따르는
+# 매수 테스트들로 번져 무더기 실패를 만들었다(2026-08-26).
+#
+# 개별 테스트의 규율(각자 clear 호출)에 맡기면 새 테스트가 추가될 때마다 다시 샌다 —
+# 공용 인프라에서 강제한다(standing lesson: 격리는 공용 인프라에 둔다).
+# ---------------------------------------------------------------------------
+@pytest.fixture(autouse=True)
+def _isolate_global_trading_halt():
+ yield
+ try:
+ from database.models import OperationEvent, get_session
+ from database.repositories import TRADING_HALT_CLEARED, TRADING_HALT_SET
+
+ session = get_session()
+ try:
+ leaked = (
+ session.query(OperationEvent)
+ .filter(
+ OperationEvent.event_type.in_(
+ (TRADING_HALT_SET, TRADING_HALT_CLEARED)
+ )
+ )
+ .count()
+ )
+ if leaked:
+ session.query(OperationEvent).filter(
+ OperationEvent.event_type.in_(
+ (TRADING_HALT_SET, TRADING_HALT_CLEARED)
+ )
+ ).delete(synchronize_session=False)
+ session.commit()
+ finally:
+ session.close()
+ except Exception:
+ # 스키마 미생성 등으로 정리에 실패해도 테스트 결과를 바꾸지 않는다.
+ pass
diff --git a/tests/test_backtest_cost_impact.py b/tests/test_backtest_cost_impact.py
index 2f152b6a..1cd3a006 100644
--- a/tests/test_backtest_cost_impact.py
+++ b/tests/test_backtest_cost_impact.py
@@ -1,4 +1,5 @@
import pandas as pd
+import pytest
def test_cost_impact_marks_cost_flipped_result_as_fail():
@@ -68,6 +69,53 @@ class _Config:
assert metrics["cost_impact_status"] == "fail"
+def test_single_backtester_metrics_anchor_first_day_to_initial_capital():
+ """첫날 즉시 발생한 손실/비용도 MDD와 일수익률에 포함한다."""
+ from backtest.backtester import Backtester
+
+ class _Config:
+ risk_params = {}
+
+ equity = pd.DataFrame({
+ "date": pd.to_datetime(["2026-01-02", "2026-01-05"]),
+ "value": [900.0, 900.0],
+ "cash": [900.0, 900.0],
+ "position_value": [0.0, 0.0],
+ })
+ metrics = Backtester(_Config())._calculate_metrics(
+ {"equity_curve": equity, "trades": []},
+ initial_capital=1_000.0,
+ )
+
+ assert metrics["total_return"] == -10.0
+ assert metrics["max_drawdown"] == -10.0
+ assert equity["daily_return"].iloc[0] == pytest.approx(-0.1)
+ assert metrics["sharpe_ratio"] < 0
+
+
+def test_portfolio_metrics_anchor_first_day_to_initial_capital():
+ """포트폴리오 백테스터도 첫 관측일 이전 초기자본 기준점을 보존한다."""
+ from backtest.portfolio_backtester import PortfolioBacktester
+
+ class _Config:
+ risk_params = {}
+
+ equity = pd.DataFrame({
+ "date": pd.to_datetime(["2026-01-02", "2026-01-05"]),
+ "value": [900.0, 900.0],
+ "n_positions": [1, 1],
+ })
+ metrics = PortfolioBacktester(_Config())._calculate_portfolio_metrics(
+ {"equity_curve": equity, "trades": []},
+ initial_capital=1_000.0,
+ )
+
+ assert metrics["total_return"] == -10.0
+ assert metrics["max_drawdown"] == -10.0
+ assert equity["daily_return"].iloc[0] == pytest.approx(-0.1)
+ assert metrics["sharpe_ratio"] < 0
+
+
def test_text_report_contains_cost_before_after_section(tmp_path):
from backtest.cost_impact import summarize_cost_impact
from backtest.report_generator import ReportGenerator
@@ -108,3 +156,86 @@ def test_text_report_contains_cost_before_after_section(tmp_path):
assert "[ 비용 전/후 성과 비교 ]" in text
assert "비용 차감 전 추정 수익률" in text
assert "비용 드래그" in text
+
+
+class _SymbolTaxBacktestConfig:
+ trading = {"market_regime_filter": False, "skip_earnings_days": 0}
+ risk_params = {
+ "position_sizing": {"max_risk_per_trade": 0.01},
+ "stop_loss": {"type": "fixed", "fixed_rate": 0.03},
+ "take_profit": {"fixed_rate": 0.08, "partial_exit": False},
+ "trailing_stop": {"enabled": False},
+ "diversification": {
+ "max_position_ratio": 0.20,
+ "max_investment_ratio": 0.70,
+ },
+ "position_limits": {"min_holding_days": 0, "max_holding_days": 0},
+ "backtest_regime_filter": {"enabled": False},
+ "transaction_costs": {
+ "commission_rate": 0.0,
+ "tax_rate": 0.002,
+ "tax_exempt_symbols": ["069500", "357870"],
+ "holding_period_income_tax": {
+ "enabled": True,
+ "rate": 0.154,
+ "symbols": ["357870"],
+ },
+ "slippage": 0.0,
+ "slippage_ticks": 0,
+ "dynamic_slippage": {"enabled": False},
+ },
+ }
+
+
+class _PassthroughSignalStrategy:
+ @staticmethod
+ def analyze(df):
+ return df.copy()
+
+
+def _run_symbol_tax_backtest(symbol):
+ from backtest.backtester import Backtester
+
+ df = pd.DataFrame(
+ {
+ "open": [100.0, 101.0],
+ "high": [100.0, 101.0],
+ "low": [100.0, 101.0],
+ "close": [100.0, 101.0],
+ "volume": [1_000_000.0, 1_000_000.0],
+ "signal": ["BUY", "SELL"],
+ },
+ index=pd.to_datetime(["2026-01-02", "2026-01-05"]),
+ )
+ backtester = Backtester(_SymbolTaxBacktestConfig())
+ backtester._get_strategy = lambda _name: _PassthroughSignalStrategy()
+ return backtester.run(
+ df,
+ strategy_name="passthrough",
+ initial_capital=1_000_000.0,
+ strict_lookahead=False,
+ symbol=symbol,
+ execution_model="legacy_same_close",
+ )
+
+
+def test_backtester_kr_equity_etf_sell_is_transaction_tax_exempt():
+ result = _run_symbol_tax_backtest("069500")
+
+ sell = next(t for t in result["trades"] if t["action"] == "SELL")
+ assert sell["tax"] == 0.0
+ assert result["metrics"]["total_tax"] == 0.0
+
+
+def test_backtester_other_etf_sell_applies_holding_period_income_tax():
+ result = _run_symbol_tax_backtest("357870")
+
+ buy = next(t for t in result["trades"] if t["action"] == "BUY")
+ sell = next(t for t in result["trades"] if t["action"] == "SELL")
+ expected = round(
+ (sell["price"] - buy["price"]) * sell["quantity"] * 0.154,
+ 0,
+ )
+ assert expected > 0
+ assert sell["tax"] == pytest.approx(expected)
+ assert result["metrics"]["total_tax"] == pytest.approx(expected)
diff --git a/tests/test_backtester_guards.py b/tests/test_backtester_guards.py
index b2259f24..003fa404 100644
--- a/tests/test_backtester_guards.py
+++ b/tests/test_backtester_guards.py
@@ -103,6 +103,34 @@ def _make_guard_df(close, *, open_=None, signals=None, volume=1_000_000):
return df
+def test_strategy_signals_execute_one_bar_later_at_next_open():
+ """종가로 확정된 전략 BUY/SELL은 신호일이 아닌 다음 거래일 시가에 체결된다."""
+ from backtest.backtester import Backtester
+
+ bt = Backtester(_BacktestGuardConfig(gap_enabled=False))
+ df = _make_guard_df(
+ [100.0, 125.0, 126.0, 130.0],
+ open_=[90.0, 123.0, 126.0, 124.0],
+ signals=["BUY", "HOLD", "SELL", "HOLD"],
+ )
+
+ result = bt._simulate(df, initial_capital=100_000.0)
+
+ assert result["execution_model"] == "next_open"
+ assert [trade["action"] for trade in result["trades"]] == ["BUY", "SELL"]
+ buy, sell = result["trades"]
+ assert buy["signal_date"] == df.index[0]
+ assert buy["date"] == df.index[1]
+ assert buy["price"] == pytest.approx(123.0)
+ assert sell["signal_date"] == df.index[2]
+ assert sell["date"] == df.index[3]
+ assert sell["price"] == pytest.approx(124.0)
+ assert not any(trade["date"] in {df.index[0], df.index[2]} for trade in result["trades"])
+
+ metrics = bt._calculate_metrics(result, initial_capital=100_000.0)
+ assert metrics["execution_model"] == "next_open"
+
+
class TestLiquidityFilter:
"""백테스터 유동성 필터: 주문량이 일평균 거래량의 N%를 초과하면 축소."""
@@ -125,7 +153,9 @@ def test_low_volume_limits_quantity(self):
df.iloc[30, df.columns.get_loc("signal")] = "BUY"
df.iloc[55, df.columns.get_loc("signal")] = "SELL"
- result = bt._simulate(df, initial_capital=100_000_000)
+ result = bt._simulate(
+ df, initial_capital=100_000_000, execution_model="legacy_same_close"
+ )
buy_trades = [t for t in result["trades"] if t["action"] == "BUY"]
if buy_trades:
@@ -150,7 +180,9 @@ def test_high_volume_no_limit(self):
df["signal"] = "HOLD"
df.iloc[30, df.columns.get_loc("signal")] = "BUY"
- result = bt._simulate(df, initial_capital=100_000_000)
+ result = bt._simulate(
+ df, initial_capital=100_000_000, execution_model="legacy_same_close"
+ )
buy_trades = [t for t in result["trades"] if t["action"] == "BUY"]
if buy_trades:
@@ -192,7 +224,9 @@ def test_monthly_cap_blocks_excess_buys(self):
df.iloc[buy_day, df.columns.get_loc("signal")] = "BUY"
df.iloc[sell_day, df.columns.get_loc("signal")] = "SELL"
- result = bt._simulate(df, initial_capital=100_000_000)
+ result = bt._simulate(
+ df, initial_capital=100_000_000, execution_model="legacy_same_close"
+ )
buy_trades = [t for t in result["trades"] if t["action"] == "BUY"]
# 월 2회 제한이므로 3번째 매수는 차단되어야 함
@@ -251,7 +285,9 @@ def test_gap_up_blocks_new_buy(self):
signals=["HOLD", "BUY", "HOLD"],
)
- result = bt._simulate(df, initial_capital=100_000.0)
+ result = bt._simulate(
+ df, initial_capital=100_000.0, execution_model="legacy_same_close"
+ )
assert [t["action"] for t in result["trades"]] == []
assert result["gap_up_buy_blocks"] == 1
@@ -267,7 +303,9 @@ def test_earnings_window_blocks_new_buy(self):
df["earnings_date"] = pd.NaT
df.loc[df.index[1], "earnings_date"] = df.index[1]
- result = bt._simulate(df, initial_capital=100_000.0)
+ result = bt._simulate(
+ df, initial_capital=100_000.0, execution_model="legacy_same_close"
+ )
assert [t["action"] for t in result["trades"]] == []
assert result["earnings_buy_blocks"] == 1
@@ -282,7 +320,9 @@ def test_gap_down_exit_preempts_close_stop_loss(self):
signals=["BUY", "HOLD", "HOLD"],
)
- result = bt._simulate(df, initial_capital=100_000.0)
+ result = bt._simulate(
+ df, initial_capital=100_000.0, execution_model="legacy_same_close"
+ )
actions = [t["action"] for t in result["trades"]]
assert actions == ["BUY", "GAP_DOWN"]
@@ -304,7 +344,9 @@ def test_blackswan_exit_blocks_cooldown_and_scales_recovery_buy(self):
signals=["BUY", "HOLD", "BUY", "BUY"],
)
- result = bt._simulate(df, initial_capital=100_000.0)
+ result = bt._simulate(
+ df, initial_capital=100_000.0, execution_model="legacy_same_close"
+ )
actions = [t["action"] for t in result["trades"]]
assert actions == ["BUY", "BLACKSWAN", "BUY"]
diff --git a/tests/test_backtester_strategies.py b/tests/test_backtester_strategies.py
index cb14b731..e1652ee9 100644
--- a/tests/test_backtester_strategies.py
+++ b/tests/test_backtester_strategies.py
@@ -9,6 +9,7 @@
from strategies.mean_reversion import MeanReversionStrategy
from strategies.scoring_strategy import ScoringStrategy
from strategies.trend_following import TrendFollowingStrategy
+from strategies.volatility_condition import VolatilityConditionStrategy
from core.strategy_ensemble import StrategyEnsemble
@@ -35,6 +36,7 @@ def test_strategy_analyze_contract_produces_signal_column():
ScoringStrategy,
MeanReversionStrategy,
TrendFollowingStrategy,
+ VolatilityConditionStrategy,
StrategyEnsemble,
):
analyzed = strategy_cls().analyze(df.copy())
@@ -50,3 +52,30 @@ def test_backtester_runs_for_all_strategies():
result = bt.run(df.copy(), strategy_name=strategy_name)
assert result.get("metrics")
assert "total_return" in result["metrics"]
+
+
+def test_volatility_condition_aligns_boolean_masks_to_input_index():
+ """pandas 2/3 모두에서 롤링 변동성 마스크는 원본 인덱스와 일치한다."""
+
+ class _VolatilityConfig:
+ strategies = {
+ "volatility_condition": {
+ "lookback_days": 20,
+ "low_vol_max_pct": 5.0,
+ "high_vol_min_pct": 30.0,
+ }
+ }
+
+ low_vol_returns = np.full(50, 0.0001)
+ high_vol_returns = np.resize(np.array([0.05, -0.05]), 50)
+ prices = 100 * np.cumprod(1 + np.concatenate([low_vol_returns, high_vol_returns]))
+ index = pd.bdate_range("2025-01-02", periods=len(prices))
+ df = pd.DataFrame({"close": prices}, index=index)
+
+ analyzed = VolatilityConditionStrategy(_VolatilityConfig()).analyze(df)
+
+ assert analyzed.index.equals(df.index)
+ assert analyzed["realized_vol_pct"].index.equals(df.index)
+ assert analyzed.iloc[0]["signal"] == "HOLD"
+ assert "BUY" in analyzed["signal"].values
+ assert "SELL" in analyzed["signal"].values
diff --git a/tests/test_backtester_trailing_stop.py b/tests/test_backtester_trailing_stop.py
index 5697fa66..a94cde1b 100644
--- a/tests/test_backtester_trailing_stop.py
+++ b/tests/test_backtester_trailing_stop.py
@@ -54,7 +54,9 @@ def test_backtester_trailing_stop_triggers():
index=dates,
)
- result = bt._simulate(df, initial_capital=1_000_000)
+ result = bt._simulate(
+ df, initial_capital=1_000_000, execution_model="legacy_same_close"
+ )
actions = [t["action"] for t in result["trades"]]
assert "TRAILING_STOP" in actions
diff --git a/tests/test_basket_rebalancer.py b/tests/test_basket_rebalancer.py
index 743bd2c0..2f31ff1c 100644
--- a/tests/test_basket_rebalancer.py
+++ b/tests/test_basket_rebalancer.py
@@ -161,6 +161,9 @@ def test_drift_trigger_skips(self, rebalancer):
rebalancer.get_current_weights = MagicMock(return_value={
"005930": 0.39, "000660": 0.35, "035420": 0.25,
})
+ # 종목별 드리프트가 임계값 아래여도 집계 배치율이 밴드를 벗어나면 트리거된다
+ # (현금 래칫 방지). 이 테스트는 종목별 트리거만 보므로 집계 격차는 0으로 둔다.
+ rebalancer._deployment_gap = MagicMock(return_value=0.0)
should, reason = rebalancer.should_rebalance()
assert should is False
@@ -327,9 +330,17 @@ def test_diversified_hold_basket_is_low_turnover(self):
baskets = self._load()
assert "kr_diversified_hold" in baskets, "분산 보유 바스켓 누락"
b = baskets["kr_diversified_hold"]
- # 10종목 균등(각 10%)
- assert len(b["holdings"]) == 10
- assert all(abs(float(w) - 0.10) < 1e-9 for w in b["holdings"].values())
+ # 섹터 분산 균등 배분. 종목 수는 자본 규모에 따라 조정되므로(1주 가격이 슬롯
+ # 금액을 넘는 종목은 편입 불가 — 2026-08-07에 000660 제외) 개수를 못 박지 않고
+ # '균등 배분'이라는 설계 불변식만 검사한다.
+ holdings = b["holdings"]
+ assert len(holdings) >= 8, "섹터 분산이 무너질 만큼 종목이 줄었다"
+ equal_weight = 1.0 / len(holdings)
+ assert all(
+ abs(float(w) - equal_weight) < 0.001 for w in holdings.values()
+ ), f"균등 배분이 아님: {holdings}"
+ # 주식 노출은 명시적 정책이어야 한다(2026-08-07: 현금 완충 40%를 설계로 고정)
+ assert b["target_stock_weight"] == 0.60
# 저회전: 넓은 드리프트 임계 + 낮은 회전 상한
rb = b["rebalance"]
assert rb["drift_threshold"] >= 0.08
@@ -377,12 +388,16 @@ def test_pocket_basket_small_capital_invariants(self):
)}
assert {"069500", "357870"} <= exempt
- def test_observation_track_deployment_alarm_disabled(self):
- """관찰용 강등(kr_diversified_hold): 종결된 자본 결정의 잔상인 배치율 미달이
- 상시 ATTENTION으로 남아 다른 바스켓 감시를 가리지 않도록 허용 오차 해제."""
- baskets = self._load()
- b = baskets["kr_diversified_hold"]
- assert float(b["monitoring"]["deployment_tolerance"]) >= 1.0
+ def test_observation_track_deployment_alarm_is_enabled(self):
+ """배치율 감시는 켜져 있어야 한다(2026-08-26에 결정을 뒤집었다).
+
+ 종전에는 tolerance 1.0으로 사실상 감시를 껐다 — '하이닉스 슬롯을 못 채워 생기는
+ 미달은 조치 불가'였기 때문이다. 그 슬롯을 제거하고 목표 배치율을 60%로 명시한
+ 지금은 미달이 곧 조치 대상(현금 누수)이고, 실제로 감시가 꺼져 있는 동안
+ 배치율이 61.0% → 54.9%로 새는 것을 헬스가 3주간 전혀 잡지 못했다.
+ """
+ b = self._load()["kr_diversified_hold"]
+ assert float(b["monitoring"]["deployment_tolerance"]) < 1.0
def test_all_basket_symbols_are_6digit_kr_codes(self):
baskets = self._load()
@@ -726,6 +741,7 @@ def test_warns_when_single_share_exceeds_target_amount(self, caplog):
"rebalance": {"trigger": "drift", "drift_threshold": 0.08,
"min_trade_amount": 200000, "max_turnover_ratio": 1.0},
}
+ rb.basket = rb.basket_cfg # 리스크 정책 조회원(재진입 차단 등)
rb.rebalance_cfg = rb.basket_cfg["rebalance"]
rb.account_key = "t"
rb.execution_strategy = "t"
@@ -733,6 +749,7 @@ def test_warns_when_single_share_exceeds_target_amount(self, caplog):
get_portfolio_summary=lambda current_prices=None: {"total_value": 1_000_000},
)
rb._is_live = lambda: False
+ rb._ledger_mode = lambda: "paper"
rb._stock_fraction = lambda: 1.0
rb.get_target_weights = lambda: {"000660": 0.5, "005930": 0.5}
rb.get_current_weights = lambda prices=None: {}
diff --git a/tests/test_basket_risk_policy.py b/tests/test_basket_risk_policy.py
new file mode 100644
index 00000000..1f9207b2
--- /dev/null
+++ b/tests/test_basket_risk_policy.py
@@ -0,0 +1,300 @@
+"""바스켓 트랙별 리스크 정책 + 자기상관 자기거부 회귀 테스트.
+
+배경(2026-08-07 점검): 두 결함이 모의투자 트랙을 57거래일간 얼려 두었다.
+ 1) check_correlation_risk가 대상 종목 자신을 비교 대상에 포함해 corr(x,x)=1.0이
+ 잡히고, 고정수량 어댑터는 scale<1.0을 하드 거부라 모든 추가매수가 영구 거부됐다.
+ 2) 전역 단타 손절(-3%)이 buy&hold 바스켓 포지션에 기록되기만 하고 일일 사이클은
+ 평가하지 않아, 손절선을 뚫은 포지션이 그대로 방치됐다.
+"""
+
+from types import SimpleNamespace
+
+import pytest
+
+from core.basket_risk import (
+ RISK_EXIT_TAG,
+ basket_risk_config,
+ basket_risk_levels,
+ evaluate_basket_stops,
+ has_risk_policy,
+ reentry_cooldown_days,
+ symbols_in_reentry_cooldown,
+)
+from core.risk_manager import RiskManager
+
+
+def _pos(symbol, avg_price, quantity=1, highest_price=0):
+ return SimpleNamespace(
+ symbol=symbol, avg_price=avg_price, quantity=quantity,
+ highest_price=highest_price,
+ )
+
+
+# ---------------------------------------------------------------- 자기상관
+
+class _StubConfig:
+ def __init__(self, risk_params):
+ self.risk_params = risk_params
+ self.trading = {"mode": "paper"}
+ self.settings = {}
+
+
+@pytest.fixture
+def corr_risk_manager():
+ return RiskManager(_StubConfig({
+ "diversification": {
+ "correlation_risk": {
+ "enabled": True,
+ "lookback_days": 60,
+ "high_corr_threshold": 0.7,
+ "high_corr_scale": 0.5,
+ "strict": True,
+ },
+ },
+ }))
+
+
+def test_self_correlation_is_not_counted(corr_risk_manager, monkeypatch):
+ """보유 중인 종목의 추가매수에서 자기 자신은 비교 대상이 아니다.
+
+ 이 케이스가 회귀하면 corr(x,x)=1.0이 다시 잡혀 모든 추가매수가 거부된다.
+ 데이터 조회가 일어나면 안 되므로 DataCollector가 불리면 실패시킨다.
+ """
+ def _boom(*args, **kwargs): # pragma: no cover - 불리면 테스트 실패
+ raise AssertionError("자기 자신만 있는 경우 시세 조회가 일어나면 안 된다")
+
+ monkeypatch.setattr("core.data_collector.DataCollector.fetch_stock", _boom)
+
+ result = corr_risk_manager.check_correlation_risk("005930", ["005930"])
+
+ assert result["scale"] == 1.0
+ assert not result.get("blocked")
+ assert result["high_corr_symbols"] == []
+
+
+def test_self_correlation_excluded_but_peers_still_checked(corr_risk_manager, monkeypatch):
+ """자기 자신만 빠지고 다른 보유 종목은 정상적으로 검사된다."""
+ seen = []
+
+ def _fake_fetch(self, symbol, *args, **kwargs):
+ seen.append(symbol)
+ return None # 데이터 없음 → strict면 차단
+
+ monkeypatch.setattr("core.data_collector.DataCollector.fetch_stock", _fake_fetch)
+
+ corr_risk_manager.check_correlation_risk("005930", ["005930", "000660"])
+
+ # 대상(005930)은 자기 자신 비교에서 빠지지만 target_df 조회는 여전히 필요하다.
+ # 핵심은 보유 목록 순회에서 005930이 다시 나오지 않는 것.
+ assert seen.count("005930") <= 1
+
+
+# ------------------------------------------------------- 바스켓 리스크 정책
+
+def test_risk_block_absent_means_global_defaults():
+ """`risk:` 블록이 없으면 None — 호출부가 전역 기본값을 쓰도록(기존 동작 유지)."""
+ assert has_risk_policy({"holdings": {}}) is False
+ assert basket_risk_levels({"holdings": {}}, 100_000) is None
+
+
+def test_all_zero_risk_block_means_explicitly_no_stops():
+ """전부 0인 `risk:` 블록은 '손절 없음'이라는 결정이다 — 전역 기본값으로 되돌아가면 안 된다.
+
+ 되돌아가면 지수 ETF 적립 트랙(kr_pocket)에 단타 -3% 손절이 다시 기록된다.
+ """
+ cfg = {"risk": {"stop_loss_pct": 0, "take_profit_pct": 0, "trailing_stop_pct": 0}}
+
+ assert has_risk_policy(cfg) is True
+ levels = basket_risk_levels(cfg, 100_000)
+ assert levels is not None
+ assert levels == {
+ "stop_loss_price": None,
+ "take_profit_price": None,
+ "trailing_stop_price": None,
+ }
+ assert evaluate_basket_stops(cfg, [_pos("069500", 100_000)], {"069500": 50_000}) == []
+
+
+def test_stop_loss_level_and_breach():
+ cfg = {"risk": {"stop_loss_pct": 0.25}}
+
+ assert basket_risk_levels(cfg, 600_000)["stop_loss_price"] == 450_000
+
+ # 정상 조정(-10%)에는 안 걸린다
+ assert evaluate_basket_stops(cfg, [_pos("005380", 600_000)], {"005380": 540_000}) == []
+
+ hits = evaluate_basket_stops(cfg, [_pos("005380", 600_000)], {"005380": 440_000})
+ assert len(hits) == 1
+ assert hits[0]["action"] == "STOP_LOSS"
+ assert hits[0]["symbol"] == "005380"
+ assert hits[0]["level"] == 450_000
+
+
+def test_evaluation_uses_policy_not_stale_position_column():
+ """포지션에 남아 있는 옛 손절가(-3%)가 아니라 정책 비율로 판정한다."""
+ cfg = {"risk": {"stop_loss_pct": 0.25}}
+ stale = _pos("005930", 302_000)
+ stale.stop_loss_price = 292_940 # 전역 -3%로 기록된 옛 값
+
+ # -3% 기준이면 걸리지만 정책(-25%) 기준이면 아직 아니다
+ assert evaluate_basket_stops(cfg, [stale], {"005930": 250_000}) == []
+
+
+def test_take_profit_precedes_stop_loss():
+ cfg = {"risk": {"stop_loss_pct": 0.25, "take_profit_pct": 0.10}}
+ hits = evaluate_basket_stops(cfg, [_pos("035720", 100_000)], {"035720": 115_000})
+ assert [h["action"] for h in hits] == ["TAKE_PROFIT"]
+
+
+def test_trailing_stop_uses_highest_price_and_needs_a_peak():
+ cfg = {"risk": {"trailing_stop_pct": 0.10}}
+
+ # 고점이 진입가 이하면 트레일링은 판단하지 않는다(손절과 구분 불가)
+ assert evaluate_basket_stops(cfg, [_pos("105560", 100_000)], {"105560": 80_000}) == []
+
+ pos = _pos("105560", 100_000, highest_price=150_000)
+ hits = evaluate_basket_stops(cfg, [pos], {"105560": 134_000})
+ assert [h["action"] for h in hits] == ["TRAILING_STOP"]
+ assert hits[0]["level"] == 135_000
+
+
+@pytest.mark.parametrize("bad", [-0.1, 1.0, 1.5, "abc", None])
+def test_out_of_range_values_are_ignored(bad):
+ cfg = basket_risk_config({"risk": {"stop_loss_pct": bad}})
+ assert cfg["stop_loss_pct"] is None
+
+
+def test_invalid_entry_price_keeps_policy_but_records_no_levels():
+ """진입가가 유효하지 않아도 전역 기본값으로 되돌아가지 않는다."""
+ levels = basket_risk_levels({"risk": {"stop_loss_pct": 0.25}}, 0)
+ assert levels is not None
+ assert levels["stop_loss_price"] is None
+
+
+# ------------------------------------------------------------- 재진입 차단
+
+def _sell(symbol, reason, days_ago=0):
+ from datetime import datetime, timedelta
+ return SimpleNamespace(
+ symbol=symbol, action="SELL", reason=reason,
+ executed_at=datetime.now() - timedelta(days=days_ago),
+ )
+
+
+def test_cooldown_absent_means_no_block():
+ assert reentry_cooldown_days({"risk": {}}) == 0
+ assert symbols_in_reentry_cooldown({"risk": {}}, "acct", "paper") == {}
+
+
+def test_risk_exit_blocks_reentry(monkeypatch):
+ """손절로 나간 종목은 재매수 차단 목록에 오른다.
+
+ 이 차단이 없으면 청산으로 비워진 슬롯을 같은 사이클의 비중 교정이 곧바로 되사서
+ 손실만 확정하는 왕복매매가 된다(2026-08-07 10:07 실측).
+ """
+ monkeypatch.setattr(
+ "database.repositories.get_trade_history",
+ lambda **kw: [_sell("005380", f"리밸런싱: {RISK_EXIT_TAG} STOP_LOSS: 손절 ...")],
+ )
+ blocked = symbols_in_reentry_cooldown(
+ {"risk": {"reentry_cooldown_days": 60}}, "acct", "paper",
+ )
+ assert "005380" in blocked
+
+
+def test_ordinary_rebalance_sell_does_not_block_reentry(monkeypatch):
+ """비중 초과로 판 것은 차단 대상이 아니다 — 정상 리밸런싱을 막으면 안 된다."""
+ monkeypatch.setattr(
+ "database.repositories.get_trade_history",
+ lambda **kw: [_sell("055550", "리밸런싱: 비중 초과 (15.3% → 10.0%, -5.3%)")],
+ )
+ blocked = symbols_in_reentry_cooldown(
+ {"risk": {"reentry_cooldown_days": 60}}, "acct", "paper",
+ )
+ assert blocked == {}
+
+
+def test_cooldown_query_failure_does_not_block_cycle(monkeypatch):
+ def _boom(**kw):
+ raise RuntimeError("DB 조회 실패")
+
+ monkeypatch.setattr("database.repositories.get_trade_history", _boom)
+ assert symbols_in_reentry_cooldown(
+ {"risk": {"reentry_cooldown_days": 60}}, "acct", "paper",
+ ) == {}
+
+
+def test_plan_rebalance_skips_symbol_in_cooldown(monkeypatch):
+ """차단 종목은 매수 후보에서 빠진다(플래너 레벨 회귀 방지)."""
+ from unittest.mock import MagicMock
+
+ from core.basket_rebalancer import BasketRebalancer
+
+ rb = BasketRebalancer.__new__(BasketRebalancer)
+ rb.basket_name = "t"
+ rb.basket = {"risk": {"reentry_cooldown_days": 60}}
+ rb.account_key = "acct"
+ rb.execution_strategy = "acct"
+ rb.holdings = {"005380": 0.5, "005930": 0.5}
+ rb.rebalance_cfg = {"min_trade_amount": 100_000, "max_turnover_ratio": 1.0}
+ rb._target_stock_weight = 1.0
+ rb._risk_params = {"diversification": {"min_cash_ratio": 0.0}}
+ rb.portfolio_mgr = MagicMock()
+ rb.portfolio_mgr.get_portfolio_summary.return_value = {"total_value": 10_000_000}
+ rb.config = MagicMock()
+ rb.config.trading = {"mode": "paper"}
+
+ monkeypatch.setattr(
+ "core.basket_rebalancer.get_all_positions", lambda **kw: [],
+ )
+ monkeypatch.setattr(
+ "core.basket_rebalancer.symbols_in_reentry_cooldown",
+ lambda *a, **k: {"005380": "60일 재진입 차단"},
+ )
+
+ orders = rb.plan_rebalance(prices={"005380": 400_000, "005930": 200_000})
+
+ symbols = {o.symbol for o in orders}
+ assert "005380" not in symbols, "재진입 차단 종목이 매수 후보에 남았다"
+ assert "005930" in symbols, "차단과 무관한 종목까지 막으면 안 된다"
+
+
+# ----------------------------------------------- 운영 설정이 정책을 갖췄는지
+
+def test_shipped_baskets_declare_risk_policy():
+ """enabled 바스켓은 리스크 정책을 명시해야 한다 — 침묵하면 단타 기본값이 적힌다."""
+ from core.basket_rebalancer import BasketRebalancer
+
+ baskets = BasketRebalancer._load_baskets_config()
+ enabled = {n: c for n, c in baskets.items() if c.get("enabled", False)}
+ assert enabled, "enabled 바스켓이 없다 — 설정 로드 경로 확인 필요"
+ missing = [n for n, c in enabled.items() if not has_risk_policy(c)]
+ assert not missing, f"리스크 정책 미선언 바스켓: {missing}"
+
+
+def test_shipped_basket_slots_are_fillable():
+ """목표 비중표에 '현재 자본으로 영원히 못 채우는 슬롯'이 남아 있으면 안 된다.
+
+ 1주 가격이 슬롯 목표금액을 넘으면 그 비중은 영구 공백이 되고, 배치율 미달로만
+ 나타나 원인이 가려진다(000660이 이 상태로 2개월 방치됐다).
+ """
+ from core.basket_deploy import effective_stock_fraction
+ from core.basket_rebalancer import BasketRebalancer
+ from config.config_loader import Config
+
+ risk_params = Config.get().risk_params
+ baskets = BasketRebalancer._load_baskets_config()
+ cfg = baskets["kr_diversified_hold"]
+ capital = float(cfg.get("initial_capital") or 10_000_000)
+ investable = capital * effective_stock_fraction(cfg, risk_params)
+
+ max_position_ratio = float(
+ (risk_params.get("diversification") or {}).get("max_position_ratio", 0.20)
+ )
+ for symbol, weight in cfg["holdings"].items():
+ slot = investable * float(weight)
+ # 슬롯이 단일 종목 상한 안에 있어야 하고, 최소 1주는 담을 수 있어야 한다.
+ assert float(weight) * effective_stock_fraction(cfg, risk_params) <= max_position_ratio, (
+ f"{symbol} 목표 비중이 단일 종목 상한을 넘는다"
+ )
+ assert slot > 0, f"{symbol} 슬롯 금액이 0"
diff --git a/tests/test_blackswan_detector.py b/tests/test_blackswan_detector.py
index ffe2e3bf..5abcc62c 100644
--- a/tests/test_blackswan_detector.py
+++ b/tests/test_blackswan_detector.py
@@ -1,5 +1,6 @@
"""BlackSwanDetector 단위 테스트"""
from datetime import datetime, timedelta
+from types import SimpleNamespace
import pytest
@@ -27,3 +28,57 @@ def test_check_stock_no_trigger(detector):
"""급락 아닐 때 triggered False"""
r = detector.check_stock("005930", 50_000, 49_000)
assert r["triggered"] is False
+
+
+def test_detector_reads_all_blackswan_risk_params():
+ """risk_params.blackswan이 감지·cooldown·recovery의 실제 운영값이다."""
+ config = SimpleNamespace(
+ risk_params={
+ "blackswan": {
+ "single_stock_threshold": -0.04,
+ "portfolio_threshold": -0.025,
+ "consecutive_days": 4,
+ "consecutive_threshold": -0.015,
+ "cooldown_minutes": 7,
+ "recovery_minutes": 11,
+ "recovery_scale": 0.25,
+ }
+ },
+ # risk_params가 있으면 기존 settings 값보다 우선해야 한다.
+ trading={
+ "blackswan_recovery_minutes": 99,
+ "blackswan_recovery_scale": 0.9,
+ },
+ )
+
+ configured = BlackSwanDetector(config)
+
+ assert configured.single_stock_threshold == -0.04
+ assert configured.portfolio_threshold == -0.025
+ assert configured.consecutive_days == 4
+ assert configured.consecutive_threshold == -0.015
+ assert configured.cooldown_minutes == 7
+ assert configured.recovery_minutes == 11
+ assert configured.recovery_scale == 0.25
+
+ before = datetime.now()
+ result = configured.check_stock("005930", 95_000, 100_000)
+ assert result["triggered"] is True
+ assert configured.is_on_cooldown() is True
+ remaining = configured._cooldown_until - before
+ assert timedelta(minutes=6, seconds=55) <= remaining <= timedelta(minutes=7, seconds=5)
+
+
+def test_detector_keeps_legacy_recovery_fallback_when_risk_keys_missing():
+ config = SimpleNamespace(
+ risk_params={"blackswan": {}},
+ trading={
+ "blackswan_recovery_minutes": 33,
+ "blackswan_recovery_scale": 0.4,
+ },
+ )
+
+ configured = BlackSwanDetector(config)
+
+ assert configured.recovery_minutes == 33
+ assert configured.recovery_scale == 0.4
diff --git a/tests/test_cash_flows.py b/tests/test_cash_flows.py
index c1f2ced6..e3cc2112 100644
--- a/tests/test_cash_flows.py
+++ b/tests/test_cash_flows.py
@@ -81,22 +81,40 @@ def _pm(monkeypatch, account, initial=300_000, cash_delta=0.0, deposits=0.0,
"""요약 산식 검증용 PortfolioManager — 저장소 의존을 모두 결정론으로 고정."""
import core.portfolio_manager as pm_mod
- monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None)
+ monkeypatch.setattr(
+ pm_mod,
+ "get_latest_peak_value",
+ lambda account_key="", mode="paper": None,
+ )
pm = PortfolioManager(account_key=account, initial_capital=initial)
- monkeypatch.setattr(pm_mod, "get_all_positions", lambda account_key=None: [])
+ monkeypatch.setattr(
+ pm_mod,
+ "get_all_positions",
+ lambda account_key=None, mode="paper": [],
+ )
monkeypatch.setattr(
pm_mod, "get_trade_cash_summary",
lambda mode=None, account_key=None: {"cash_delta": cash_delta},
)
- monkeypatch.setattr(pm_mod, "get_cash_flow_total", lambda account_key="": deposits)
monkeypatch.setattr(
- pm_mod, "get_latest_snapshot_summary", lambda account_key="": prev_snapshot,
+ pm_mod,
+ "get_cash_flow_total",
+ lambda account_key="", mode="paper": deposits,
)
monkeypatch.setattr(
- pm_mod, "get_cash_flow_total_between", lambda ak, a, u: flow_since,
+ pm_mod,
+ "get_latest_snapshot_summary",
+ lambda account_key="", mode="paper": prev_snapshot,
)
monkeypatch.setattr(
- pm_mod, "get_max_cumulative_return", lambda account_key="": hist_max_cum,
+ pm_mod,
+ "get_cash_flow_total_between",
+ lambda ak, a, u, mode="paper": flow_since,
+ )
+ monkeypatch.setattr(
+ pm_mod,
+ "get_max_cumulative_return",
+ lambda account_key="", mode="paper": hist_max_cum,
)
return pm
@@ -111,7 +129,11 @@ class TestBasketCapitalResolution:
def test_basket_key_resolves_basket_capital(self, monkeypatch):
import core.portfolio_manager as pm_mod
- monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None)
+ monkeypatch.setattr(
+ pm_mod,
+ "get_latest_peak_value",
+ lambda account_key="", mode="paper": None,
+ )
from unittest.mock import patch
with patch(
"core.basket_rebalancer.BasketRebalancer._load_baskets_config",
@@ -123,7 +145,11 @@ def test_basket_key_resolves_basket_capital(self, monkeypatch):
def test_unknown_basket_falls_back_to_global(self, monkeypatch):
import core.portfolio_manager as pm_mod
- monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None)
+ monkeypatch.setattr(
+ pm_mod,
+ "get_latest_peak_value",
+ lambda account_key="", mode="paper": None,
+ )
from unittest.mock import patch
with patch(
"core.basket_rebalancer.BasketRebalancer._load_baskets_config",
@@ -135,14 +161,22 @@ def test_unknown_basket_falls_back_to_global(self, monkeypatch):
def test_non_basket_key_unchanged(self, monkeypatch):
import core.portfolio_manager as pm_mod
- monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None)
+ monkeypatch.setattr(
+ pm_mod,
+ "get_latest_peak_value",
+ lambda account_key="", mode="paper": None,
+ )
pm = PortfolioManager(account_key="scoring")
assert pm.initial_capital >= 1_000_000 # 기존 동작 그대로
def test_explicit_capital_still_wins(self, monkeypatch):
import core.portfolio_manager as pm_mod
- monkeypatch.setattr(pm_mod, "get_latest_peak_value", lambda account_key="": None)
+ monkeypatch.setattr(
+ pm_mod,
+ "get_latest_peak_value",
+ lambda account_key="", mode="paper": None,
+ )
pm = PortfolioManager(
account_key="basket_rebalance:kr_pocket", initial_capital=777,
)
@@ -250,7 +284,11 @@ def test_net_zero_flows_still_twr_branch(self, monkeypatch):
prev_snapshot=prev, flow_since=-100_000, hist_max_cum=25.0,
)
import core.portfolio_manager as pm_mod
- monkeypatch.setattr(pm_mod, "has_cash_flows", lambda account_key="": True)
+ monkeypatch.setattr(
+ pm_mod,
+ "has_cash_flows",
+ lambda account_key="", mode="paper": True,
+ )
out = pm.get_portfolio_summary()
# V=300k, 유입 -100k → r = 300/(400-100)-1 = 0 → 누적 25% 유지 (legacy면 0%로 붕괴)
assert out["total_return"] == pytest.approx(25.0)
diff --git a/tests/test_config_auto_entry.py b/tests/test_config_auto_entry.py
index e6d2b9a0..872fa2af 100644
--- a/tests/test_config_auto_entry.py
+++ b/tests/test_config_auto_entry.py
@@ -345,9 +345,8 @@ def test_undeclared_account_env_warns(self, monkeypatch, caplog):
_override_with_env({"kis_api": {"accounts": {}}})
assert any("KIS_ACCOUNT_NO_GHOST_STRATEGY" in r.message for r in caplog.records)
- def test_live_default_fallback_warns_once(self, caplog):
- """live에서 미선언 전략이 기본 계좌로 폴백하면 1회 경고(공유 가시화)."""
- import logging
+ def test_live_default_fallback_is_blocked(self):
+ """live에서 미선언 전략은 기본 실계좌로 침묵 폴백할 수 없다."""
from config.config_loader import Config
cfg = Config.__new__(Config)
@@ -355,12 +354,8 @@ def test_live_default_fallback_warns_once(self, caplog):
"trading": {"mode": "live"},
"kis_api": {"account_no": "1111-01", "accounts": {}},
}
- Config._default_account_warned = set()
- with caplog.at_level(logging.WARNING, logger="config_loader"):
- assert cfg.get_account_no("scoring") == "1111-01"
- assert cfg.get_account_no("scoring") == "1111-01" # 2회째는 경고 없음
- warns = [r for r in caplog.records if "기본 계좌로 폴백" in r.message]
- assert len(warns) == 1
+ with pytest.raises(ValueError, match="기본 계좌 폴백은 허용되지 않습니다"):
+ cfg.get_account_no("scoring")
def test_paper_default_fallback_silent(self, caplog):
"""paper에서는 기본 계좌 폴백이 정상 동작 — 경고 없음."""
@@ -372,7 +367,6 @@ def test_paper_default_fallback_silent(self, caplog):
"trading": {"mode": "paper"},
"kis_api": {"account_no": "1111-01", "accounts": {}},
}
- Config._default_account_warned = set()
with caplog.at_level(logging.WARNING, logger="config_loader"):
assert cfg.get_account_no("scoring") == "1111-01"
assert not [r for r in caplog.records if "기본 계좌" in r.message]
diff --git a/tests/test_config_duplicate_keys.py b/tests/test_config_duplicate_keys.py
new file mode 100644
index 00000000..c0c10f4d
--- /dev/null
+++ b/tests/test_config_duplicate_keys.py
@@ -0,0 +1,34 @@
+"""중복 YAML 키는 마지막 값으로 조용히 덮지 않고 설정 로드 단계에서 차단한다."""
+
+import pytest
+import yaml
+
+from config.config_loader import load_yaml
+
+
+def test_duplicate_yaml_key_is_rejected(tmp_path):
+ path = tmp_path / "duplicate.yaml"
+ path.write_text(
+ "strategy:\n threshold: 1\nstrategy:\n threshold: 2\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(yaml.constructor.ConstructorError, match="duplicate key"):
+ load_yaml(path)
+
+
+def test_nested_duplicate_yaml_key_is_rejected(tmp_path):
+ path = tmp_path / "nested_duplicate.yaml"
+ path.write_text(
+ "strategy:\n threshold: 1\n threshold: 2\n",
+ encoding="utf-8",
+ )
+
+ with pytest.raises(yaml.constructor.ConstructorError, match="threshold"):
+ load_yaml(path)
+
+
+def test_project_strategy_config_has_unique_keys():
+ loaded = load_yaml("config/strategies.yaml")
+ assert "trend_pullback" in loaded
+ assert loaded["trend_pullback"]["sma_period"] == 60
diff --git a/tests/test_config_risk_validation.py b/tests/test_config_risk_validation.py
new file mode 100644
index 00000000..afd24290
--- /dev/null
+++ b/tests/test_config_risk_validation.py
@@ -0,0 +1,214 @@
+"""실거래 손실 한도에 직접 영향을 주는 설정은 로드 시 fail-closed 검증한다."""
+
+import copy
+
+import pytest
+
+from config.config_loader import Config
+
+
+def _base_risk_params() -> dict:
+ return {
+ "position_sizing": {
+ "initial_capital": 10_000_000,
+ "max_risk_per_trade": 0.01,
+ "signal_scaling": {
+ "enabled": True,
+ "min_scale": 0.5,
+ "max_scale": 1.0,
+ },
+ },
+ "drawdown": {
+ "max_portfolio_mdd": 0.15,
+ "max_daily_loss": 0.03,
+ },
+ "diversification": {
+ "max_position_ratio": 0.2,
+ "max_investment_ratio": 0.7,
+ "max_sector_ratio": 0.4,
+ "min_cash_ratio": 0.2,
+ },
+ "blackswan": {
+ "single_stock_threshold": -0.05,
+ "portfolio_threshold": -0.03,
+ "consecutive_days": 3,
+ "consecutive_threshold": -0.02,
+ "cooldown_minutes": 60,
+ "recovery_minutes": 120,
+ "recovery_scale": 0.5,
+ },
+ }
+
+
+def _validate(risk_params: dict, settings: dict | None = None) -> None:
+ config = object.__new__(Config)
+ config._risk_params = risk_params
+ config._settings = settings or {"trading": {"mode": "paper"}}
+ config._validate_critical_params()
+
+
+def test_valid_conservative_risk_config_is_accepted():
+ _validate(_base_risk_params())
+
+
+@pytest.mark.parametrize(
+ ("section", "key", "bad_value"),
+ [
+ ("position_sizing", "max_risk_per_trade", 0),
+ ("position_sizing", "max_risk_per_trade", 0.051),
+ ("position_sizing", "max_risk_per_trade", float("nan")),
+ ("drawdown", "max_daily_loss", -0.01),
+ ("drawdown", "max_portfolio_mdd", float("inf")),
+ ("diversification", "max_position_ratio", 1.1),
+ ("diversification", "min_cash_ratio", -0.1),
+ ],
+)
+def test_invalid_loss_limit_ratios_are_rejected(section, key, bad_value):
+ params = copy.deepcopy(_base_risk_params())
+ params[section][key] = bad_value
+
+ with pytest.raises(ValueError, match=key):
+ _validate(params)
+
+
+def test_signal_scale_cannot_raise_position_above_risk_budget():
+ params = _base_risk_params()
+ params["position_sizing"]["signal_scaling"]["max_scale"] = 1.5
+
+ with pytest.raises(ValueError, match="max_scale"):
+ _validate(params)
+
+
+def test_signal_scale_min_must_not_exceed_max():
+ params = _base_risk_params()
+ params["position_sizing"]["signal_scaling"]["min_scale"] = 0.9
+ params["position_sizing"]["signal_scaling"]["max_scale"] = 0.5
+
+ with pytest.raises(ValueError, match="min_scale ≤ max_scale"):
+ _validate(params)
+
+
+def test_holding_period_income_tax_rate_must_be_a_ratio():
+ params = _base_risk_params()
+ params["transaction_costs"] = {
+ "holding_period_income_tax": {
+ "enabled": True,
+ "rate": 1.54,
+ "symbols": ["357870"],
+ },
+ }
+
+ with pytest.raises(ValueError, match="holding_period_income_tax.rate"):
+ _validate(params)
+
+
+@pytest.mark.parametrize("symbols", [None, [], "357870"])
+def test_enabled_holding_period_income_tax_requires_symbol_list(symbols):
+ params = _base_risk_params()
+ params["transaction_costs"] = {
+ "holding_period_income_tax": {
+ "enabled": True,
+ "rate": 0.154,
+ "symbols": symbols,
+ },
+ }
+
+ with pytest.raises(ValueError, match="holding_period_income_tax.symbols"):
+ _validate(params)
+
+
+@pytest.mark.parametrize(
+ ("key", "bad_value"),
+ [
+ ("single_stock_threshold", 0),
+ ("portfolio_threshold", -1),
+ ("consecutive_threshold", float("nan")),
+ ("consecutive_days", 0),
+ ("cooldown_minutes", -1),
+ ("recovery_minutes", 1.5),
+ ("recovery_scale", 1.1),
+ ],
+)
+def test_invalid_blackswan_controls_are_rejected(key, bad_value):
+ params = _base_risk_params()
+ params["blackswan"][key] = bad_value
+
+ with pytest.raises(ValueError, match=key):
+ _validate(params)
+
+
+@pytest.mark.parametrize(
+ ("key", "bad_value"),
+ [
+ ("pending_order_ttl_seconds", 59),
+ ("pending_order_ttl_seconds", float("nan")),
+ ("ledger_reconcile_guard_ttl_seconds", 3_599),
+ ("skip_earnings_days", -1),
+ ("skip_earnings_days", True),
+ ],
+)
+def test_invalid_trading_safety_controls_are_rejected(key, bad_value):
+ settings = {"trading": {"mode": "paper", key: bad_value}}
+
+ with pytest.raises(ValueError, match=key):
+ _validate(_base_risk_params(), settings)
+
+
+@pytest.mark.parametrize(
+ ("key", "bad_value"),
+ [
+ ("max_calls_per_sec", 0),
+ ("max_calls_per_sec", float("inf")),
+ ("max_calls_per_min", 0),
+ ("max_calls_per_min", 1.5),
+ ],
+)
+def test_invalid_kis_rate_limits_are_rejected(key, bad_value):
+ settings = {
+ "trading": {"mode": "paper"},
+ "kis_api": {key: bad_value},
+ }
+
+ with pytest.raises(ValueError, match=key):
+ _validate(_base_risk_params(), settings)
+
+
+@pytest.mark.parametrize(
+ ("section", "key", "bad_value"),
+ [
+ ("correlation_risk", "high_corr_threshold", float("nan")),
+ ("correlation_risk", "high_corr_scale", 0),
+ ("correlation_risk", "lookback_days", 29),
+ ("gap_risk", "gap_down_threshold", 0),
+ ("gap_risk", "gap_up_entry_block", float("inf")),
+ ("performance_degradation", "min_win_rate", float("nan")),
+ ("performance_degradation", "recent_trades", 4),
+ ],
+)
+def test_invalid_entry_filter_controls_are_rejected(section, key, bad_value):
+ params = _base_risk_params()
+ if section == "correlation_risk":
+ params["diversification"]["correlation_risk"] = {
+ "enabled": True,
+ "high_corr_threshold": 0.7,
+ "high_corr_scale": 0.5,
+ "lookback_days": 60,
+ }
+ params["diversification"]["correlation_risk"][key] = bad_value
+ elif section == "gap_risk":
+ params["gap_risk"] = {
+ "enabled": True,
+ "gap_down_threshold": -0.03,
+ "gap_up_entry_block": 0.05,
+ }
+ params["gap_risk"][key] = bad_value
+ else:
+ params["performance_degradation"] = {
+ "enabled": True,
+ "min_win_rate": 0.35,
+ "recent_trades": 20,
+ }
+ params["performance_degradation"][key] = bad_value
+
+ with pytest.raises(ValueError, match=key):
+ _validate(params)
diff --git a/tests/test_critical_fixes.py b/tests/test_critical_fixes.py
index 26b9bb5d..0c4ed41e 100644
--- a/tests/test_critical_fixes.py
+++ b/tests/test_critical_fixes.py
@@ -334,8 +334,16 @@ def generate_signal(self, df, symbol=None):
lambda cfg: SimpleNamespace(resolve=lambda: ["005930"]),
)
monkeypatch.setattr(main_mod, "_get_strategy", lambda strategy: FakeStrategy())
- monkeypatch.setattr(repositories, "get_all_positions", lambda account_key=None: [])
- monkeypatch.setattr(repositories, "get_position", lambda symbol, account_key="": None)
+ monkeypatch.setattr(
+ repositories,
+ "get_all_positions",
+ lambda account_key=None, mode="paper": [],
+ )
+ monkeypatch.setattr(
+ repositories,
+ "get_position",
+ lambda symbol, account_key="", mode="paper": None,
+ )
main_mod.run_paper_trading(SimpleNamespace(strategy="scoring"))
@@ -359,6 +367,12 @@ def test_live_liquidate_syncs_broker_positions_before_loading_db_positions(self,
monkeypatch.setattr(main_mod.Config, "get", lambda: config)
monkeypatch.setenv("ENABLE_LIVE_TRADING", "true")
+ def fake_set_trading_halt(reason, *, source, mode, detail=None):
+ calls.append(("halt", source, mode))
+ return {"halted": True, "event_id": 101, "reason": reason}
+
+ monkeypatch.setattr(repositories, "set_trading_halt", fake_set_trading_halt)
+
class FakePortfolio:
def __init__(self, cfg, account_key=""):
self.account_key = account_key
@@ -371,9 +385,13 @@ def sync_with_broker(self, auto_correct=True):
"message": "KIS-only 포지션 DB 반영",
}
- def fake_get_all_positions():
+ def fake_get_all_positions(mode="paper"):
+ assert mode == "live"
calls.append(("positions",))
- assert calls[0] == ("sync", "", True)
+ assert calls[:2] == [
+ ("halt", "main.run_emergency_liquidate", "live"),
+ ("sync", "", True),
+ ]
return [
SimpleNamespace(symbol="005930", avg_price=60_000, quantity=3, account_key=""),
]
@@ -408,7 +426,11 @@ def execute_sell(self, symbol, price, quantity=None, reason="", strategy=""):
summary = main_mod.run_emergency_liquidate(SimpleNamespace(confirm_live=True))
- assert calls[:2] == [("sync", "", True), ("positions",)]
+ assert calls[:3] == [
+ ("halt", "main.run_emergency_liquidate", "live"),
+ ("sync", "", True),
+ ("positions",),
+ ]
assert sells == [{
"account_key": "",
"symbol": "005930",
@@ -421,8 +443,8 @@ def execute_sell(self, symbol, price, quantity=None, reason="", strategy=""):
assert summary["succeeded"] == 1
assert summary["failed"] == 0
- def test_live_liquidate_does_not_fallback_to_avg_price_when_current_price_missing(self, monkeypatch):
- """live 긴급 청산은 현재가 조회 실패 시 평균단가 지정가 매도를 내지 않는다."""
+ def test_live_liquidate_uses_avg_price_reference_for_market_exit_when_current_price_missing(self, monkeypatch):
+ """live 긴급 청산은 현재가가 없어도 평균단가를 참조가로 시장가 청산을 시도한다."""
import main as main_mod
import database.repositories as repositories
@@ -437,11 +459,16 @@ def test_live_liquidate_does_not_fallback_to_avg_price_when_current_price_missin
monkeypatch.setattr(main_mod.Config, "get", lambda: config)
monkeypatch.setenv("ENABLE_LIVE_TRADING", "true")
+ monkeypatch.setattr(
+ repositories,
+ "set_trading_halt",
+ lambda *args, **kwargs: {"halted": True, "event_id": 102},
+ )
monkeypatch.setattr(main_mod, "_sync_live_positions_before_liquidation", lambda cfg: [])
monkeypatch.setattr(
repositories,
"get_all_positions",
- lambda: [
+ lambda mode="paper": [
SimpleNamespace(symbol="005930", avg_price=60_000, quantity=3, account_key=""),
],
)
@@ -466,7 +493,7 @@ def execute_sell(self, symbol, price, quantity=None, reason="", strategy=""):
"reason": reason,
"strategy": strategy,
})
- raise AssertionError("현재가 실패 시 live 매도 주문을 호출하면 안 됨")
+ return {"success": True}
class FakeNotifier:
def __init__(self, cfg):
@@ -482,17 +509,23 @@ def send_message(self, text, critical=False):
summary = main_mod.run_emergency_liquidate(SimpleNamespace(confirm_live=True))
assert calls == [("price", "005930", "12345678-01")]
- assert sells == []
+ assert sells == [{
+ "symbol": "005930",
+ "price": 60_000,
+ "quantity": None,
+ "reason": "긴급 전량 청산 (--mode liquidate)",
+ "strategy": "emergency_liquidate",
+ }]
assert summary["attempted"] == 1
- assert summary["succeeded"] == 0
- assert summary["failed"] == 1
+ assert summary["succeeded"] == 1
+ assert summary["failed"] == 0
assert summary["details"] == [{
"symbol": "005930",
"account_key": "",
- "status": "failed",
- "reason": "실전 긴급 청산 현재가 조회 실패",
+ "status": "success",
+ "price": 60_000,
}]
- assert notifications and "실패 상세" in notifications[0]["text"]
+ assert notifications and notifications[0]["critical"] is True
def test_live_liquidate_aborts_when_broker_sync_fails_before_position_load(self, monkeypatch):
"""live 긴급 청산 전 KIS↔DB 동기화 실패가 남으면 stale DB 포지션만으로 진행하지 않는다."""
@@ -510,6 +543,11 @@ def test_live_liquidate_aborts_when_broker_sync_fails_before_position_load(self,
monkeypatch.setattr(main_mod.Config, "get", lambda: config)
monkeypatch.setenv("ENABLE_LIVE_TRADING", "true")
+ monkeypatch.setattr(
+ repositories,
+ "set_trading_halt",
+ lambda *args, **kwargs: {"halted": True, "event_id": 103},
+ )
class FakePortfolio:
def __init__(self, cfg, account_key=""):
@@ -543,6 +581,11 @@ def test_live_liquidate_aborts_when_broker_sync_partially_corrects_positions(sel
monkeypatch.setattr(main_mod.Config, "get", lambda: config)
monkeypatch.setenv("ENABLE_LIVE_TRADING", "true")
+ monkeypatch.setattr(
+ repositories,
+ "set_trading_halt",
+ lambda *args, **kwargs: {"halted": True, "event_id": 104},
+ )
class FakePortfolio:
def __init__(self, cfg, account_key=""):
@@ -626,7 +669,7 @@ def test_liquidate_summary_reports_sell_failure(self, monkeypatch):
monkeypatch.setattr(
repositories,
"get_all_positions",
- lambda: [
+ lambda mode="paper": [
SimpleNamespace(symbol="005930", avg_price=60_000, quantity=3, account_key=""),
],
)
@@ -663,7 +706,7 @@ def test_liquidate_summary_sends_critical_notification(self, monkeypatch):
monkeypatch.setattr(
repositories,
"get_all_positions",
- lambda: [
+ lambda mode="paper": [
SimpleNamespace(symbol="005930", avg_price=60_000, quantity=3, account_key=""),
],
)
@@ -788,8 +831,8 @@ def test_web_dashboard_defaults_to_loopback(self, monkeypatch):
assert host == "127.0.0.1"
assert port == 8080
- def test_web_dashboard_uses_configured_or_explicit_host(self, monkeypatch):
- """외부 바인드는 설정 또는 CLI에서 명시한 경우에만 사용한다."""
+ def test_web_dashboard_rejects_external_host(self, monkeypatch):
+ """인증 없는 금융 대시보드는 명시해도 외부 주소에 바인드하지 않는다."""
from monitoring import web_dashboard as wd
monkeypatch.setattr(
@@ -798,7 +841,8 @@ def test_web_dashboard_uses_configured_or_explicit_host(self, monkeypatch):
lambda: SimpleNamespace(settings={"dashboard": {"host": "0.0.0.0", "port": 9090}}),
)
- assert wd.resolve_dashboard_bind() == ("0.0.0.0", 9090)
+ with pytest.raises(ValueError, match="loopback"):
+ wd.resolve_dashboard_bind()
assert wd.resolve_dashboard_bind(host="127.0.0.1", port=7070) == ("127.0.0.1", 7070)
def test_main_dashboard_passes_host_and_port(self, monkeypatch):
diff --git a/tests/test_dashboard_basket_evaluation.py b/tests/test_dashboard_basket_evaluation.py
index 5c1cdbc2..8bfbfc42 100644
--- a/tests/test_dashboard_basket_evaluation.py
+++ b/tests/test_dashboard_basket_evaluation.py
@@ -108,7 +108,7 @@ async def run():
html = await res.text()
finally:
await client.close()
- assert "basketEval" in html and "승격 진행률" in html
+ assert "basketEval" in html and "모의 운용 검증" in html
asyncio.run(run())
diff --git a/tests/test_dashboard_baskets.py b/tests/test_dashboard_baskets.py
index d40cd6a3..a53aad94 100644
--- a/tests/test_dashboard_baskets.py
+++ b/tests/test_dashboard_baskets.py
@@ -6,7 +6,7 @@
import sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-from datetime import datetime
+from datetime import datetime, timedelta
from unittest.mock import patch
import pytest
@@ -27,8 +27,11 @@ def _seed_pocket(basket_name):
init_database()
session = get_session()
try:
+ snapshot_at = (datetime.now() - timedelta(days=1)).replace(
+ hour=0, minute=0, second=0, microsecond=0
+ )
session.add(PortfolioSnapshot(
- account_key=acct, date=datetime(2026, 7, 6),
+ account_key=acct, date=snapshot_at,
total_value=400_126, cash=171_846, invested=228_280,
cumulative_return=0.04, mdd=0.0, peak_value=400_126,
))
@@ -39,7 +42,11 @@ def _seed_pocket(basket_name):
session.commit()
finally:
session.close()
- record_cash_flow(100_000, account_key=acct, occurred_at=datetime(2026, 7, 6, 9, 0))
+ record_cash_flow(
+ 100_000,
+ account_key=acct,
+ occurred_at=snapshot_at.replace(hour=9),
+ )
return acct
@@ -48,9 +55,15 @@ def _cfg(basket_name):
basket_name: {
"name": "소액 적립 (KODEX200 50/50)",
"enabled": True,
+ "primary": True,
+ "purpose": "월 적립 중심",
+ "contribution_plan": {
+ "enabled": True, "cadence": "monthly", "amount": 100_000,
+ },
"initial_capital": 300_000,
"target_stock_weight": 0.5,
"holdings": {"069500": 1.0},
+ "holding_names": {"069500": "KODEX 200"},
}
}
@@ -86,9 +99,15 @@ def test_principal_snapshot_deployment_positions(self):
assert b["design_fraction"] == pytest.approx(0.5)
# 보유
assert b["positions"] == [{
- "symbol": "069500", "quantity": 1,
+ "symbol": "069500", "name": "KODEX 200", "quantity": 1,
"avg_price": 128_135.0, "invested": 128_135.0,
}]
+ assert data["mode"] in {"paper", "live"}
+ assert b["is_primary"] is True
+ assert b["purpose"] == "월 적립 중심"
+ assert b["contribution_plan"] == {
+ "enabled": True, "cadence": "monthly", "amount": 100_000.0,
+ }
def test_no_snapshot_yet_is_null_not_crash(self):
name = "kr_pocket_empty" # 시드 없음 — 운영 전 상태
@@ -195,6 +214,31 @@ async def run():
asyncio.run(run())
+ @pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치")
+ def test_snapshots_endpoint_clamps_excessive_range(self):
+ import asyncio
+ from aiohttp.test_utils import TestClient, TestServer
+ from monitoring import web_dashboard as wd
+
+ async def run():
+ with patch.object(
+ wd,
+ "get_snapshots_json",
+ return_value={"snapshots": [], "days": 3650, "mode": "paper"},
+ ) as mocked:
+ app = wd.create_app()
+ client = TestClient(TestServer(app))
+ await client.start_server()
+ try:
+ response = await client.get("/api/snapshots?days=999999")
+ assert response.status == 200
+ await response.json()
+ finally:
+ await client.close()
+ mocked.assert_called_once_with(days=3650, account_key=None)
+
+ asyncio.run(run())
+
@pytest.mark.skipif(not _has_aiohttp, reason="aiohttp 미설치")
def test_empty_account_key_filters_default_account_only(self):
# account_key=(빈 값)은 기본 계정('')만 — 무필터(전 계정 혼합)로 강등되면
@@ -209,7 +253,7 @@ def test_empty_account_key_filters_default_account_only(self):
session = get_session()
try:
session.add(PortfolioSnapshot(
- account_key="", date=_dt(2026, 7, 7),
+ account_key="", date=_dt.now() - timedelta(days=1),
total_value=10_000_000, cash=10_000_000, invested=0,
))
session.commit()
@@ -236,11 +280,141 @@ def test_html_page_contains_basket_tracks_section():
from monitoring.web_dashboard import _html_page
html = _html_page()
- assert "basketTracks" in html # 섹션
- assert "/api/baskets" in html # 폴링 대상
- assert "chartAccount" in html # 차트 계정 선택기
- assert "/api/deposit" in html # 웹 입금 폼
- assert "depositOverlay" in html # 입금 모달
+ assert "basketTracks" in html # 주력 포트폴리오 섹션
+ assert "chartAccount" in html # 장기 차트 계정 선택기
+ assert 'id="decisionTitle"' in html # 오늘의 단일 판단
+ assert '