Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ reports/validation_walkforward_*.txt
# --- 테스트/에이전트 임시 산출물 ---
pytest_tmp*/
.claude/worktrees/
output/
.playwright-mcp/

# --- IDE / OS ---
.idea/
Expand Down
151 changes: 74 additions & 77 deletions README.md

Large diffs are not rendered by default.

54 changes: 18 additions & 36 deletions config/baskets.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,25 +29,12 @@ baskets:
"006400": 0.10 # 삼성SDI 10%
# 합계 = 1.00 (현금 비중 = diversification.min_cash_ratio로 별도 관리)

# --- 분산 대형주 buy&hold (수익성 검증 결론 반영) ---
# docs/PROFITABILITY_FINDINGS.md 결론: 대형주에서 능동 alpha는 없고, 분산 보유(베타)가
# 현실적 고수익 경로다(실행 기준 ~22% CAGR, Sharpe ~1.0, MDD ~-20%). 능동 리밸런싱은
# 비용이라 수익을 깎으므로 회전을 최소화한다 — 그래서 분기(quarterly) + 넓은 드리프트
# 임계값(8%p) + 낮은 회전 상한으로 "사서 들고 있기"에 가깝게 운용한다.
# 2026-06-10 paper 운영 개시: trading.mode=paper에서 트랙레코드(60영업일) 축적 목적.
# paper에서는 실주문이 없고, live 전환은 별도로 ENABLE_LIVE_TRADING + --confirm-live +
# 바스켓별 readiness gate(basket_rebalance:kr_diversified_hold)를 모두 통과해야 한다.
# 2026-08-07 튜닝: 운영 2개월 실측을 반영해 두 가지를 명시로 바꿨다.
# (1) target_stock_weight 0.60 — 종전에는 '주식 80%(=1-min_cash_ratio)'가 설계였는데
# 실효 배치율은 59%였다(하이닉스 슬롯 미충족 + 정수주 절사 + 추가매수 영구 거부
# 버그). 그 결과 6/10~8/07 KOSPI -18.6% 구간에서 총자산은 -5.0%로 막혔다 —
# 현금 완충이 방어의 주원인이었다. 그 완충을 '버그의 부작용'으로 두지 않고
# 의도한 정책으로 고정한다. 상승 전환 시 80% 대비 수익은 덜 먹는 대가다.
# (2) risk 블록 — 종전에는 전역 단타 기준(-3% 손절/+8% 익절/-5% 트레일링)이 진입
# 시 기록되기만 하고 일일 사이클이 평가하지 않아, 손절선을 뚫은 포지션이 그대로
# 방치됐다. 트랙 성격에 맞는 폭으로 다시 잡고 실제로 평가한다(core/basket_risk.py).
# --- 대형주 분산 투자 ---
# 주식 60%·현금 40%, 비중 8%p 이탈 시 조정한다. 기존 운용 규칙을 유지한다.
# 특정 과거 구간의 높은 수익률을 앞으로의 기대수익률로 사용하지 않는다.
# 기존 매입 이후 기록은 보존하며, 새로운 위험 관리 검증은 docs/RISK_REVIEW_20260917.md 참고.
kr_diversified_hold:
name: "분산 대형주 buy&hold (저회전)"
name: "대형주 분산 투자"
enabled: true
primary: false
purpose: "장기 관찰용"
Expand All @@ -70,10 +57,8 @@ baskets:
# 60%로 명시한 지금은 미달이 곧 조치 대상(현금 누수)이다. 실제로 감시가 꺼져 있는
# 동안 배치율이 61.0% → 54.9%로 새는 것을 헬스가 전혀 잡지 못했다.
deployment_tolerance: 0.05
# 리스크 오버레이는 이 트랙에선 아직 끈다 — 60거래일 검증을 마치고 실전 전환 승인을
# 기다리는 중이라, 정책을 바꾸면 검증 대상이 달라진다. 백테스트(2021-12~2026-09,
# docs/RISK_OVERLAY_FINDINGS.md)에서 낙폭 제어(-10%에서 절반)는 최대낙폭 -26.1→-16.4%,
# 샤프 0.82→0.85(연수익률 18.3→15.8%)였다. 켜려면 enabled: true.
# 이 계좌의 위험 조절 규칙은 기존대로 꺼 둔다.
# ETF 적립 계좌에서의 개선 결과를 개별 주식 계좌에 그대로 적용하지 않는다.
overlays:
drawdown_guard:
enabled: false
Expand Down Expand Up @@ -167,10 +152,14 @@ baskets:
# 월 적립은 웹 대시보드 입금 버튼 또는 tools/record_deposit.py --basket kr_pocket
# --amount 100000 으로 기록(입금은 TWR이 중화 — 수익률 왜곡 없음). 다음 사이클이 흡수한다.
kr_pocket:
name: "소액 적립 (지수+CD금리 파킹)"
name: "ETF 적립"
enabled: true
primary: true
purpose: "월 적립 중심"
promotion:
# 이전 규칙으로 쌓은 운영 일수만으로 새 규칙을 실전에 적용하지 않는다.
paper_only: true
review_note: "9월 17일 변경한 위험 관리 규칙을 모의투자로 검증하고 있습니다."
contribution_plan:
enabled: true
cadence: "monthly"
Expand All @@ -184,20 +173,13 @@ baskets:
# (1주가/총액)과의 max로 자동 완화·조임한다 — 잔고가 커지면 이 값이 다시 지배한다.
monitoring:
deployment_tolerance: 0.10
# --- 리스크 오버레이 (2026-09-17, docs/RISK_OVERLAY_FINDINGS.md) ---
# 종목을 고르지 않고 '주식 비중을 언제 줄이는가'만 바꾼다. 배수는 설계 비중(95%)에
# 곱한다: 추세 필터 발동 시 95% × 0.5 = 47.5%(지수 23.75 + 파킹 23.75), 나머지 현금.
# 추세 필터: 코스피200이 200일선 아래로 2% 넘게 내려가면 절반, 위로 2% 넘게 올라와야
# 복귀. 2002~2026 백테스트: 연수익률 7.45→7.19%(-0.26%p), 최대낙폭
# -28.8→-21.8%, 최악 연도 -18.7→-12.3%, 샤프 0.42→0.45. ETF 구간(2014~)도
# 연 9.62→9.32%에 최악 연도 -11.2→-4.6%. 비용은 ETF라 거래세 없이 수수료뿐.
# 낙폭 제어: 시간가중 NAV가 고점 대비 -10% 아래면 절반, -5% 안으로 회복해야 복귀.
# ETF 구간 최대낙폭 -21.7→-16.3%(추세와 결합), 샤프 0.59→0.64.
# 변동성 목표: 한국 시장에선 수익을 2~4%p 깎아(고변동 구간이 고수익 구간) 끈다.
# 상태(추세 아래/낙폭 발동)는 data/overlay_state/kr_pocket.json으로 이어지고, 헬스와
# 대시보드는 '설계 × 배수'를 그날의 목표로 본다(오버레이가 줄인 날 미달 경보 안 울림).
# 끄려면 enabled: false — 다음 사이클부터 설계 비중으로 되돌아간다.
# 2026-09-17 재검증: 주식과 CD ETF를 함께 줄이던 동작을 수정한다.
# 추세·낙폭 규칙이 겹치면 더 낮은 비중을 한 번만 적용한다.
# 기본: 주식 47.5 / CD 47.5 / 현금 5 → 방어: 주식 23.75 / CD 71.25 / 현금 5.
# 실전 자동 전환 없음. 기간별·비용별 검증과 한계: docs/RISK_REVIEW_20260917.md
overlays:
combination: minimum
defensive_symbol: "357870"
trend_filter:
enabled: true
index_symbol: "KS200"
Expand Down
12 changes: 11 additions & 1 deletion core/basket_evaluation.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ def format_evaluation_report(result: dict[str, Any], basket_name: str = "") -> s
f" 비용 드래그: 누적 {result['cost_drag_cum']:.4%} | 연환산 {result['cost_drag_annualized']:.4%}"
+ (
" (기간 미충족 — 초기 매입 일회성 비용이 과장되므로 판정 미적용)"
if result["verdict"] == "WAIT" else ""
if result["progress_days"] < result["min_trading_days"] else ""
),
]
if m.get("nav_return_pct") is not None:
Expand Down Expand Up @@ -540,4 +540,14 @@ def _d(v):
result["metrics"]["composition_gap_pct"] = gaps["composition_gap_pct"]
result["metrics"]["total_gap_pct"] = gaps["total_gap_pct"]

