From beed4b82df4f0ca6538580305da9bc365974714c Mon Sep 17 00:00:00 2001 From: easygap <103491329+easygap@users.noreply.github.com> Date: Tue, 22 Sep 2026 16:37:08 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=A7=A4=EC=88=98?= =?UTF-8?q?=EB=9F=89=EA=B3=BC=20=ED=98=84=EA=B8=88=20=ED=95=9C=EB=8F=84=20?= =?UTF-8?q?=EA=B3=84=EC=82=B0=EC=9D=84=20=EA=B3=A0=EC=B9=98=EA=B3=A0=20ETF?= =?UTF-8?q?=20=EB=B9=84=EC=9A=A9=20=EA=B8=B0=EC=A4=80=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 - config/risk_params.yaml | 4 + core/basket_rebalancer.py | 141 +++-- core/instrument_classes.py | 9 + core/risk_manager.py | 9 +- docs/REBALANCE_REVIEW_20260922.md | 86 +++ docs/RISK_REVIEW_20260922.md | 2 + .../research/rebalance_review_20260922.json | 561 ++++++++++++++++++ tests/test_basket_rebalancer.py | 1 + tests/test_deployment_ratchet.py | 150 +++++ tests/test_rebalance_review.py | 78 +++ tests/test_risk_manager.py | 54 ++ tools/rebalance_review.py | 243 ++++++++ 13 files changed, 1299 insertions(+), 41 deletions(-) create mode 100644 docs/REBALANCE_REVIEW_20260922.md create mode 100644 reports/research/rebalance_review_20260922.json create mode 100644 tests/test_rebalance_review.py create mode 100644 tools/rebalance_review.py diff --git a/README.md b/README.md index 41906624..1e297d43 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,6 @@ 오류가 나거나 필요한 기능이 있으면 [오류 제보·기능 제안](https://github.com/easygap/quant_trader/issues/new/choose)에 남겨 주세요. 화면이나 오류 메시지를 올릴 때는 계좌번호와 API 키를 지워 주세요. -도움이 됐다면 **Star**를 눌러 주세요. -
개발 문서 diff --git a/config/risk_params.yaml b/config/risk_params.yaml index 477f175b..2ac1fb4a 100644 --- a/config/risk_params.yaml +++ b/config/risk_params.yaml @@ -196,6 +196,10 @@ transaction_costs: # # 면제되는 것은 위 두 가지뿐이다. 노출 상한·유동성·갭·현금·거래중단은 그대로 적용된다. instrument_classes: + # 국내 ETF 호가: 2,000원 미만 1원, 그 이상 5원. 거래비용의 틱 계산에만 사용한다. + krx_etf_symbols: + - "069500" + - "357870" non_company_symbols: - "069500" # KODEX 200 — 200종목 지수 ETF - "357870" # TIGER CD금리투자KIS(합성) — 금리 파킹, 주식 아님 diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index c7643a34..22bff352 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -10,8 +10,10 @@ from __future__ import annotations +import math import os from datetime import datetime, timedelta +from types import SimpleNamespace from zoneinfo import ZoneInfo import yaml @@ -799,7 +801,25 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder reason=f"비중 초과 ({actual_w:.1%} → {target_w:.1%}, {drift:.1%})", ), sell_qty * price)) - # 1-b) 집계 배치율 보충: 종목별 드리프트가 전부 min_trade 미만이라 개별로는 아무것도 + # 2) 매도부터 회전 한도를 배정한다. 정수 주로 줄인 실제 주문금액에 + # 최소 거래금액을 적용해야 소액 주문이 기준을 우회하지 않는다. + candidates.sort(key=lambda c: (0 if c[0].action == "SELL" else 1, -c[1])) + orders: list[RebalanceOrder] = [] + total_trade_amount = 0.0 + for order, notional in candidates: + budget_left = max_turnover_amount - total_trade_amount + if budget_left < min_trade: + break + if notional > budget_left: + order.quantity = int(budget_left / order.price) + notional = order.quantity * order.price + order.reason += " (회전상한 부분 실행)" + if order.quantity <= 0 or notional < min_trade: + continue + orders.append(order) + total_trade_amount += notional + + # 3) 집계 배치율 보충: 종목별 드리프트가 전부 min_trade 미만이라 개별로는 아무것도 # 못 사는데, 그 얇은 미달분을 합치면 설계 배치율에서 크게 벗어나 있는 상태를 채운다. # # 이게 없으면 리밸런싱은 현금을 늘리기만 하는 한쪽 방향 래칫이 된다: 비중 초과 @@ -817,15 +837,21 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder prices.get(p.symbol, p.avg_price) * p.quantity for p in positions if p.symbol in targets ) - shortfall = investable - stock_value + # 앞에서 확정한 매수·매도를 먼저 반영한다. 후보 전체를 쓰면 회전 한도로 + # 줄이거나 제외한 주문까지 체결된 것으로 계산해 보충량이 잘못된다. + projected_stock_value = stock_value + sum( + (1 if order.action == "BUY" else -1) * order.quantity * order.price + for order in orders + ) + shortfall = investable - projected_stock_value band = float(self.rebalance_cfg.get("deployment_band", 0.03)) * total_value - already = {o.symbol for o, _ in candidates} - if shortfall > band: + already = {o.symbol for o in orders} + if shortfall > band and max_turnover_amount - total_trade_amount >= min_trade: logger.info( - "바스켓 '{}' 집계 배치율 미달 {:,.0f}원 (실제 {:.1%} vs 설계 {:.1%}) — " - "격차를 줄이는 보충 매수만 집행", + "바스켓 '{}' 기존 주문 반영 후 투자금 {:,.0f}원 부족 " + "(계획 {:.1%}, 목표 {:.1%}) — 남은 한도에서 보충 매수", self.basket_name, shortfall, - stock_value / total_value if total_value else 0, + projected_stock_value / total_value, self._stock_fraction(), ) remaining = shortfall @@ -864,44 +890,25 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder # 잔여 격차를 가장 많이 줄이는 순서로 집행한다(1주 단가가 낮을수록 정밀). topups.sort() for _residual, notional, symbol, qty, price in topups: + # 이미 허용 범위에 들어왔으면 정확한 목표까지 억지로 채우지 않는다. + if remaining <= band: + break + if notional > max_turnover_amount - total_trade_amount: + continue if abs(remaining - notional) >= abs(remaining): continue # 앞선 체결로 격차가 줄어 더는 개선이 아님 - candidates.append((RebalanceOrder( + orders.append(RebalanceOrder( symbol=symbol, action="BUY", quantity=qty, price=price, reason=( f"배치율 보충 (집계 {stock_value / total_value:.1%} → " f"설계 {self._stock_fraction():.1%})" ), - ), notional)) + )) remaining -= notional + total_trade_amount += notional - # 2) SELL을 먼저(현금 확보) 두고 거래액 큰 순으로 정렬해 회전율 예산 우선권을 준다. - # (기존엔 dict 순서대로라 BUY가 예산을 먼저 소진해 자금원 SELL이 누락될 수 있었다.) - candidates.sort(key=lambda c: (0 if c[0].action == "SELL" else 1, -c[1])) - - # 3) 회전율 예산 적용: 개별 거래가 예산을 넘으면 그 거래만 건너뛰고(continue) 더 작은 - # 거래는 계속 검토한다(기존 break는 이후 거래를 모두 누락시켰다). - orders: list[RebalanceOrder] = [] - total_trade_amount = 0.0 - for order, notional in candidates: - remaining = max_turnover_amount - total_trade_amount - if remaining < min_trade: - # 예산 소진 — 이후 후보는 모두 min_trade 이상이라 어차피 담을 수 없다. - break - if notional > remaining: - # 부분 실행: 예산 잔여분만큼 수량을 줄여 집행한다(드리프트 점진 수렴). - shrunk_qty = int(remaining / order.price) - if shrunk_qty <= 0: - continue - shrunk_notional = shrunk_qty * order.price - if shrunk_notional < min_trade: - continue - order.quantity = shrunk_qty - order.reason += " (회전상한 부분 실행)" - notional = shrunk_notional - orders.append(order) - total_trade_amount += notional - + orders = self._fit_cash_orders(orders, summary, prices, positions) + total_trade_amount = sum(o.quantity * o.price for o in orders) sells = [o for o in orders if o.action == "SELL"] buys = [o for o in orders if o.action == "BUY"] ordered = sells + buys @@ -913,6 +920,68 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder return ordered + def _fit_cash_orders(self, orders, summary, prices, positions): + """매도 후 남는 현금과 예상 비용 안에서 매수 수량을 정한다. + + 계획의 매도가 실제로 체결된다는 보장은 없으므로, 실행부의 잔고·비중 검사는 + 그대로 필요하다. 여기서는 계획 시점부터 현금 부족인 주문을 줄인다. + """ + if not orders: + return orders + from core.risk_manager import RiskManager + + total = float(summary["total_value"]) + held_value = sum(prices.get(p.symbol, p.avg_price) * p.quantity for p in positions) + cash = float(summary.get("cash", total - held_value)) + div = (self._risk_params or {}).get("diversification", {}) or {} + min_cash = float(self.basket.get("min_cash_ratio", div.get("min_cash_ratio", 0.20))) + if not all(math.isfinite(v) for v in (total, cash, min_cash)) or not 0 <= min_cash <= 1: + logger.error("리밸런싱 보류: 잔고 또는 최소 현금 비중을 확인할 수 없습니다") + return [] + reserve = total * min_cash + min_trade = self.rebalance_cfg.get("min_trade_amount", 100000) + rm = RiskManager(SimpleNamespace(risk_params=self._risk_params)) + pos_map = {p.symbol: p for p in positions} + snapshot = getattr(self, "_market_snapshot", None) or {} + funded = [] + for order in orders: # plan_rebalance가 정한 매도 → 매수 순서 + pos = pos_map.get(order.symbol) + + def estimate(qty): + return rm.calculate_transaction_costs( + order.price, qty, order.action, symbol=order.symbol, + avg_price=pos.avg_price if pos else None, + avg_daily_volume=snapshot.get(order.symbol, {}).get("avg_volume"), + ) + + if order.action == "SELL": + costs = estimate(order.quantity) + cash += costs["execution_price"] * order.quantity - sum( + costs[k] for k in ("commission", "tax", "capital_gains_tax") + ) + else: + # 거래량 구간에 따라 슬리피지 배수가 달라지므로 1주 비용의 단순 배수 대신 + # 후보 수량별 비용을 계산한다. 큰 계좌도 주 수만큼 반복하지 않는다. + low, high = 0, order.quantity + budget = max(0.0, cash - reserve) + while low < high: + qty = (low + high + 1) // 2 + costs = estimate(qty) + required = costs["execution_price"] * qty + costs["commission"] + if required <= budget: + low = qty + else: + high = qty - 1 + if low <= 0 or low * order.price < min_trade: + continue + if low < order.quantity: + order.quantity = low + order.reason += " (비용·현금 한도에 맞춰 수량 조정)" + costs = estimate(order.quantity) + cash -= costs["execution_price"] * order.quantity + costs["commission"] + funded.append(order) + return funded + def execute( self, orders: list[RebalanceOrder], diff --git a/core/instrument_classes.py b/core/instrument_classes.py index 1373f64a..da8847d4 100644 --- a/core/instrument_classes.py +++ b/core/instrument_classes.py @@ -38,3 +38,12 @@ def is_non_company_symbol(symbol: str, risk_params: dict[str, Any] | None) -> bo if not symbol: return False return str(symbol).strip() in non_company_symbols(risk_params) + + +def is_krx_etf_symbol(symbol: str | None, risk_params: dict[str, Any] | None) -> bool: + """국내 ETF로 명시한 종목인지 확인한다. 비과세·기업 필터 면제와는 별개다.""" + classes = (risk_params or {}).get("instrument_classes") or {} + raw = classes.get("krx_etf_symbols") or [] + if not symbol or not isinstance(raw, (list, tuple, set)): + return False + return str(symbol).strip() in {str(value).strip() for value in raw} diff --git a/core/risk_manager.py b/core/risk_manager.py index 4ff2f31c..b6263030 100644 --- a/core/risk_manager.py +++ b/core/risk_manager.py @@ -10,10 +10,10 @@ from loguru import logger from config.config_loader import Config -from core.instrument_classes import is_non_company_symbol +from core.instrument_classes import is_krx_etf_symbol, is_non_company_symbol -def _get_tick_size(price: float) -> int: +def _get_tick_size(price: float, *, is_etf: bool = False) -> int: """ KRX 호가 단위 (원). 가격대별: 2천원미만 1원, 5천원미만 5원, 2만원미만 10원, 5만원미만 50원, @@ -21,6 +21,9 @@ def _get_tick_size(price: float) -> int: """ if price <= 0: return 1 + # KRX ETF: 2천원 미만 1원, 그 이상 5원. 고가 ETF에도 주식 호가표를 쓰지 않는다. + if is_etf: + return 1 if price < 2000 else 5 if price < 2000: return 1 if price < 5000: @@ -1035,7 +1038,7 @@ def calculate_transaction_costs( dynamic = costs.get("dynamic_slippage", {}) slippage_rate_fixed = costs.get("slippage", 0.0005) slippage_ticks = costs.get("slippage_ticks", 2) - tick = _get_tick_size(price) + tick = _get_tick_size(price, is_etf=is_krx_etf_symbol(symbol, self.risk_params)) participation_rate = 0.0 slippage_multiplier = 1.0 diff --git a/docs/REBALANCE_REVIEW_20260922.md b/docs/REBALANCE_REVIEW_20260922.md new file mode 100644 index 00000000..e556d1c3 --- /dev/null +++ b/docs/REBALANCE_REVIEW_20260922.md @@ -0,0 +1,86 @@ +# 주문 수량과 거래비용 점검 + +2026년 9월 22일 점검 + +추가 매수량을 중복 계산하는 문제와 비용을 빼지 않고 주문 수량을 정하는 문제를 고쳤습니다. ETF 거래비용에 일반 주식의 호가 단위를 적용하던 부분도 수정했습니다. + +과거 가격으로 비교했을 때 현금·비중 한도에 걸려 보류되는 주문은 줄었습니다. 다만 기본 비용 조건의 연환산 수익률은 **13.45%에서 13.31%로 낮아졌고, 최대 낙폭도 조금 커졌습니다.** 수익률과 낙폭이 함께 좋아진 결과는 아닙니다. + +## 고친 부분 + +### 추가 매수량 + +일반 매수 주문을 만든 뒤, 이미 살 예정인 금액을 빼지 않고 추가 매수량을 계산하고 있었습니다. 테스트에서는 40만원만 더 투자하면 되는 계좌에 50만원어치 주문이 나왔습니다. + +이제 매매 한도를 적용한 주문부터 반영하고, 남은 부족분만 계산합니다. 목표 비중의 허용 범위에 들어오면 추가 매수를 멈춥니다. 매도 때문에 투자 비중이 낮아지는 경우도 같은 계획에서 계산합니다. + +### 최소 주문금액 + +목표 금액과의 차이가 6만원이어도 1주 가격이 4만원이면 실제 주문은 4만원입니다. 기존에는 금액 차이만 확인해 최소 주문금액이 5만원인 설정을 통과했습니다. + +주 수를 정한 뒤 실제 주문금액으로 다시 확인합니다. 매매 한도나 현금 부족으로 수량을 줄였을 때도 같은 기준을 적용합니다. + +### 수수료와 남겨 둘 현금 + +주문 수량을 정할 때 거래비용을 빼지 않아 실행 단계에서 매수가 통째로 거절될 수 있었습니다. 총 100만원 중 5%를 현금으로 남기는 테스트에서는 2만 5천원짜리 종목을 각각 19주씩 사려다 두 번째 매수가 걸리는 경우를 재현했습니다. + +이제 예상 수수료와 체결 가격 차이를 반영해 두 번째 주문을 18주로 줄입니다. 매도 대금도 세금과 비용을 뺀 금액만 사용합니다. 예상대로 매도되지 않을 수 있으므로 실제 주문 직전의 잔고 확인은 계속 거칩니다. + +### ETF 호가 단위 + +국내 ETF는 가격이 2,000원 미만이면 1원, 그 이상이면 5원 단위로 호가를 냅니다. 기존 비용 계산은 ETF에도 일반 주식의 가격 구간별 호가 단위를 적용했습니다. [한국거래소 ETF 매매절차](https://regulation.krx.co.kr/contents/RGL/03/03060101/RGL03060101.jsp) + +예를 들어 10만원짜리 ETF 1주를 거래할 때, 현재 기본 설정의 예상 가격 차이는 100원이 아니라 50원입니다. 호가 단위 5원과 별개로 **기본 슬리피지 0.05%**가 더 크기 때문입니다. 거래량 대비 주문이 크면 비용을 높이는 규칙도 적용합니다. + +설정에 국내 ETF로 등록한 `069500`, `357870`에 이 기준을 적용합니다. 거래세 면제 목록만 보고 ETF로 판단하지 않습니다. 이 값은 비용 모형이며 실제 호가 차이를 측정한 값은 아닙니다. + +## 과거 가격으로 비교한 결과 + +ETF 적립 설정으로 **2020년 7월 8일~2026년 9월 17일**을 비교했습니다. 시작 금액은 30만원, 월 적립금은 10만원입니다. 전일 종가로 주문 수량을 정하고 다음 거래일 종가에 비용을 반영해 가상 체결했습니다. + +주문 계획의 차이를 비교하려고 양쪽에 동일한 비용 계산을 적용했습니다. 위에서 고친 ETF 호가 기준도 양쪽 모두 사용했습니다. 종목 구성, 비중, 추세·낙폭 조건을 바꿔 가며 수익률이 높은 조합을 찾은 실험은 아닙니다. + +| 항목 | 수정 전 | 수정 후 | +|---|---:|---:| +| 연환산 수익률 | 13.45% | 13.31% | +| 최대 낙폭 | −15.28% | −15.82% | +| 가상 체결 건수 | 121건 | 106건 | +| 현금·비중 한도로 보류한 주문 | 101건 | 3건 | +| 계산된 총 거래비용 | 76,478원 | 76,443원 | +| 비용 3배: 연환산 수익률 | 12.83% | 12.92% | +| 비용 3배: 최대 낙폭 | −15.44% | −15.70% | +| 비용 3배: 가상 체결 건수 | 110건 | 110건 | +| 비용 3배: 현금·비중 한도로 보류한 주문 | 189건 | 2건 | +| 비용 3배: 계산된 총 거래비용 | 143,827원 | 146,855원 | + +기본 조건에서 체결 건수는 줄었지만 비용 차이는 35원에 그쳤습니다. 비용을 3배로 높인 조건에서는 총 거래비용이 오히려 늘었습니다. 적은 거래가 항상 더 높은 수익으로 이어지지는 않았습니다. + +이 건수는 별도의 가상 체결 모형에서 나온 값입니다. 실제 계좌의 주문 거절 기록이 아닙니다. 수정 후에도 다음 날 가격이 바뀌면 주문이 한도에 걸릴 수 있습니다. + +## 결과를 볼 때 알아둘 점 + +- 코스피200 자료가 9월 17일까지만 있어 세 자료가 모두 있는 날까지 비교했습니다. ETF에만 있는 9월 18일·21일 가격은 제외했습니다. +- 같은 과거 자료를 다시 사용한 비교입니다. 앞으로의 수익을 확인한 결과가 아닙니다. +- 분배금, 실제 호가, 유동성 부족, 부분체결은 재현하지 못했습니다. 주 수와 주문 순서는 프로그램의 코드를 사용하지만 증권사 주문 실행부 전체를 재현한 것은 아닙니다. +- 현금 이자는 0%로 두었습니다. CD ETF는 과표기준가가 없어 양의 매매차익 전액에 15.4%를 적용했습니다. 실제 세금과 차이가 날 수 있습니다. +- 비용 3배 비교는 수수료와 슬리피지 가정만 높였습니다. 세율은 바꾸지 않았습니다. +- ETF 적립 설정은 모의투자 전용입니다. 이 점검에서 실제 주문을 실행하거나 기존 계좌 기록을 바꾸지 않았습니다. + +[전체 결과와 기간별 수치](../reports/research/rebalance_review_20260922.json)에 사용한 설정과 자료 해시를 남겼습니다. 기존의 [위험 관리 비교](RISK_REVIEW_20260922.md)와는 주문·체결 모형이 달라 숫자를 직접 이어 붙이면 안 됩니다. + +## 참고 자료 + +2026년 9월 22일에 확인했습니다. + +- [Vanguard, Delivering on design](https://workplace.vanguard.com/insights-and-research/perspective/delivering-on-design-disciplined-implementation-in-index-based-target-date-funds.html), 2026년 8월 14일: 목표 비중과 거래비용을 함께 고려하는 비중 조정 방식을 참고했습니다. 해당 펀드의 임계값이나 비용 절감률을 이 프로젝트에 적용하지는 않았습니다. +- [한국거래소 ETF 매매절차](https://regulation.krx.co.kr/contents/RGL/03/03060101/RGL03060101.jsp): 국내 ETF 호가 단위를 확인했습니다. +- [QuantConnect 체결 모형 안내](https://www.quantconnect.com/docs/v2/writing-algorithms/reality-modeling/trade-fills/key-concepts): 수량을 정하는 시점과 체결 시점을 나누고, 비용과 체결 가정을 밝혀 비교하는 데 참고했습니다. + +## 다시 실행하기 + +```powershell +.\.venv\Scripts\python.exe tools/rebalance_review.py --baseline 4f16e55ead3b105eb625f0d8e5316a50e260c83d --as-of 2026-09-22 +.\.venv\Scripts\python.exe -m pytest tests/test_deployment_ratchet.py tests/test_basket_rebalancer.py tests/test_risk_manager.py tests/test_rebalance_review.py -q +``` + +첫 명령은 기준 커밋의 주문 계획과 현재 코드를 비교합니다. 가격 캐시가 없으면 공개 종가를 조회합니다. 데이터 공급자가 과거 가격을 정정하면 결과가 달라질 수 있습니다. diff --git a/docs/RISK_REVIEW_20260922.md b/docs/RISK_REVIEW_20260922.md index 6ee7c569..b8898f63 100644 --- a/docs/RISK_REVIEW_20260922.md +++ b/docs/RISK_REVIEW_20260922.md @@ -2,6 +2,8 @@ 검증일 **2026-09-22** · 비교에 사용한 마지막 공통 종가 **2026-09-17** +같은 날 진행한 [주문 수량과 거래비용 점검](REBALANCE_REVIEW_20260922.md)에서는 추가 매수량, 현금 부족으로 인한 주문 보류, ETF 호가 계산을 고쳤습니다. 수정 전후 수익률과 낙폭도 함께 공개했습니다. + 이번에는 위험 비중을 결정하는 자료와 계좌 구분을 보완하고 계산 시간을 줄였습니다. 9월 17일 정한 추세·낙폭 임계값과 자산 구성은 유지했습니다. 수익률을 높이려고 새 매매 조건을 반복 탐색한 결과가 아닙니다. ## 고친 문제 diff --git a/reports/research/rebalance_review_20260922.json b/reports/research/rebalance_review_20260922.json new file mode 100644 index 00000000..de3c61b2 --- /dev/null +++ b/reports/research/rebalance_review_20260922.json @@ -0,0 +1,561 @@ +{ + "as_of": "2026-09-22", + "baseline_commit": "4f16e55ead3b105eb625f0d8e5316a50e260c83d", + "planner_sha256": "4d60cf97d57a05700f132f1d2edeac68c3e3d9d1ec8fa41afb4ef81bde788aa6", + "cost_model_sha256": "a6684044c1205e2eaebe52e13fd523fa0f5d3aa0143648303229b4901398c35b", + "data_audit": { + "common_last_bar": "2026-09-17", + "latest_available": { + "069500": "2026-09-21", + "357870": "2026-09-21", + "KS200": "2026-09-17" + }, + "excluded_after_common_bar": { + "069500": 2, + "357870": 2, + "KS200": 0 + } + }, + "input_sha256": "1f0d1dcd7063d843eb3b140a47d5c69f45cb3de35e952d630de20975df77f7d8", + "basket": { + "name": "ETF 적립", + "enabled": true, + "primary": true, + "purpose": "월 적립 중심", + "promotion": { + "paper_only": true, + "review_note": "9월 17일 변경한 위험 관리 규칙을 모의투자로 검증하고 있습니다." + }, + "contribution_plan": { + "enabled": true, + "cadence": "monthly", + "amount": 100000 + }, + "initial_capital": 300000, + "target_stock_weight": 0.95, + "min_cash_ratio": 0.05, + "monitoring": { + "deployment_tolerance": 0.1 + }, + "overlays": { + "combination": "minimum", + "defensive_symbol": "357870", + "trend_filter": { + "enabled": true, + "index_symbol": "KS200", + "ma_days": 200, + "band": 0.02, + "off_scale": 0.5 + }, + "drawdown_guard": { + "enabled": true, + "trigger": -0.1, + "release": -0.05, + "scale": 0.5 + }, + "volatility_target": { + "enabled": false, + "target": 0.2, + "lookback_days": 60, + "min_scale": 0.5, + "max_scale": 1.0, + "step": 0.1 + } + }, + "risk": { + "stop_loss_pct": 0, + "take_profit_pct": 0, + "trailing_stop_pct": 0 + }, + "rebalance": { + "trigger": "drift", + "drift_threshold": 0.08, + "min_trade_amount": 50000, + "max_turnover_ratio": 0.6, + "deployment_band": 0.03 + }, + "holdings": { + "069500": 0.5, + "357870": 0.5 + }, + "holding_names": { + "069500": "KODEX 200", + "357870": "TIGER CD금리투자KIS(합성)" + } + }, + "cost_config": { + "commission_rate": 0.00015, + "tax_rate": 0.002, + "tax_exempt_symbols": [ + "069500", + "357870" + ], + "holding_period_income_tax": { + "enabled": true, + "rate": 0.154, + "symbols": [ + "357870" + ] + }, + "slippage": 0.0005, + "slippage_ticks": 1, + "capital_gains_tax": { + "enabled": false, + "rate": 0.2 + }, + "dynamic_slippage": { + "enabled": true, + "warn_at_volume_ratio": 0.01, + "warn_slippage_multiplier": 2.0, + "critical_at_volume_ratio": 0.03, + "critical_slippage_multiplier": 4.0 + } + }, + "instrument_classes": { + "krx_etf_symbols": [ + "069500", + "357870" + ], + "non_company_symbols": [ + "069500", + "357870" + ] + }, + "comparisons": { + "cost_1x": { + "before": { + "all": { + "years": 6.19, + "cagr_pct": 13.45, + "period_return_pct": 118.46, + "vol_pct": 11.28, + "sharpe": 0.94, + "mdd_pct": -15.28, + "calmar": 0.88, + "worst_year_pct": -4.58, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 40.0, + "avg_exposure_pct": 36.9, + "turnover_per_year_pct": 185.6, + "final_value": 13463321, + "contributed": 7700000, + "profit": 5763321, + "yearly": { + "2020": 16.84, + "2021": 2.17, + "2022": -4.58, + "2023": 8.58, + "2024": -3.79, + "2025": 36.97, + "2026": 34.03 + }, + "planned_orders": 222, + "filled_orders": 121, + "rejected_orders": 101, + "trade_value": 55423578.0, + "cost": 76478.0, + "fees_and_tax": 48769.0, + "slippage_cost": 27709.0 + }, + "periods": { + "2020_2022": { + "years": 2.48, + "cagr_pct": 5.4, + "period_return_pct": 13.91, + "vol_pct": 6.87, + "sharpe": 0.38, + "mdd_pct": -10.01, + "calmar": 0.54, + "worst_year_pct": -4.58, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 36.7, + "avg_exposure_pct": 35.6, + "turnover_per_year_pct": 92.0, + "final_value": 3205638, + "contributed": 3200000, + "profit": 5638, + "yearly": { + "2020": 16.84, + "2021": 2.17, + "2022": -4.58 + } + }, + "2023_2025": { + "years": 2.99, + "cagr_pct": 12.72, + "period_return_pct": 43.09, + "vol_pct": 7.87, + "sharpe": 1.23, + "mdd_pct": -8.78, + "calmar": 1.45, + "worst_year_pct": -3.79, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 41.7, + "avg_exposure_pct": 37.7, + "turnover_per_year_pct": 142.3, + "final_value": 9318647, + "contributed": 6800000, + "profit": 2518647, + "yearly": { + "2023": 8.58, + "2024": -3.79, + "2025": 36.97 + } + }, + "2026": { + "years": 0.71, + "cagr_pct": 51.39, + "period_return_pct": 34.03, + "vol_pct": 26.06, + "sharpe": 1.63, + "mdd_pct": -15.28, + "calmar": 3.36, + "worst_year_pct": 34.03, + "losing_years": 0, + "total_years": 1, + "negative_months_pct": 44.4, + "avg_exposure_pct": 38.4, + "turnover_per_year_pct": 317.4, + "final_value": 13463321, + "contributed": 7700000, + "profit": 5763321, + "yearly": { + "2026": 34.03 + } + } + } + }, + "after": { + "all": { + "years": 6.19, + "cagr_pct": 13.31, + "period_return_pct": 116.77, + "vol_pct": 11.34, + "sharpe": 0.92, + "mdd_pct": -15.82, + "calmar": 0.84, + "worst_year_pct": -4.17, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 40.0, + "avg_exposure_pct": 37.2, + "turnover_per_year_pct": 189.5, + "final_value": 13413156, + "contributed": 7700000, + "profit": 5713156, + "yearly": { + "2020": 16.76, + "2021": 2.05, + "2022": -4.17, + "2023": 7.41, + "2024": -3.53, + "2025": 36.82, + "2026": 33.91 + }, + "planned_orders": 109, + "filled_orders": 106, + "rejected_orders": 3, + "trade_value": 56454932.0, + "cost": 76443.0, + "fees_and_tax": 48216.0, + "slippage_cost": 28227.0 + }, + "periods": { + "2020_2022": { + "years": 2.48, + "cagr_pct": 5.51, + "period_return_pct": 14.19, + "vol_pct": 6.62, + "sharpe": 0.4, + "mdd_pct": -9.34, + "calmar": 0.59, + "worst_year_pct": -4.17, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 36.7, + "avg_exposure_pct": 34.3, + "turnover_per_year_pct": 80.6, + "final_value": 3216716, + "contributed": 3200000, + "profit": 16716, + "yearly": { + "2020": 16.76, + "2021": 2.05, + "2022": -4.17 + } + }, + "2023_2025": { + "years": 2.99, + "cagr_pct": 12.37, + "period_return_pct": 41.76, + "vol_pct": 8.08, + "sharpe": 1.16, + "mdd_pct": -8.7, + "calmar": 1.42, + "worst_year_pct": -3.53, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 41.7, + "avg_exposure_pct": 39.3, + "turnover_per_year_pct": 156.4, + "final_value": 9291294, + "contributed": 6800000, + "profit": 2491294, + "yearly": { + "2023": 7.41, + "2024": -3.53, + "2025": 36.82 + } + }, + "2026": { + "years": 0.71, + "cagr_pct": 51.19, + "period_return_pct": 33.91, + "vol_pct": 26.25, + "sharpe": 1.62, + "mdd_pct": -15.82, + "calmar": 3.24, + "worst_year_pct": 33.91, + "losing_years": 0, + "total_years": 1, + "negative_months_pct": 44.4, + "avg_exposure_pct": 38.8, + "turnover_per_year_pct": 310.0, + "final_value": 13413156, + "contributed": 7700000, + "profit": 5713156, + "yearly": { + "2026": 33.91 + } + } + } + } + }, + "cost_3x": { + "before": { + "all": { + "years": 6.19, + "cagr_pct": 12.83, + "period_return_pct": 111.16, + "vol_pct": 11.21, + "sharpe": 0.89, + "mdd_pct": -15.44, + "calmar": 0.83, + "worst_year_pct": -4.71, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 41.3, + "avg_exposure_pct": 36.7, + "turnover_per_year_pct": 182.5, + "final_value": 13286050, + "contributed": 7700000, + "profit": 5586050, + "yearly": { + "2020": 15.34, + "2021": 1.58, + "2022": -4.71, + "2023": 8.24, + "2024": -4.27, + "2025": 36.43, + "2026": 33.77 + }, + "planned_orders": 299, + "filled_orders": 110, + "rejected_orders": 189, + "trade_value": 53952187.0, + "cost": 143827.0, + "fees_and_tax": 62913.0, + "slippage_cost": 80914.0 + }, + "periods": { + "2020_2022": { + "years": 2.48, + "cagr_pct": 4.55, + "period_return_pct": 11.65, + "vol_pct": 6.66, + "sharpe": 0.26, + "mdd_pct": -10.18, + "calmar": 0.45, + "worst_year_pct": -4.71, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 36.7, + "avg_exposure_pct": 34.9, + "turnover_per_year_pct": 83.5, + "final_value": 3186065, + "contributed": 3200000, + "profit": -13935, + "yearly": { + "2020": 15.34, + "2021": 1.58, + "2022": -4.71 + } + }, + "2023_2025": { + "years": 2.99, + "cagr_pct": 12.27, + "period_return_pct": 41.38, + "vol_pct": 7.9, + "sharpe": 1.17, + "mdd_pct": -8.98, + "calmar": 1.37, + "worst_year_pct": -4.27, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 44.4, + "avg_exposure_pct": 37.8, + "turnover_per_year_pct": 140.1, + "final_value": 9206293, + "contributed": 6800000, + "profit": 2406293, + "yearly": { + "2023": 8.24, + "2024": -4.27, + "2025": 36.43 + } + }, + "2026": { + "years": 0.71, + "cagr_pct": 50.96, + "period_return_pct": 33.77, + "vol_pct": 25.96, + "sharpe": 1.63, + "mdd_pct": -15.44, + "calmar": 3.3, + "worst_year_pct": 33.77, + "losing_years": 0, + "total_years": 1, + "negative_months_pct": 44.4, + "avg_exposure_pct": 38.4, + "turnover_per_year_pct": 315.7, + "final_value": 13286050, + "contributed": 7700000, + "profit": 5586050, + "yearly": { + "2026": 33.77 + } + } + } + }, + "after": { + "all": { + "years": 6.19, + "cagr_pct": 12.92, + "period_return_pct": 112.25, + "vol_pct": 11.3, + "sharpe": 0.9, + "mdd_pct": -15.7, + "calmar": 0.82, + "worst_year_pct": -4.15, + "losing_years": 2, + "total_years": 7, + "negative_months_pct": 40.0, + "avg_exposure_pct": 37.2, + "turnover_per_year_pct": 189.1, + "final_value": 13216792, + "contributed": 7700000, + "profit": 5516792, + "yearly": { + "2020": 16.53, + "2021": 1.94, + "2022": -4.15, + "2023": 6.94, + "2024": -3.87, + "2025": 36.36, + "2026": 32.98 + }, + "planned_orders": 112, + "filled_orders": 110, + "rejected_orders": 2, + "trade_value": 55930809.0, + "cost": 146855.0, + "fees_and_tax": 62973.0, + "slippage_cost": 83882.0 + }, + "periods": { + "2020_2022": { + "years": 2.48, + "cagr_pct": 5.39, + "period_return_pct": 13.86, + "vol_pct": 6.59, + "sharpe": 0.39, + "mdd_pct": -9.39, + "calmar": 0.57, + "worst_year_pct": -4.15, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 36.7, + "avg_exposure_pct": 34.3, + "turnover_per_year_pct": 80.7, + "final_value": 3214538, + "contributed": 3200000, + "profit": 14538, + "yearly": { + "2020": 16.53, + "2021": 1.94, + "2022": -4.15 + } + }, + "2023_2025": { + "years": 2.99, + "cagr_pct": 11.95, + "period_return_pct": 40.17, + "vol_pct": 8.08, + "sharpe": 1.11, + "mdd_pct": -8.92, + "calmar": 1.34, + "worst_year_pct": -3.87, + "losing_years": 1, + "total_years": 3, + "negative_months_pct": 41.7, + "avg_exposure_pct": 39.3, + "turnover_per_year_pct": 154.4, + "final_value": 9211752, + "contributed": 6800000, + "profit": 2411752, + "yearly": { + "2023": 6.94, + "2024": -3.87, + "2025": 36.36 + } + }, + "2026": { + "years": 0.71, + "cagr_pct": 49.71, + "period_return_pct": 32.98, + "vol_pct": 26.12, + "sharpe": 1.59, + "mdd_pct": -15.7, + "calmar": 3.17, + "worst_year_pct": 32.98, + "losing_years": 0, + "total_years": 1, + "negative_months_pct": 44.4, + "avg_exposure_pct": 38.7, + "turnover_per_year_pct": 313.2, + "final_value": 13216792, + "contributed": 7700000, + "profit": 5516792, + "yearly": { + "2026": 32.98 + } + } + } + } + } + }, + "limitations": [ + "같은 과거 자료를 다시 사용한 사후 비교이며 향후 수익률 검증이 아님", + "전일 종가로 주문 수량 결정, 다음 거래일 종가에 수수료·슬리피지를 반영한 체결 근사", + "분배금·실제 호가·유동성·부분체결 미반영, 실제 주문 실행부를 호출하지 않음", + "현금 이자 0%, CD ETF 양의 매매차익에 15.4% 과세 상한 근사", + "매수 시 시장가 기준 현금·비중 한도를 확인하는 별도 모형, 운영 엔진 전체의 재현이 아님", + "3배 비용은 수수료·슬리피지만 늘리고 세율과 매매 설정은 유지", + "주문 계획 차이를 보기 위해 수정 전후 모두 ETF 호가를 고친 현재 비용 계산 사용" + ], + "start": "2020-07-08", + "end": "2026-09-17" +} \ No newline at end of file diff --git a/tests/test_basket_rebalancer.py b/tests/test_basket_rebalancer.py index 2f31ff1c..5b380503 100644 --- a/tests/test_basket_rebalancer.py +++ b/tests/test_basket_rebalancer.py @@ -742,6 +742,7 @@ def test_warns_when_single_share_exceeds_target_amount(self, caplog): "min_trade_amount": 200000, "max_turnover_ratio": 1.0}, } rb.basket = rb.basket_cfg # 리스크 정책 조회원(재진입 차단 등) + rb._risk_params = {"diversification": {"min_cash_ratio": 0.0}} rb.rebalance_cfg = rb.basket_cfg["rebalance"] rb.account_key = "t" rb.execution_strategy = "t" diff --git a/tests/test_deployment_ratchet.py b/tests/test_deployment_ratchet.py index d6019ee1..b0f7dde6 100644 --- a/tests/test_deployment_ratchet.py +++ b/tests/test_deployment_ratchet.py @@ -148,6 +148,156 @@ def test_deployment_gap_sign(wired): assert rb._deployment_gap({"A": 100_000}) == pytest.approx(-0.10, abs=1e-9) +def test_topup_counts_buys_already_in_the_plan(wired): + """일반 매수와 보충 매수가 같은 부족분을 두 번 채우면 안 된다.""" + rb = _rebalancer( + holdings={"A": 0.5, "B": 0.25, "C": 0.25}, + target_stock_weight=0.60, min_trade=100_000, + positions=[_pos("B", 100_000, 1), _pos("C", 100_000, 1)], + drift_threshold=0.10, + ) + wired(rb, 1_000_000) + orders = rb.plan_rebalance({"A": 100_000, "B": 100_000, "C": 100_000}) + + bought = sum(o.quantity * o.price for o in orders if o.action == "BUY") + assert bought == 400_000 # A 30만원 + 보충 10만원. 50만원이면 중복 매수다. + assert 200_000 + bought == 600_000 + + +def test_regular_buys_restore_band_without_extra_topups(wired): + """일반 주문으로 허용 범위에 돌아왔다면 작은 보충 주문은 붙이지 않는다.""" + rb = _rebalancer( + holdings={"A": 0.5, "B": 0.25, "C": 0.25}, + target_stock_weight=0.60, min_trade=100_000, + positions=[_pos("B", 50_000, 29), _pos("C", 50_000, 29)], + ) + wired(rb, 10_000_000) + orders = rb.plan_rebalance({"A": 100_000, "B": 50_000, "C": 50_000}) + + assert [(o.symbol, o.action, o.quantity) for o in orders] == [("A", "BUY", 30)] + + +def test_topups_stop_once_deployment_returns_to_band(wired): + holdings = {f"A{i}": 1 / 9 for i in range(9)} + positions = [_pos(s, 100_000, 6) for s in holdings] + rb = _rebalancer( + holdings=holdings, target_stock_weight=0.60, + min_trade=200_000, positions=positions, + ) + wired(rb, 10_000_000) + orders = rb.plan_rebalance({s: 100_000 for s in holdings}) + + bought = sum(o.quantity * o.price for o in orders) + assert bought == 400_000 # 54% → 58%. 57~63% 안에 들어왔으므로 여기서 멈춘다. + + +def test_topup_accounts_for_planned_sell_proceeds(wired): + """과다 보유 종목을 줄인 뒤 생기는 부족분도 같은 계획에서 계산한다.""" + holdings = {f"A{i}": 0.1 for i in range(10)} + positions = [_pos("A0", 100_000, 15)] + [ + _pos(f"A{i}", 100_000, 5) for i in range(1, 10) + ] + rb = _rebalancer( + holdings=holdings, target_stock_weight=0.60, + min_trade=200_000, positions=positions, + ) + wired(rb, 10_000_000) + orders = rb.plan_rebalance({s: 100_000 for s in holdings}) + + assert orders[0].action == "SELL" + projected = 6_000_000 + sum( + (1 if o.action == "BUY" else -1) * o.quantity * o.price for o in orders + ) + assert 5_700_000 <= projected <= 6_300_000 + + +@pytest.mark.parametrize("held", [6, 9], ids=["buy", "sell"]) +def test_minimum_trade_uses_rounded_share_quantity(wired, held): + """차액 6만원이어도 1주 4만원 주문이면 최소 거래금액 5만원 미만이다.""" + rb = _rebalancer( + holdings={"A": 0.5, "B": 0.5}, target_stock_weight=0.60, + min_trade=50_000, deployment_band=0.10, + positions=[_pos("A", 40_000, held), _pos("B", 50_000, 6)], + ) + wired(rb, 1_000_000) + + assert rb.plan_rebalance({"A": 40_000, "B": 50_000}) == [] + + +@pytest.mark.parametrize("turnover", [0.25, 0.35, 0.45]) +def test_regular_and_topup_orders_share_one_turnover_budget(wired, turnover): + rb = _rebalancer( + holdings={"A": 0.5, "B": 0.25, "C": 0.25}, + target_stock_weight=0.60, min_trade=100_000, turnover=turnover, + positions=[_pos("B", 100_000, 1), _pos("C", 100_000, 1)], + drift_threshold=0.10, + ) + wired(rb, 1_000_000) + orders = rb.plan_rebalance({"A": 100_000, "B": 100_000, "C": 100_000}) + + assert orders + assert all(o.quantity * o.price >= 100_000 for o in orders) + assert sum(o.quantity * o.price for o in orders) <= 1_000_000 * turnover + assert len({o.symbol for o in orders}) == len(orders) + + +def test_plan_leaves_cash_reserve_after_buy_costs(wired): + """현금 5%를 딱 남기는 주문에 비용을 더해 두 번째 매수가 전부 거절되는 경우.""" + from core.risk_manager import RiskManager + + rb = _rebalancer( + holdings={"A": 0.5, "B": 0.5}, target_stock_weight=0.95, + min_trade=50_000, positions=[], + ) + rb.basket["min_cash_ratio"] = 0.05 + wired(rb, 1_000_000) + orders = rb.plan_rebalance({"A": 25_000, "B": 25_000}) + + assert [o.quantity for o in orders] == [19, 18] + rm = RiskManager(SimpleNamespace(risk_params=rb._risk_params)) + spent = 0 + for o in orders: + costs = rm.calculate_transaction_costs(o.price, o.quantity, "BUY", symbol=o.symbol) + spent += costs["execution_price"] * o.quantity + costs["commission"] + assert 1_000_000 - spent >= 50_000 + + +def test_cash_reduction_keeps_minimum_trade(wired): + rb = _rebalancer( + holdings={"A": 1.0}, target_stock_weight=0.60, + min_trade=50_000, positions=[], + ) + wired(rb, 100_000) + rb.portfolio_mgr.get_portfolio_summary.return_value["cash"] = 49_000 + assert rb.plan_rebalance({"A": 30_000}) == [] + + +def test_cash_budget_uses_net_sell_proceeds(wired): + from core.risk_manager import RiskManager + + rb = _rebalancer( + holdings={"A": 0.5, "B": 0.5}, target_stock_weight=0.60, + min_trade=50_000, positions=[_pos("A", 25_000, 40)], + ) + rb.basket["min_cash_ratio"] = 0.40 + rb._risk_params["transaction_costs"] = { + "commission_rate": 0.01, "tax_rate": 0.02, "slippage": 0.01, "slippage_ticks": 0, + } + wired(rb, 1_000_000) + orders = rb.plan_rebalance({"A": 25_000, "B": 25_000}) + + assert [o.action for o in orders] == ["SELL", "BUY"] + assert orders[-1].quantity < 12 # 매도 대금 70만원 전액을 쓸 수 없다. + rm = RiskManager(SimpleNamespace(risk_params=rb._risk_params)) + cash = 0 + for o in orders: + costs = rm.calculate_transaction_costs(o.price, o.quantity, o.action, symbol=o.symbol) + amount = costs["execution_price"] * o.quantity + cash += amount if o.action == "SELL" else -amount + cash -= costs["commission"] + costs["tax"] + assert cash >= 400_000 + + # --------------------------------------------------- 결측 경보 중복 억제 class _FakeQuery: diff --git a/tests/test_rebalance_review.py b/tests/test_rebalance_review.py new file mode 100644 index 00000000..dbf3625b --- /dev/null +++ b/tests/test_rebalance_review.py @@ -0,0 +1,78 @@ +"""주문 계획 비교에서 미래 가격·적립금이 수익으로 섞이지 않는지 확인한다.""" + +from pathlib import Path + +import pandas as pd +import pytest +import yaml + +from core import basket_rebalancer +from tools.rebalance_review import replay + + +@pytest.fixture +def basket(): + path = Path(__file__).resolve().parents[1] / "config/baskets.yaml" + return yaml.safe_load(path.read_text(encoding="utf-8"))["baskets"]["kr_pocket"] + + +def prices(rows=201): + return pd.DataFrame( + {"069500": 30_000.0, "357870": 100_000.0, "KS200": 100.0}, + index=pd.bdate_range("2025-01-02", periods=rows), + ) + + +def test_quantity_uses_previous_close_and_unaffordable_gap_is_rejected(basket, monkeypatch): + plans = [] + original = basket_rebalancer.BasketRebalancer.plan_rebalance + + def record(self, current_prices): + orders = original(self, current_prices) + plans.append([(o.symbol, o.quantity, o.price) for o in orders]) + return orders + + monkeypatch.setattr(basket_rebalancer.BasketRebalancer, "plan_rebalance", record) + stable = prices() + jumped = prices() + jumped.iloc[-1, jumped.columns.get_loc("069500")] = 60_000.0 + + no_gap = replay(basket_rebalancer, stable, basket) + gap = replay(basket_rebalancer, jumped, basket) + + assert plans[0] == plans[1] == [("069500", 4, 30_000.0)] + assert no_gap.filled_orders.iloc[0] == 1 + assert gap.filled_orders.iloc[0] == 0 + assert gap.rejected_orders.iloc[0] == 1 + assert gap.cash.iloc[0] == basket["initial_capital"] + + +def test_deposits_are_not_profit_and_no_runtime_objects_are_created(basket, monkeypatch): + def forbidden(*a, **kw): + pytest.fail("연구 비교가 실제 계좌 또는 주문 실행부에 접근했습니다") + + monkeypatch.setattr(basket_rebalancer.BasketRebalancer, "__init__", forbidden) + monkeypatch.setattr(basket_rebalancer.BasketRebalancer, "execute", forbidden) + monkeypatch.setattr(basket_rebalancer, "PortfolioManager", forbidden) + monkeypatch.setattr(basket_rebalancer, "DataCollector", forbidden) + frame = replay(basket_rebalancer, prices(280), basket, cost_multiple=0) + + assert frame.flow.sum() > 0 + assert frame.total.iloc[-1] == pytest.approx(basket["initial_capital"] + frame.flow.sum()) + assert frame.twr.tolist() == pytest.approx([1.0] * len(frame)) + assert frame.drawdown.min() == pytest.approx(0) + + +def test_missing_price_inside_comparison_is_rejected(basket): + panel = prices(210) + panel.iloc[205, panel.columns.get_loc("069500")] = float("nan") + with pytest.raises(ValueError, match="가격과 날짜"): + replay(basket_rebalancer, panel, basket) + + +def test_index_warmup_can_precede_etf_listing(basket): + panel = prices(210) + panel.loc[panel.index[:203], ["069500", "357870"]] = float("nan") + frame = replay(basket_rebalancer, panel, basket) + assert frame.index[0] == panel.index[204] + assert frame.planned_orders.iloc[0] > 0 diff --git a/tests/test_risk_manager.py b/tests/test_risk_manager.py index 89ca02c4..8c2cd167 100644 --- a/tests/test_risk_manager.py +++ b/tests/test_risk_manager.py @@ -106,6 +106,60 @@ def test_tick_size(): assert _get_tick_size(0) == 1 +@pytest.mark.parametrize("price,tick", [(1999, 1), (2000, 5), (50000, 5), (1000000, 5)]) +def test_registered_krx_etf_uses_etf_tick_rule(price, tick): + rm = RiskManager(SimpleNamespace(risk_params={ + "instrument_classes": {"krx_etf_symbols": ["069500", "357870"]}, + "transaction_costs": {"slippage": 0, "slippage_ticks": 2}, + })) + result = rm.calculate_transaction_costs(price, 3, "BUY", symbol="069500") + assert result["slippage_per_share"] == 2 * tick + assert result["execution_price"] == price + 2 * tick + + +def test_etf_fixed_slippage_floor_and_volume_multiplier_still_apply(): + rm = RiskManager(SimpleNamespace(risk_params={ + "instrument_classes": {"krx_etf_symbols": ["069500"]}, + "transaction_costs": { + "slippage": 0.0005, "slippage_ticks": 1, + "dynamic_slippage": {"enabled": True}, + }, + })) + normal = rm.calculate_transaction_costs(100_000, 1, "BUY", symbol="069500") + large = rm.calculate_transaction_costs( + 100_000, 4, "SELL", symbol="069500", avg_daily_volume=100, + ) + assert normal["slippage_per_share"] == 50 + assert large["slippage_per_share"] == 200 + + +@pytest.mark.parametrize("classes", [ + {}, {"non_company_symbols": ["005930"]}, {"krx_etf_symbols": "005930"}, +]) +def test_non_etf_or_malformed_etf_list_keeps_stock_ticks(classes): + rm = RiskManager(SimpleNamespace(risk_params={ + "instrument_classes": classes, + "transaction_costs": {"slippage": 0, "slippage_ticks": 1, + "tax_exempt_symbols": ["005930"]}, + })) + assert rm.calculate_transaction_costs( + 100_000, 1, "BUY", symbol="005930", + )["slippage_per_share"] == 100 + + +def test_configured_pocket_etfs_have_correct_tick_costs(): + from pathlib import Path + import yaml + + path = Path(__file__).resolve().parents[1] / "config/risk_params.yaml" + risk_params = yaml.safe_load(path.read_text(encoding="utf-8")) + rm = RiskManager(SimpleNamespace(risk_params=risk_params)) + for symbol in ("069500", "357870"): + assert rm.calculate_transaction_costs( + 100_000, 1, "BUY", symbol=symbol, + )["slippage_per_share"] == 50 + + def test_diversification_blocks_when_remaining_cash_too_low(risk_manager): """주문 후 남는 현금 비중이 설정값보다 낮으면 차단 (단일 종목 비중은 20% 이하로 두어 해당 검사 통과)""" result = risk_manager.check_diversification( diff --git a/tools/rebalance_review.py b/tools/rebalance_review.py new file mode 100644 index 00000000..2b3ecf81 --- /dev/null +++ b/tools/rebalance_review.py @@ -0,0 +1,243 @@ +"""현재 주문 계획과 지정한 커밋의 계획을 같은 ETF 가격으로 비교한다. + +실제 계좌·DB·주문 실행부에 연결하지 않는다. 전일 종가로 수량을 정하고 다음 거래일 +종가에 비용을 더해 가상 체결한다. 모의 운용이나 실전 수익을 대신하는 검증은 아니다. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import json +import math +import subprocess +import sys +import types +from pathlib import Path +from unittest.mock import patch + +import pandas as pd +import yaml +from loguru import logger + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from core import basket_rebalancer as current +from core.risk_manager import RiskManager +from core.risk_overlays import compute_decision, parse_overlay_config +from tools import risk_overlay_backtest as research +from tools.risk_review import align_report_inputs, period_metrics + + +def replay(module, panel, basket, *, cost_multiple=1.0, risk_params=None): + """주문 계획만 실제 코드를 사용한다. 체결과 비용은 아래의 단순 모형이다.""" + symbols = list(basket["holdings"]) + cfg = parse_overlay_config(basket) + if set(symbols) != {"069500", "357870"} or cfg.volatility.enabled: + raise ValueError("이 비교는 변동성 목표를 끈 KODEX 200·CD ETF 설정만 지원합니다") + if basket["rebalance"].get("trigger") != "drift": + raise ValueError("비중 이탈 기준의 주문 계획만 비교합니다") + if not math.isfinite(cost_multiple) or cost_multiple < 0: + raise ValueError("비용 배수는 유한한 0 이상의 값이어야 합니다") + complete_etf = panel[symbols].dropna() + if panel.empty or complete_etf.empty: + raise ValueError("비교할 ETF 가격이 없습니다") + first_etf_bar = complete_etf.index[0] + checked_series = [panel.KS200, *(panel[s].loc[first_etf_bar:] for s in symbols)] + if (not panel.index.is_unique or not panel.index.is_monotonic_increasing + or not all(math.isfinite(v) and v > 0 for values in checked_series for v in values)): + raise ValueError("비교할 가격과 날짜를 확인하세요") + # ETF 상장 전의 지수 가격도 이동평균에 쓰되, 두 ETF의 전일 종가가 있어야 주문한다. + start = max(200, cfg.trend.ma_days, panel.index.get_loc(first_etf_bar) + 1) + if len(panel) <= start: + raise ValueError("이동평균 준비 기간 이후의 가격이 필요합니다") + + initial = float(basket["initial_capital"]) + monthly = float(basket["contribution_plan"]["amount"]) + quantity = dict.fromkeys(symbols, 0) + avg = dict.fromkeys(symbols, 0.0) + cash = contributed = previous_total = initial + twr = peak = 1.0 + previous_month = previous_state = None + model_risk = copy.deepcopy(risk_params if risk_params is not None else { + "transaction_costs": { + "commission_rate": research.COMMISSION, "slippage": research.SLIPPAGE, + "slippage_ticks": 0, "tax_exempt_symbols": symbols, + "holding_period_income_tax": {"enabled": True, "rate": 0.154, "symbols": ["357870"]}, + }, + }) + costs_cfg = model_risk.setdefault("transaction_costs", {}) + for name, default in (("commission_rate", 0.00015), ("slippage", 0.0005), ("slippage_ticks", 2)): + costs_cfg[name] = costs_cfg.get(name, default) * cost_multiple + costs_model = RiskManager(types.SimpleNamespace(risk_params=model_risk)) + + # 생성자를 부르지 않아 PortfolioManager와 DataCollector도 만들지 않는다. + rb = module.BasketRebalancer.__new__(module.BasketRebalancer) + rb.basket = copy.deepcopy(basket) + rb.basket_name = "kr_pocket" + rb.holdings = dict(basket["holdings"]) + rb.rebalance_cfg = dict(basket["rebalance"]) + rb.account_key = rb.execution_strategy = "research:kr_pocket" + rb.config = types.SimpleNamespace(trading={"mode": "paper"}) + rb._risk_params = copy.deepcopy(model_risk) + rb._risk_params.setdefault("diversification", {})["min_cash_ratio"] = basket["min_cash_ratio"] + rb._target_stock_weight = basket["target_stock_weight"] + + def positions(**_): + return [types.SimpleNamespace(symbol=s, quantity=q, avg_price=avg[s]) + for s, q in quantity.items() if q] + + def summary(current_prices): + return {"total_value": cash + sum(quantity[s] * current_prices[s] for s in symbols), + "cash": cash} + + rb.portfolio_mgr = types.SimpleNamespace(get_portfolio_summary=summary) + rows = [] + with patch.object(module, "get_all_positions", positions), patch.object( + module, "symbols_in_reentry_cooldown", lambda *a, **kw: {}, + ): + for i in range(start, len(panel)): + day = panel.index[i] + month = (day.year, day.month) + flow = monthly if previous_month is not None and month != previous_month else 0 + cash += flow + contributed += flow + previous_month = month + decision = compute_decision( + cfg, index_closes=panel.KS200.iloc[i - cfg.trend.ma_days:i].tolist(), + cumulative_returns_pct=[(peak - 1) * 100, (twr - 1) * 100], + prev_state=previous_state, now=day.to_pydatetime(), + ) + previous_state = decision.to_dict() + rb.overlay_decision = lambda: decision + # 주문 수량에는 체결일 가격을 주지 않는다. + signal_prices = {s: float(panel[s].iloc[i - 1]) for s in symbols} + need, _ = rb.should_rebalance(signal_prices) + orders = rb.plan_rebalance(signal_prices) if need else [] + prices = {s: float(panel[s].iloc[i]) for s in symbols} + turnover = fees = slip_cost = 0.0 + filled = rejected = 0 + for order in orders: + s, qty = order.symbol, order.quantity + buy = order.action == "BUY" + costs = costs_model.calculate_transaction_costs( + prices[s], qty, order.action, symbol=s, avg_price=avg[s] or None, + ) + fill = costs["execution_price"] + notional = fill * qty + fee = costs["commission"] + costs["tax"] + costs["capital_gains_tax"] + if buy: + total_now = cash + sum(quantity[k] * prices[k] for k in symbols) + limits = rb._policy_exposure_limits(s) + invested = total_now - cash + # 다음 날 가격이 바뀌어 감당할 수 없어진 주문은 전량 보류한다. + if (cash - notional - fee < total_now * limits["min_cash_ratio"] + or invested + notional > total_now * limits["max_investment_ratio"] + or quantity[s] * prices[s] + notional + > total_now * limits["max_position_ratio"]): + rejected += 1 + continue + avg[s] = (avg[s] * quantity[s] + notional) / (quantity[s] + qty) + quantity[s] += qty + cash -= notional + fee + else: + if qty > quantity[s]: + raise AssertionError("보유 수량보다 큰 매도 계획") + quantity[s] -= qty + cash += notional - fee + filled += 1 + turnover += notional + fees += fee + slip_cost += costs["slippage"] + total = cash + sum(quantity[s] * prices[s] for s in symbols) + daily_return = total / (previous_total + flow) - 1 + twr *= 1 + daily_return + peak = max(peak, twr) + previous_total = total + rows.append({ + "date": day, "total": total, "twr": twr, "drawdown": twr / peak - 1, + "daily_return": daily_return, "flow": flow, "contributed": contributed, + "cash": cash, "stock_w": quantity["069500"] * prices["069500"] / total, + "trade_value": turnover, "cost": fees + slip_cost, "fees_and_tax": fees, + "slippage_cost": slip_cost, "planned_orders": len(orders), + "filled_orders": filled, "rejected_orders": rejected, + **{f"qty_{s}": quantity[s] for s in symbols}, + }) + return pd.DataFrame(rows).set_index("date") + + +def summarize(frame): + result = research.metrics(frame, { + "turnover_value": frame.trade_value.sum(), "avg_exposure": frame.stock_w.mean(), + }) + result.update({key: int(frame[key].sum()) for key in + ("planned_orders", "filled_orders", "rejected_orders")}) + result.update({key: round(float(frame[key].sum()), 2) for key in + ("trade_value", "cost", "fees_and_tax", "slippage_cost")}) + return result + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--baseline", required=True, help="비교할 로컬 Git 커밋") + parser.add_argument("--as-of", default="2026-09-22") + parser.add_argument("--output") + args = parser.parse_args() + baseline_sha = subprocess.check_output( + ["git", "rev-parse", "--verify", f"{args.baseline}^{{commit}}"], cwd=ROOT, + ).decode().strip() + before = types.ModuleType("rebalance_before") + before.__file__ = str(ROOT / "core/basket_rebalancer.py") + source = subprocess.check_output( + ["git", "show", f"{baseline_sha}:core/basket_rebalancer.py"], cwd=ROOT, + ).decode("utf-8") + exec(compile(source, before.__file__, "exec"), before.__dict__) # noqa: S102 + logger.disable(current.__name__) + logger.disable(before.__name__) + logger.disable("core.risk_manager") + basket = yaml.safe_load((ROOT / "config/baskets.yaml").read_text(encoding="utf-8"))["baskets"]["kr_pocket"] + risk_params = yaml.safe_load((ROOT / "config/risk_params.yaml").read_text(encoding="utf-8")) + research.AS_OF = args.as_of + series = {s: research._fdr(s, "2014-01-01") for s in ("069500", "357870", "KS200")} + _, panel, audit = align_report_inputs(series, args.as_of) + payload = { + "as_of": args.as_of, "baseline_commit": baseline_sha, + "planner_sha256": hashlib.sha256((ROOT / "core/basket_rebalancer.py").read_bytes()).hexdigest(), + "cost_model_sha256": hashlib.sha256((ROOT / "core/risk_manager.py").read_bytes()).hexdigest(), + "data_audit": audit, "input_sha256": hashlib.sha256(panel.to_csv().encode()).hexdigest(), + "basket": basket, "cost_config": risk_params["transaction_costs"], + "instrument_classes": risk_params["instrument_classes"], "comparisons": {}, + "limitations": [ + "같은 과거 자료를 다시 사용한 사후 비교이며 향후 수익률 검증이 아님", + "전일 종가로 주문 수량 결정, 다음 거래일 종가에 수수료·슬리피지를 반영한 체결 근사", + "분배금·실제 호가·유동성·부분체결 미반영, 실제 주문 실행부를 호출하지 않음", + "현금 이자 0%, CD ETF 양의 매매차익에 15.4% 과세 상한 근사", + "매수 시 시장가 기준 현금·비중 한도를 확인하는 별도 모형, 운영 엔진 전체의 재현이 아님", + "3배 비용은 수수료·슬리피지만 늘리고 세율과 매매 설정은 유지", + "주문 계획 차이를 보기 위해 수정 전후 모두 ETF 호가를 고친 현재 비용 계산 사용", + ], + } + for multiple in (1, 3): + comparison = {} + for label, module in (("before", before), ("after", current)): + frame = replay(module, panel, basket, cost_multiple=multiple, risk_params=risk_params) + comparison[label] = {"all": summarize(frame), "periods": { + period: period_metrics(frame, a, b) for period, a, b in ( + ("2020_2022", "2020-01-01", "2022-12-31"), + ("2023_2025", "2023-01-01", "2025-12-31"), + ("2026", "2026-01-01", args.as_of), + ) + }} + payload["start"] = str(frame.index[0].date()) + payload["end"] = str(frame.index[-1].date()) + payload["comparisons"][f"cost_{multiple}x"] = comparison + print(json.dumps({f"cost_{multiple}x": comparison}, ensure_ascii=False), flush=True) + path = Path(args.output or f"reports/research/rebalance_review_{pd.Timestamp(args.as_of):%Y%m%d}.json") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False), encoding="utf-8") + + +if __name__ == "__main__": + main()