promotion = basket_cfg.get("promotion") or {}
if promotion.get("paper_only", False):
# 운영 일수는 남겨 두되, 이전 규칙의 기록으로 새 정책이 통과하지 않게 한다.
result["operation_verdict"] = result["verdict"]
result["paper_only"] = True
if result["verdict"] == "PASS_CANDIDATE":
result["verdict"] = "WAIT"
result["issues"].append(str(promotion.get("review_note") or
"변경한 운용 규칙을 모의투자로 검증하고 있습니다. 실전 전환은 제한됩니다."))

return result, basket_name
46 changes: 37 additions & 9 deletions core/basket_rebalancer.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,11 @@ def _stock_fraction(self) -> float:
배치 진단·트리거·주문 계획이 전부 이 값을 쓰므로, 오버레이가 비중을 줄인 날
'현금 래칫' 감시가 설계 비중을 향해 되사는 일은 생기지 않는다.
"""
return self.base_stock_fraction() * self._overlay_scale()
from core.risk_overlays import overlay_target_weights
return sum(overlay_target_weights(
self.holdings, self.base_stock_fraction(), self._overlay_scale(),
(self.basket.get("overlays") or {}).get("defensive_symbol"),
).values())

def _overlay_scale(self) -> float:
decision = self.overlay_decision()
Expand Down Expand Up @@ -220,7 +224,7 @@ def overlay_decision(self):
if decision.reasons:
logger.info("바스켓 '{}' 리스크 오버레이 발동: {} (배수 {})", self.basket_name, " · ".join(decision.reasons), decision.scale)
else:
logger.info("바스켓 '{}' 리스크 오버레이 발동 없음 (배수 1.0)", self.basket_name)
logger.info("바스켓 '{}' 리스크 오버레이 계산 완료 (배수 {})", self.basket_name, decision.scale)
self._overlay_decision = decision
return decision

Expand All @@ -245,11 +249,21 @@ def _fetch_index_closes(self, symbol: str, ma_days: int) -> list[float] | None:
try:
import pandas as pd
parsed = pd.to_datetime(dates).dt.date if hasattr(pd.to_datetime(dates), "dt") else pd.to_datetime(dates).date
mask = [d != today for d in parsed]
frame = frame[mask]
mask = [d < today for d in parsed]
frame = frame[mask].copy()
frame["_overlay_date"] = [d for d, keep in zip(parsed, mask) if keep]
frame = frame.sort_values("_overlay_date")
if frame["_overlay_date"].duplicated().any():
return None
except Exception:
pass
closes = [float(c) for c in frame["close"].dropna().tolist()]
return None
else:
return None
# 누락 봉을 삭제하면 이동평균 창이 과거로 밀려 잘못 복귀할 수 있다.
try:
closes = [float(c) if c is not None else float("nan") for c in frame["close"].tolist()]
except (TypeError, ValueError):
return None
return closes or None

def _nav_series_for_overlay(self) -> tuple[list[float] | None, list[float] | None]:
Expand All @@ -264,9 +278,17 @@ def _nav_series_for_overlay(self) -> tuple[list[float] | None, list[float] | Non
return None, None
if snaps is None or getattr(snaps, "empty", True) or "cumulative_return" not in snaps.columns:
return None, None
cumulative = [float(v) for v in snaps["cumulative_return"].tolist() if v is not None]
if "date" in snaps.columns:
import pandas as pd
snaps = snaps.copy()
snaps["date"] = pd.to_datetime(snaps["date"], errors="coerce")
snaps = snaps[snaps["date"].dt.date < datetime.now(_KST).date()].sort_values("date")
try:
cumulative = [float(v) if v is not None else float("nan") for v in snaps["cumulative_return"].tolist()]
except (TypeError, ValueError):
return None, None
index = [1.0 + c / 100.0 for c in cumulative]
daily = [index[i] / index[i - 1] - 1.0 for i in range(1, len(index)) if index[i - 1] > 0]
daily = [index[i] / index[i - 1] - 1.0 if index[i - 1] > 0 else float("nan") for i in range(1, len(index))]
return cumulative, daily

def _is_live(self) -> bool:
Expand Down Expand Up @@ -374,7 +396,13 @@ def get_target_weights(self) -> dict[str, float]:
if total > 0 and abs(total - 1.0) > 0.001:
base = {s: w / total for s, w in base.items()}

return base
from core.risk_overlays import overlay_target_weights
targets = overlay_target_weights(
base, self.base_stock_fraction(), self._overlay_scale(),
(self.basket.get("overlays") or {}).get("defensive_symbol"),
)
invested = sum(targets.values())
return {s: w / invested for s, w in targets.items()} if invested > 0 else base

def get_current_weights(self, prices: dict[str, float] = None) -> dict[str, float]:
"""
Expand Down
5 changes: 5 additions & 0 deletions core/live_readiness.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ def check_basket_live_readiness(config, strategy_name: str) -> list[str]:
issues.append(
f"바스켓 '{basket_name}'이 enabled=false — paper 운영(트랙레코드)부터 시작하세요."
)
if (basket.get("promotion") or {}).get("paper_only", False):
issues.append(
f"바스켓 '{basket_name}'은 모의투자 전용(paper_only)입니다. "
"변경한 운용 규칙을 검증하기 전에는 실전으로 전환할 수 없습니다."
)
holdings = basket.get("holdings", {}) or {}
total_w = sum(float(w) for w in holdings.values())
if abs(total_w - 1.0) > 0.001:
Expand Down
Loading
Loading