diff --git a/README.md b/README.md index 004e7b6..0a3cb29 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ 일일 운영 뒤 공시·종목별 수급을 자동 수집하고, 새 주문의 계획가격 대비 체결비용과 등록 이후 모의운용 성과를 누적합니다. 화면에서 수집 실패·측정 근거·상승/하락일 성과·결측을 확인할 수 있습니다. [자동 수집·체결비용·장기 검증 안내](docs/RESEARCH_OPERATIONS_20261013.md) +2026년 10월 급락 뒤 투자 방식과 시장 상황을 다시 조사했습니다. 그 결과를 바탕으로 대형주 계좌의 손절·낙폭 한도와 ETF 적립 계좌의 1주 단위 주문 계획을 고쳤고, 넣은 돈 기준 수익률도 함께 보여 줍니다. [투자 방식 조사와 운용 개선](docs/MARKET_RESEARCH_20261020.md) + ## 투자 내역 저장 ![날짜별 평가금액·투자원금·수익률과 고점 대비 하락률](docs/images/history-table-20260922.png) diff --git a/api/kis_api.py b/api/kis_api.py index 00116b4..d4d971f 100644 --- a/api/kis_api.py +++ b/api/kis_api.py @@ -1009,6 +1009,7 @@ def buy_order( quantity: int, price: int = 0, order_type: str = "00", + exchange_id: str = "KRX", ) -> Optional[Dict[str, Any]]: """ 매수 주문 @@ -1018,6 +1019,7 @@ def buy_order( quantity: 주문 수량 price: 주문 가격 (0: 시장가) order_type: "00"(지정가), "01"(시장가) + exchange_id: 거래소ID구분코드 KRX | NXT | SOR (모의투자는 KRX만 지원) Returns: 주문 결과 딕셔너리 @@ -1030,8 +1032,10 @@ def buy_order( raise PermissionError( "실계좌 주문은 OrderExecutor 안전 게이트를 통해서만 제출할 수 있습니다." ) - # 모의투자 vs 실전 거래 ID - tr_id = "VTTC0802U" if self.use_mock else "TTTC0802U" + # 모의투자 vs 실전 거래 ID — 대체거래소(NXT) 도입 뒤의 현행 주문 규격(TTTC0012U, + # 거래소ID구분코드 필수). 예전 TTTC0802U는 KRX 전용으로 동작했고, 취소(TTTC0013U)는 + # 이미 새 규격이라 같은 주문의 접수·취소가 서로 다른 규격을 쓰고 있었다. + tr_id = "VTTC0012U" if self.use_mock else "TTTC0012U" body = { "CANO": self.cano, @@ -1040,6 +1044,7 @@ def buy_order( "ORD_DVSN": order_type, "ORD_QTY": str(quantity), "ORD_UNPR": str(price), + "EXCG_ID_DVSN_CD": self._order_exchange(exchange_id), } data = self._request( @@ -1064,6 +1069,7 @@ def sell_order( quantity: int, price: int = 0, order_type: str = "00", + exchange_id: str = "KRX", ) -> Optional[Dict[str, Any]]: """ 매도 주문 @@ -1073,6 +1079,7 @@ def sell_order( quantity: 주문 수량 price: 주문 가격 (0: 시장가) order_type: "00"(지정가), "01"(시장가) + exchange_id: 거래소ID구분코드 KRX | NXT | SOR (모의투자는 KRX만 지원) Returns: 주문 결과 딕셔너리 @@ -1085,7 +1092,7 @@ def sell_order( raise PermissionError( "실계좌 주문은 OrderExecutor 안전 게이트를 통해서만 제출할 수 있습니다." ) - tr_id = "VTTC0801U" if self.use_mock else "TTTC0801U" + tr_id = "VTTC0011U" if self.use_mock else "TTTC0011U" body = { "CANO": self.cano, @@ -1094,6 +1101,7 @@ def sell_order( "ORD_DVSN": order_type, "ORD_QTY": str(quantity), "ORD_UNPR": str(price), + "EXCG_ID_DVSN_CD": self._order_exchange(exchange_id), } data = self._request( @@ -1194,6 +1202,15 @@ def cancel_order( logger.error("미체결 주문 취소 실패: {} - {}", normalized_order_no, msg) return None + def _order_exchange(self, exchange_id: str) -> str: + """주문 거래소 코드. 모의투자는 KRX 주문만 받으므로 다른 값은 미리 막는다.""" + code = str(exchange_id or "KRX").strip().upper() + if code not in {"KRX", "NXT", "SOR"}: + raise ValueError("거래소ID구분코드는 KRX, NXT, SOR 중 하나여야 합니다.") + if self.use_mock and code != "KRX": + raise ValueError("모의투자 주문은 KRX만 지원합니다.") + return code + @staticmethod def _odno_from_order_output(order_output: Optional[Dict[str, Any]]) -> str: if not order_output or not isinstance(order_output, dict): diff --git a/config/baskets.yaml b/config/baskets.yaml index 04be053..5cdcb4b 100644 --- a/config/baskets.yaml +++ b/config/baskets.yaml @@ -51,12 +51,24 @@ baskets: # 단일 종목 대형 손실만 잘라내도록 넓게 잡는다. 실측 참고: 이 구간 최악은 # 현대차 -34.8%였고, 정상 조정권은 -10~-17%대에 몰려 있었다. stop_loss_pct: 0.25 + # 2026-10-20: 손절을 같은 기간 KODEX 200 대비로 잰다. 평균 매입가 대비 -25%는 + # 시장 전체가 빠질 때 연달아 걸렸다(9/21 현대모비스, 10/15 삼성전자·현대차 — 그 + # 사이 KODEX 200도 -30% 가까이 하락). 두 구간(2014-07~, 2021-12~) 비교에서 절대 + # 손절로 판 종목은 그 뒤 60일 동안 시장보다 +1~+2%p 더 올랐고(바닥에서 판 셈), 시장 + # 대비 손절로 판 종목은 -1~-3%p 더 빠졌다. 수익률은 손절 없음과 비슷해서, 수익을 + # 올리는 장치가 아니라 한 종목 사고만 막는 장치로 둔다. docs/HOLD_RISK_REVIEW_20261020.md + stop_basis: relative + stop_benchmark: "069500" take_profit_pct: 0 # 익절 없음 — 장기 보유가 이 트랙의 전제 trailing_stop_pct: 0 # 트레일링 없음 — 저회전 유지 # 손절한 종목의 재매수를 막는 기간. 0이면 손절이 무의미해진다 — 비워진 슬롯을 # 같은 사이클의 비중 교정이 곧바로 되사기 때문이다(2026-08-07 10:07에 실제로 # 현대차를 손절 4초 뒤 되사는 왕복매매가 발생). 분기 리밸런싱 성격에 맞춰 60일. reentry_cooldown_days: 60 + # 계좌 낙폭이 15%를 넘어도 선언한 주식 비중(60%)까지는 되산다. 전역 낙폭 가드에 + # 맡겨 두면 손절·비중 초과 매도로 생긴 현금이 돌아가지 못해 비중이 한쪽으로만 + # 줄어든다(10/15 이후 45%). 주문 단계의 노출 상한(60% + 밴드 3%p)은 그대로 검사한다. + account_drawdown_guard: delegate monitoring: # 2026-08-26: 1.0(감시 사실상 해제)에서 복원. 1.0으로 둔 이유는 '하이닉스 슬롯을 # 못 채워 생기는 -21%p 미달은 조치 불가'였는데, 그 슬롯을 제거하고 목표 배치율을 @@ -222,6 +234,10 @@ baskets: min_trade_amount: 50000 max_turnover_ratio: 0.6 deployment_band: 0.03 + # 종목별 내림 대신 1주 단위 수량 조합을 함께 고른다(core/integer_allocation.py). + # 10/12 주식 ETF 26.7%·CD 61.2%(목표 47.5%·47.5%)에서 종목별 부족·초과가 모두 1주 + # 미만이라 주문이 0건이었다. CD 2주를 팔아 주식 ETF 1주를 사면 주식 53.5%가 된다. + planner: integer holdings: "069500": 0.5 # KODEX 200 — 위험자산 슬리브(총자산의 47.5%) "357870": 0.5 # TIGER CD금리투자KIS(합성) — 파킹 슬리브(총자산의 47.5%) diff --git a/core/basket_evaluation.py b/core/basket_evaluation.py index 3eabede..cb31d41 100644 --- a/core/basket_evaluation.py +++ b/core/basket_evaluation.py @@ -28,6 +28,7 @@ from __future__ import annotations +import math from typing import Any from loguru import logger @@ -329,6 +330,34 @@ def _d(v): return max(weighted, 1.0) +def money_weighted_metrics( + starting_capital: float, + flows: list, + operation_start: Any, + end_day: Any, + end_value: float | None, +) -> dict[str, float | None]: + """넣은 돈 기준 수익률(Modified Dietz) — 같은 기간의 시간가중 수익률과 나란히 본다. + + (기말 평가액 − 원금 − 입금 합) ÷ (원금 + 각 입금 × 남은 기간 비율). 입금이 없으면 + 단순 수익률과 같다. 연환산하지 않는다(몇 달짜리 기록을 연율로 키우면 과장된다). + """ + try: + start = float(starting_capital) + end = None if end_value is None else float(end_value) + except (TypeError, ValueError): + return {"money_weighted_return_pct": None, "contributed_capital": None} + deposits = sum(float(amount) for _, amount in (flows or [])) + contributed = start + deposits + if end is None or not math.isfinite(end) or contributed <= 0: + return {"money_weighted_return_pct": None, "contributed_capital": contributed} + denominator = time_weighted_capital(start, flows, operation_start, end_day) + return { + "money_weighted_return_pct": (end - contributed) / denominator * 100, + "contributed_capital": contributed, + } + + def decompose_return_gap( nav_return_pct: float | None, design_return_pct: float | None, @@ -526,10 +555,12 @@ def _config_date(value): # 흐름 0건이면 아래 두 값은 기존과 완전히 동일하다(하위 호환). from database.repositories import get_cash_flows, has_cash_flows account_has_flows = has_cash_flows(account_key=basket_key, mode="paper") + starting_capital = initial_capital + account_flows = get_cash_flows(account_key=basket_key, mode="paper") if account_has_flows else [] if account_has_flows: initial_capital = time_weighted_capital( initial_capital, - get_cash_flows(account_key=basket_key, mode="paper"), + account_flows, operation_start, today, ) @@ -586,6 +617,13 @@ def _config_date(value): promotion = basket_cfg.get("promotion") or {} result.setdefault("metrics", {})["close_comparison"] = comparison + # 시간가중(TWR)은 운용 규칙의 성과, 금액가중은 '넣은 돈 기준' 성과다. 적립 계좌는 돈이 + # 들어온 시점에 따라 둘이 갈린다(자본시장연구원 26-04: 개인 계좌는 금액가중 기준 수익 계좌 + # 비율이 시간가중보다 낮았다). 둘을 함께 보여 주되 판정에는 쓰지 않는다. + result["metrics"].update(money_weighted_metrics( + starting_capital, account_flows, operation_start, nav_end, + float(snaps[-1].total_value) if snaps else None, + )) result["metrics"]["comparison_basis_note"] = ( "별도 종가 평가 기준" if comparison else "동일 시점 종가 평가 기록 부족 — 벤치마크·귀속 비교 보류" ) diff --git a/core/basket_observability.py b/core/basket_observability.py index 77760d5..bd1f71b 100644 --- a/core/basket_observability.py +++ b/core/basket_observability.py @@ -18,7 +18,9 @@ def snapshot_entry_status(snapshot, basket, risk_params): return result result["measured_at"] = str(snapshot.get("created_at") or snapshot.get("date") or "") # An explicit basket drawdown rule owns both MDD and daily-loss policy. - delegated = bool(((basket.get("overlays") or {}).get("drawdown_guard") or {}).get("enabled")) + from core.basket_risk import delegates_account_drawdown_guard + + delegated = delegates_account_drawdown_guard(basket or {}) limits = risk_params.get("drawdown") or {} if not delegated: for field, limit_field, label in ( @@ -62,8 +64,23 @@ def allocation_diagnostics(total, cash, positions, targets, defensive_symbol=Non assets.append({"asset": key, "label": label, "actual": actual, "target": target, "gap": actual - target}) target_cash = max(0.0, 1 - sum(targets.values())) assets.append({"asset": "cash", "label": "현금", "actual": cash / total, "target": target_cash, "gap": cash / total - target_cash}) + # CD 방어 자산과 현금은 위험이 거의 같다. 둘을 합친 안전 자산 비중이 목표 근처이고 + # 남는 현금으로 방어 자산 1주를 더 살 수도 없다면, 둘 사이의 차이는 1주 단위 때문에 + # 생긴 구조적 차이다 — 경보로 세지 않고 표시만 한다(조치할 수 없는 경보는 신호를 죽인다). + structural = set() + if defensive_symbol: + safe = [a for a in assets if a["asset"] in ("defensive", "cash")] + safe_gap = sum(a["gap"] for a in safe) + unit = next((float(p["price"]) for p in positions if p["symbol"] == defensive_symbol), None) + idle_cash = cash - target_cash * total + can_buy_one = unit is not None and unit > 0 and idle_cash >= unit + if abs(safe_gap) <= tolerance and not can_buy_one: + structural = {"defensive", "cash"} + for a in assets: + if a["asset"] in structural and abs(a["gap"]) > tolerance: + a["structural"] = True issues = [f"{a['label']} 비중 {a['actual']:.1%}, 목표 {a['target']:.1%} ({a['gap'] * 100:+.1f}%p)" - for a in assets if abs(a["gap"]) > tolerance] + for a in assets if abs(a["gap"]) > tolerance and a["asset"] not in structural] return {"status": "ATTENTION" if issues else "OK", "assets": assets, "issues": issues} diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index 6ea2286..e7aa03d 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -189,13 +189,15 @@ def _stock_fraction(self) -> float: ).values()) def _has_drawdown_rule(self) -> bool: - """이 바스켓이 자기 낙폭 규칙(overlays.drawdown_guard)을 켜 두었는지.""" - cfg = getattr(self, "_overlay_cfg", None) - if cfg is None: - from core.risk_overlays import parse_overlay_config + """계좌 낙폭·일일 손실 매수 차단을 이 바스켓의 규칙에 맡기는지. - cfg = parse_overlay_config(getattr(self, "basket", None) or {}) - return bool(cfg.drawdown.enabled) + 자기 낙폭 규칙(overlays.drawdown_guard)을 켰거나, risk.account_drawdown_guard: + delegate로 '선언 비중까지만 되산다'고 정한 바스켓이다. 판단 규칙은 + core.basket_risk.delegates_account_drawdown_guard 한 곳에 둔다(평가·화면도 같은 함수). + """ + from core.basket_risk import delegates_account_drawdown_guard + + return delegates_account_drawdown_guard(getattr(self, "basket", None) or {}) def _overlay_scale(self) -> float: decision = self.overlay_decision() @@ -809,7 +811,8 @@ def plan_risk_exits(self, prices: dict[str, float] = None) -> list[RebalanceOrde account_key=self.account_key, mode=self._ledger_mode(), ) - hits = evaluate_basket_stops(self.basket, positions, prices) + relative_context = self._relative_stop_context(positions) + hits = evaluate_basket_stops(self.basket, positions, prices, relative_context) orders: list[RebalanceOrder] = [] for hit in hits: logger.warning( @@ -825,6 +828,96 @@ def plan_risk_exits(self, prices: dict[str, float] = None) -> list[RebalanceOrde )) return orders + def _relative_stop_context(self, positions) -> dict | None: + """상대 손절에 필요한 비교 종목 가격(지금·매입 시점). 상대 손절이 아니면 None. + + 매입 시점 수준은 현재 보유 구간의 매수 기록마다 그날 비교 종목 종가를 수량 가중 + 평균해 만든다(평균 매입가와 같은 규칙). 지금 가격은 보유 종목 시세와 같은 날짜여야 + 한다 — 날짜가 다르면 하루 치 시장 움직임이 상대 수익률에 섞인다. 맞추지 못하면 + price를 비워 두고(그날 상대 손절 판단 보류) 사유를 남긴다. + """ + from core.basket_risk import basket_risk_config, relative_stop_basis, stop_basis + + kind, benchmark = stop_basis(self.basket) + if kind != "relative" or basket_risk_config(self.basket)["stop_loss_pct"] is None: + return None + held = { + str(p.symbol): int(getattr(p, "quantity", 0) or 0) + for p in positions or [] if int(getattr(p, "quantity", 0) or 0) > 0 + } + context = {"benchmark": benchmark, "price": None, "basis": {}, "issues": []} + if not held: + return context + try: + from database.repositories import get_trade_history + + trades = get_trade_history(mode=self._ledger_mode(), account_key=self.account_key) + except Exception as exc: + context["issues"].append(f"매매 기록 조회 실패: {exc}") + trades = [] + buy_days = [ + getattr(t, "executed_at", None) for t in trades + if str(getattr(t, "symbol", "")) in held and str(getattr(t, "action", "")).upper() == "BUY" + and getattr(t, "executed_at", None) is not None + ] + start_day = (min(buy_days) if buy_days else datetime.now(_KST)) - timedelta(days=14) + end_day = datetime.now(_KST) + closes: dict = {} + latest = None + try: + frame = self.data_collector.fetch_korean_stock( + benchmark, start_day.strftime("%Y-%m-%d"), end_day.strftime("%Y-%m-%d"), + ) + if frame is not None and not frame.empty and "close" in frame.columns: + import pandas as pd + + days = pd.to_datetime(frame["date"] if "date" in frame.columns else frame.index, errors="coerce") + for day, close in zip(days, frame["close"].tolist()): + if not pd.isna(day): + closes[day.date()] = close + if closes: + latest = max(closes) + except Exception as exc: + context["issues"].append(f"{benchmark} 조회 실패: {exc}") + + quote_days = set() + snapshot = getattr(self, "_market_snapshot", None) or {} + for symbol in held: + quote = (snapshot.get(symbol) or {}).get("quote_date") + if quote: + quote_days.add(str(quote)[:10]) + if latest is None: + context["issues"].append(f"{benchmark} 가격 없음 — 오늘 상대 손절 판단 보류") + elif quote_days and quote_days != {str(latest)}: + context["issues"].append( + f"{benchmark} 기준일 {latest}가 보유 종목 시세 {sorted(quote_days)}와 달라 오늘 상대 손절 판단 보류" + ) + else: + context["price"] = closes[latest] + + basis = relative_stop_basis(trades, closes) + for symbol, quantity in held.items(): + entry = basis.get(symbol) + if not entry or entry.get("quantity") != quantity: + context["issues"].append(f"{symbol} 매수 기록과 보유 수량이 달라 절대 기준으로 판단") + continue + context["basis"][symbol] = entry.get("basis") + if entry.get("missing"): + context["issues"].append(f"{symbol} 매입일 {benchmark} 종가 없음({', '.join(entry['missing'])})") + if context["issues"]: + logger.warning("바스켓 '{}' 상대 손절 자료 문제: {}", self.basket_name, "; ".join(context["issues"])) + try: + from core.cycle_observability import record_event_once_per_day + + record_event_once_per_day( + "RELATIVE_STOP_DATA_ISSUE", + f"바스켓 '{self.basket_name}' 상대 손절 자료 문제: " + "; ".join(context["issues"]), + severity="warning", strategy=self.account_key, mode=self._ledger_mode(), + ) + except Exception as exc: + logger.warning("상대 손절 자료 문제 기록 실패: {}", exc) + return context + # ------------------------------------------------------------------ # 리밸런싱 주문 계획 # ------------------------------------------------------------------ @@ -870,6 +963,15 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder self.basket, self.account_key, self._ledger_mode(), ) + # 소액 ETF 계좌: 종목별 내림 대신 수량 조합을 함께 고른다(core/integer_allocation.py). + # 적용할 수 없으면(종목 수 초과 등) 아래 기존 계획기를 그대로 쓴다. + if str(self.rebalance_cfg.get("planner", "") or "").strip().lower() == "integer": + integer_orders = self._plan_integer( + summary, prices, positions, targets, min_trade, max_turnover, cooldown, + ) + if integer_orders is not None: + return self._finalize_plan(integer_orders, summary, prices, positions, max_turnover_amount) + # 1) 후보 거래를 먼저 모두 계산(회전율 예산 적용 전). 거래액은 실제 주문 명목금액 기준. candidates: list[tuple[RebalanceOrder, float]] = [] for symbol in targets: @@ -1057,6 +1159,10 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder remaining -= notional total_trade_amount += notional + return self._finalize_plan(orders, summary, prices, positions, max_turnover_amount) + + def _finalize_plan(self, orders, summary, prices, positions, max_turnover_amount): + """현금·비용 한도로 매수 수량을 맞추고 매도 → 매수 순서로 돌려준다.""" planned_quantities = {o.symbol: o.quantity for o in orders} orders = self._fit_cash_orders(orders, summary, prices, positions) funded_quantities = {o.symbol: o.quantity for o in orders} @@ -1077,6 +1183,97 @@ def plan_rebalance(self, prices: dict[str, float] = None) -> list[RebalanceOrder return ordered + def _plan_integer(self, summary, prices, positions, targets, min_trade, max_turnover, cooldown): + """정수 주 조합 계획. 적용할 수 없으면 None(호출부가 기존 계획기를 쓴다).""" + from core.integer_allocation import choose_integer_holdings + from core.risk_manager import RiskManager + + total_value = float(summary.get("total_value", 0) or 0) + pos_map = {p.symbol: p for p in positions} + held_value = sum(prices.get(p.symbol, p.avg_price) * p.quantity for p in positions) + cash = float(summary.get("cash", total_value - held_value)) + div = (self._risk_params or {}).get("diversification", {}) or {} + try: + min_cash = float(self.basket.get("min_cash_ratio", div.get("min_cash_ratio", 0.20))) + except (TypeError, ValueError): + return None + stock_fraction = self._stock_fraction() + total_targets = {s: float(w) * stock_fraction for s, w in targets.items()} + defensive = (self.basket.get("overlays") or {}).get("defensive_symbol") + + # 1주 매매 비용(슬리피지·수수료·세금 추정)을 실제 비용 모형으로 잰다. CD ETF 매도에는 + # 매매차익 과세 상한이 붙어서, 같은 개선이면 매도 없이 사는 조합이 이기게 된다. + unit_costs = {} + try: + rm = RiskManager(SimpleNamespace(risk_params=self._risk_params)) + snapshot = getattr(self, "_market_snapshot", None) or {} + for symbol in total_targets: + price = float(prices.get(symbol, 0) or 0) + if price <= 0: + return None + pos = pos_map.get(symbol) + volume = snapshot.get(symbol, {}).get("avg_volume") + buy = rm.calculate_transaction_costs(price, 1, "BUY", symbol=symbol, avg_daily_volume=volume) + sell = rm.calculate_transaction_costs( + price, 1, "SELL", symbol=symbol, avg_price=pos.avg_price if pos else None, + avg_daily_volume=volume, + ) + unit_costs[symbol] = { + "buy": max(0.0, float(buy["execution_price"]) - price) + float(buy.get("commission", 0) or 0), + "sell": max(0.0, price - float(sell["execution_price"])) + sum( + float(sell.get(k, 0) or 0) for k in ("commission", "tax", "capital_gains_tax") + ), + } + except Exception as exc: + logger.warning("정수 배분 비용 추정 실패 — 기본 비용률 사용: {}", exc) + unit_costs = None + + plan = choose_integer_holdings( + total_value=total_value, + cash=cash, + prices={s: float(prices[s]) for s in total_targets}, + current={s: int(pos_map[s].quantity) if s in pos_map else 0 for s in total_targets}, + targets=total_targets, + min_cash_ratio=min_cash, + min_trade=float(min_trade), + max_turnover=float(max_turnover), + unit_costs=unit_costs, + defensive_symbol=defensive, + blocked_buys=set(cooldown or {}), + ) + if plan is None: + return None + orders: list[RebalanceOrder] = [] + for symbol, delta in plan.trades.items(): + if delta == 0: + gap = plan.weights_after[symbol] - total_targets[symbol] + if abs(gap) >= 0.01: + code = "COOLDOWN" if symbol in (cooldown or {}) and gap < 0 else "INTEGER_HOLD" + self._plan_diagnostics.append({ + "symbol": symbol, "code": code, "price": float(prices[symbol]), + "reason": ( + f"1주 단위라 지금 수량이 목표에 가장 가깝습니다 — 현재 " + f"{plan.weights_after[symbol]:.1%}, 목표 {total_targets[symbol]:.1%}" + ), + }) + continue + reason = ( + f"비중 조정 ({plan.weights_before[symbol]:.1%} → {plan.weights_after[symbol]:.1%}, " + f"목표 {total_targets[symbol]:.1%}, 1주 단위 조합)" + ) + orders.append(RebalanceOrder( + symbol=symbol, action="BUY" if delta > 0 else "SELL", + quantity=abs(int(delta)), price=float(prices[symbol]), reason=reason, + )) + if orders: + logger.info( + "바스켓 '{}' 정수 배분: 이탈 {:.5f} → {:.5f}, 거래 {:,.0f}원", + self.basket_name, plan.loss_before, plan.loss_after, plan.turnover, + ) + # 매도 대금으로 매수하는 조합이므로 현금 맞춤(_fit_cash_orders)이 매도를 먼저 봐야 한다. + orders.sort(key=lambda o: 0 if o.action == "SELL" else 1) + return orders + def _fit_cash_orders(self, orders, summary, prices, positions): """매도 후 남는 현금과 예상 비용 안에서 매수 수량을 정한다. @@ -1220,6 +1417,22 @@ def execute( results["details"].append({"order": repr(order), "status": "dry_run"}) continue + stale = self._quote_guard_reason(order, snapshot) + if stale: + logger.warning("리밸런싱 주문 보류 {} {} {}주: {}", order.symbol, order.action, order.quantity, stale) + results["skipped"] += 1 + results["details"].append({"order": repr(order), "status": "blocked", "reason": stale}) + try: + from core.cycle_observability import record_event_once_per_day + + record_event_once_per_day( + "STALE_QUOTE_BLOCKED", f"바스켓 '{self.basket_name}' {order.symbol} 주문 보류: {stale}", + severity="warning", strategy=self.account_key, mode=self._ledger_mode(), + ) + except Exception as exc: + logger.warning("시세 가드 기록 실패: {}", exc) + continue + try: if order.action == "BUY": available_cash = self.portfolio_mgr.get_available_cash() @@ -1286,6 +1499,38 @@ def execute( ) return results + # 국내 주식·ETF의 하루 가격제한폭(±30%)을 넘는 변화는 시세 자료 오류로 본다. + _MAX_DAILY_MOVE = 0.30 + + def _quote_guard_reason(self, order, snapshot: dict) -> str | None: + """주문 직전 시세 점검. 문제가 있으면 보류 사유, 없으면 None. + + - 시세 기준일이 오늘(KST)이 아니면 오래된 가격으로 주문하지 않는다. 9/17~10/12처럼 + 공급자 자료가 멈추면 전날 가격으로 수량을 정하고 체결까지 그 가격으로 기록된다. + - 전일 종가 대비 ±30%를 넘는 가격은 가격제한폭 밖이라 자료 오류로 본다. + 기준일 정보가 없는 시세(테스트용 고정 시세 등)는 기존처럼 통과시킨다. + """ + from core.trading_hours import _now_kst + + quote = (snapshot or {}).get(order.symbol) or {} + quote_date = quote.get("quote_date") + # 거래 시간 판정과 같은 시계를 쓴다(장중 여부와 '오늘'이 서로 다른 시계를 보면 안 된다). + today = _now_kst().date().isoformat() + if quote_date and str(quote_date)[:10] != today: + return f"시세 기준일 {str(quote_date)[:10]} — 오늘({today}) 시세가 아니어서 주문하지 않음" + prev_close = quote.get("prev_close") + try: + if prev_close and float(prev_close) > 0: + move = float(order.price) / float(prev_close) - 1.0 + if abs(move) > self._MAX_DAILY_MOVE: + return ( + f"주문 가격 {float(order.price):,.0f}원이 전일 종가 {float(prev_close):,.0f}원 대비 " + f"{move:+.1%} — 가격제한폭 밖이라 시세 자료 오류로 보고 주문하지 않음" + ) + except (TypeError, ValueError): + return "주문 가격 확인 실패" + return None + # ------------------------------------------------------------------ # 리포트 # ------------------------------------------------------------------ @@ -1361,6 +1606,13 @@ def _fetch_market_snapshot(self) -> dict[str, dict]: source = source_info.get("source") if isinstance(source_info, dict) else None entry["source"] = source if isinstance(source, str) else "unknown" entry["observed_at"] = datetime.now(_KST).replace(tzinfo=None).isoformat() + if len(df) >= 2: + try: + prev_close = float(df["close"].iloc[-2]) + if math.isfinite(prev_close) and prev_close > 0: + entry["prev_close"] = prev_close + except (TypeError, ValueError): + pass if "volume" in df.columns: recent_vol = df["volume"].tail(20).dropna() if len(recent_vol) > 0 and float(recent_vol.mean()) > 0: diff --git a/core/basket_risk.py b/core/basket_risk.py index a0e992d..f6562b8 100644 --- a/core/basket_risk.py +++ b/core/basket_risk.py @@ -16,13 +16,23 @@ baskets.yaml 예: risk: stop_loss_pct: 0.20 # 진입가 대비 -20%에서 청산 (0/미지정이면 손절 없음) + stop_basis: relative # absolute(기본) | relative — 같은 기간 시장 대비로 잴지 + stop_benchmark: "069500" # relative일 때 비교할 종목 (기본 KODEX 200) take_profit_pct: 0 # 0/미지정이면 익절 없음 (buy&hold 기본) trailing_stop_pct: 0 # 0/미지정이면 트레일링 없음 + account_drawdown_guard: delegate # block(기본) | delegate — 계좌 낙폭 매수 차단을 맡길지 + +상대 손절(2026-10-20): 평균 매입가 대비 -25% 손절은 시장 전체가 빠질 때 연달아 걸린다. +10/15 삼성전자·현대차가 그렇게 팔렸는데 그 기간 KODEX 200도 -30% 가까이 빠져 있었다. +'한 종목이 포트폴리오를 망가뜨리는 것을 막는다'는 원래 목적에 맞게 같은 보유 기간의 +시장 수익률로 나눠서 잰다. 2014-07~2026-10 비교는 docs/HOLD_RISK_REVIEW_20261020.md. """ from __future__ import annotations -from typing import Any +import math +from datetime import date, datetime +from typing import Any, Iterable from loguru import logger @@ -119,6 +129,127 @@ def symbols_in_reentry_cooldown( return blocked +_STOP_BASES = ("absolute", "relative") +DEFAULT_STOP_BENCHMARK = "069500" +_ACCOUNT_GUARD_MODES = ("block", "delegate") + + +def stop_basis(basket_cfg: dict[str, Any]) -> tuple[str, str | None]: + """손절을 무엇에 비춰 재는가 — (기준, 비교 종목). + + absolute(기본): 평균 매입가 대비 하락률. + relative: 같은 보유 기간 비교 종목(기본 069500) 수익률 대비 초과 하락률. 시장 전체가 + 내릴 때는 걸리지 않고 시장보다 손절 비율만큼 더 빠진 종목만 판다. + 모르는 값은 기존 동작(absolute)으로 처리하고 경고를 남긴다. + """ + risk_cfg = (basket_cfg or {}).get("risk") or {} + raw = str(risk_cfg.get("stop_basis") or "absolute").strip().lower() + if raw not in _STOP_BASES: + logger.warning("바스켓 손절 기준 {!r}을 알 수 없어 absolute로 처리", raw) + raw = "absolute" + if raw != "relative": + return "absolute", None + bench = str(risk_cfg.get("stop_benchmark") or DEFAULT_STOP_BENCHMARK).strip() + return "relative", bench or DEFAULT_STOP_BENCHMARK + + +def delegates_account_drawdown_guard(basket_cfg: dict[str, Any]) -> bool: + """계좌 단위 낙폭·일일 손실 매수 차단을 이 바스켓의 규칙에 맡기는가. + + - overlays.drawdown_guard가 켜져 있으면 낙폭이 커질 때 목표 주식 비중 자체가 줄어든다 + (kr_pocket). 그 바스켓이 낙폭 정책을 갖고 있으니 계좌 가드를 맡긴다. + - risk.account_drawdown_guard: delegate는 낙폭과 상관없이 선언한 주식 비중까지만 + 되사겠다는 결정이다. 주문 단계의 노출 상한(설계 비중 + 밴드)은 그대로 검사한다. + 전역 가드에 막히면 손절·비중 초과 매도로 생긴 현금이 돌아가지 못해 비중이 한쪽으로만 + 줄어든다(2026-10: 설계 60%가 45%로, 2014~2026 비교에서 연 0.3~1.0%p 손해). + 둘 다 아니면 전역 가드가 매수를 막는다(기존 동작). + """ + cfg = basket_cfg or {} + overlay = ((cfg.get("overlays") or {}).get("drawdown_guard") or {}) + if bool(overlay.get("enabled")): + return True + raw = str(((cfg.get("risk") or {}).get("account_drawdown_guard") or "block")).strip().lower() + if raw not in _ACCOUNT_GUARD_MODES: + logger.warning("account_drawdown_guard={!r}를 알 수 없어 block으로 처리", raw) + return False + return raw == "delegate" + + +def _as_date(value) -> date | None: + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + try: + return date.fromisoformat(str(value)[:10]) + except (TypeError, ValueError): + return None + + +def relative_stop_basis( + trades: Iterable[Any], + benchmark_closes: dict[Any, float], + *, + max_gap_days: int = 7, +) -> dict[str, dict[str, Any]]: + """현재 보유 구간의 매수마다 그날 비교 종목 종가를 수량 가중 평균한다. + + 평균 매입가와 같은 규칙(매도는 평균을 바꾸지 않고, 수량이 0이 되면 새 보유 구간)을 + 써야 '종목 수익률 ÷ 같은 기간 시장 수익률'이 평균 매입가 기준 손익과 짝이 맞는다. + 매수일 종가가 없으면 그 전 max_gap_days 안의 마지막 종가를 쓰고, 그것도 없으면 + basis를 None으로 둔다(호출부는 절대 기준으로 대신 판단한다). + + 반환 {symbol: {"basis": float | None, "quantity": int, "missing": [날짜 문자열]}} + """ + closes: dict[date, float] = {} + for key, value in (benchmark_closes or {}).items(): + day = _as_date(key) + try: + close = float(value) + except (TypeError, ValueError): + continue + if day is not None and math.isfinite(close) and close > 0: + closes[day] = close + ordered_days = sorted(closes) + + def close_on(day: date) -> float | None: + if day in closes: + return closes[day] + earlier = [d for d in ordered_days if d < day and (day - d).days <= max_gap_days] + return closes[earlier[-1]] if earlier else None + + def _when(trade): + return getattr(trade, "executed_at", None) or datetime.min + + state: dict[str, dict[str, Any]] = {} + for trade in sorted(list(trades or []), key=_when): + symbol = str(getattr(trade, "symbol", "") or "") + action = str(getattr(trade, "action", "") or "").upper() + try: + qty = int(getattr(trade, "quantity", 0) or 0) + except (TypeError, ValueError): + qty = 0 + if not symbol or qty <= 0 or action not in ("BUY", "SELL"): + continue + entry = state.setdefault(symbol, {"basis": 0.0, "quantity": 0, "missing": []}) + if action == "SELL": + entry["quantity"] -= qty + if entry["quantity"] <= 0: + state[symbol] = {"basis": 0.0, "quantity": 0, "missing": []} + continue + day = _as_date(_when(trade)) + close = close_on(day) if day else None + if close is None: + entry["missing"].append(str(day)) + elif entry["basis"] is not None: + held = entry["quantity"] + entry["basis"] = (entry["basis"] * held + close * qty) / (held + qty) + entry["quantity"] += qty + if entry["missing"]: + entry["basis"] = None + return {s: e for s, e in state.items() if e["quantity"] > 0} + + def basket_risk_config(basket_cfg: dict[str, Any]) -> dict[str, float | None]: """바스켓의 리스크 정책을 정규화해 반환한다. @@ -174,17 +305,29 @@ def _level(pct: float | None, direction: int) -> float | None: return None return round(entry * (1 + direction * pct), 0) + # 상대 손절은 시장이 움직이면 손절 가격도 움직인다. 고정 가격을 적어 두면 장부에는 + # 절대 기준처럼 보이므로 비워 둔다(판단은 evaluate_basket_stops가 매번 다시 한다). + relative = stop_basis(basket_cfg)[0] == "relative" return { - "stop_loss_price": _level(cfg["stop_loss_pct"], -1), + "stop_loss_price": None if relative else _level(cfg["stop_loss_pct"], -1), "take_profit_price": _level(cfg["take_profit_pct"], +1), "trailing_stop_price": _level(cfg["trailing_stop_pct"], -1), } +def _positive(value) -> float | None: + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if math.isfinite(number) and number > 0 else None + + def evaluate_basket_stops( basket_cfg: dict[str, Any], positions: list[Any], prices: dict[str, float], + relative_context: dict[str, Any] | None = None, ) -> list[dict[str, Any]]: """보유 포지션을 바스켓 정책에 비춰 평가하고 청산 대상을 반환한다. @@ -195,12 +338,20 @@ def evaluate_basket_stops( 트레일링은 포지션의 highest_price(고점)를 기준으로 한다. 고점 정보가 없으면 진입가를 고점으로 보아 손절과 같아지므로, 그 경우 트레일링은 건너뛴다. + relative_context: stop_basis가 relative일 때 {"benchmark", "price"(지금 비교 종목 + 가격), "basis"({종목: 매입 시점 비교 종목 평균})}. 비교 자료가 없는 종목은 절대 + 기준으로 대신 판단한다 — 자료 문제로 큰 개별 손실 방어까지 꺼지지 않게 한다. + 반환: [{symbol, action, price, quantity, avg_price, level, reason}] — action은 "STOP_LOSS" | "TAKE_PROFIT" | "TRAILING_STOP". """ cfg = basket_risk_config(basket_cfg) if not any(v is not None for v in cfg.values()): return [] + basis_kind, benchmark = stop_basis(basket_cfg) + ctx = relative_context or {} + bench_now = _positive(ctx.get("price")) + bench_basis = ctx.get("basis") or {} hits: list[dict[str, Any]] = [] for pos in positions or []: @@ -240,16 +391,47 @@ def evaluate_basket_stops( continue sl_pct = cfg["stop_loss_pct"] - if sl_pct is not None: - level = avg_price * (1 - sl_pct) - if price <= level: - hits.append({ - "symbol": symbol, "action": "STOP_LOSS", "price": float(price), - "quantity": quantity, "avg_price": avg_price, "level": round(level, 0), - "reason": ( - f"손절: 현재가 {price:,.0f} ≤ 손절선 {level:,.0f} " - f"(진입가 {avg_price:,.0f} -{sl_pct:.0%})" - ), - }) + if sl_pct is None: + continue + note = "" + if basis_kind == "relative": + if bench_now is None: + # 지금 시장 수준을 모르면 상대 손절을 판단할 수 없다. 절대 기준으로 대신 + # 팔면 시장 전체 하락에 손절이 몰리는 바로 그 일을 자료 장애가 만든다 — + # 오늘은 판단을 보류하고(다음 사이클에 다시 본다) 호출부가 사유를 남긴다. + continue + start = _positive(bench_basis.get(symbol)) + if start is not None: + own = price / avg_price - 1.0 + market = bench_now / start - 1.0 + relative = (1.0 + own) / (1.0 + market) - 1.0 + if relative <= -sl_pct: + # 지금 시장 수준에서 상대 손절이 걸리는 가격(참고용) + level = avg_price * (1 - sl_pct) * (bench_now / start) + hits.append({ + "symbol": symbol, "action": "STOP_LOSS", "price": float(price), + "quantity": quantity, "avg_price": avg_price, "level": round(level, 0), + "basis": "relative", "relative_return": round(relative, 4), + "reason": ( + f"시장 대비 손절: 종목 {own:+.1%} · {benchmark} {market:+.1%} " + f"→ 상대 {relative:+.1%} ≤ -{sl_pct:.0%} (진입가 {avg_price:,.0f})" + ), + }) + continue + # 매수 기록과 보유 수량이 맞지 않는 등 이 종목의 매입 시점 시장 수준만 모르는 + # 경우다. 종목 하나의 문제라 절대 기준으로 대신 판단한다. + note = f" — 매입 시점 {benchmark} 자료가 없어 절대 기준으로 판단" + logger.warning("바스켓 상대 손절: {} 매입 시점 비교 자료 없음 — 절대 기준으로 판단", symbol) + level = avg_price * (1 - sl_pct) + if price <= level: + hits.append({ + "symbol": symbol, "action": "STOP_LOSS", "price": float(price), + "quantity": quantity, "avg_price": avg_price, "level": round(level, 0), + "basis": "absolute", + "reason": ( + f"손절: 현재가 {price:,.0f} ≤ 손절선 {level:,.0f} " + f"(진입가 {avg_price:,.0f} -{sl_pct:.0%}){note}" + ), + }) return hits diff --git a/core/integer_allocation.py b/core/integer_allocation.py new file mode 100644 index 0000000..9156e0d --- /dev/null +++ b/core/integer_allocation.py @@ -0,0 +1,177 @@ +"""소액 ETF 계좌의 정수 주 배분 — 종목마다 따로 내림하지 않고 수량 조합을 함께 고른다. + +문제(2026-10-13 점검): 종목별로 '목표 금액 − 보유 금액'을 1주 가격으로 내림하면, 부족분과 +초과분이 둘 다 1주보다 작을 때 아무 주문도 나오지 않는다. 10/12 적립 계좌는 주식 ETF +26.7%·CD ETF 61.2%(목표 47.5%·47.5%)였는데 계획이 0건이었다. CD ETF 2주를 팔아 주식 +ETF 1주를 사면 주식이 53.5%가 돼 목표와의 차이가 20.8%p에서 6.0%p로 줄어든다. + +방법: 종목마다 후보 수량 {현재, 목표 내림, 목표 올림, 목표 내림 − 1}을 두고 모든 조합을 +평가한다(종목 수가 적은 계좌 전용). 현금 하한·최소 거래금액·1회 회전 한도·재매수 차단을 +지키는 조합 중에서 '위험자산 비중 이탈 제곱합 + 놀고 있는 현금 + 거래비용'이 가장 작은 +것을 고른다. + +- 방어 자산(CD 파킹 ETF)과 현금은 위험이 거의 같다. 둘 사이의 비중 차이는 위험이 아니라 + 이자 차이라서, 방어 자산 자체의 이탈은 세지 않고 목표를 넘는 현금만 낮은 가중치로 센다. + 그래야 CD를 팔아 현금으로 바꾸는(비용·세금만 드는) 거래를 하지 않고, 남는 현금은 CD로 + 옮긴다. CD를 파는 것은 주식 비중을 맞출 돈이 필요할 때뿐이다. +- 거래비용(수수료·슬리피지·세금 추정)을 손실에 더해, 개선이 비용보다 작으면 거래하지 + 않는다. 지금 조합이 가장 나으면 주문이 없다. +- 새 적립금(현금)은 매도 없이 부족한 자산부터 산다 — 매도는 비용과 세금이 들기 때문에 + 같은 개선이면 매수만 하는 조합이 이긴다. +""" + +from __future__ import annotations + +import itertools +import math +from dataclasses import dataclass, field + +DEFAULT_IDLE_CASH_WEIGHT = 0.25 +MAX_SYMBOLS = 5 + + +@dataclass +class IntegerPlan: + quantities: dict[str, int] + trades: dict[str, int] # 종목 → 수량 변화(+매수/−매도) + loss_before: float + loss_after: float + turnover: float # 거래 명목금액 합(원) + cash_after: float + weights_before: dict[str, float] = field(default_factory=dict) + weights_after: dict[str, float] = field(default_factory=dict) + + @property + def changed(self) -> bool: + return any(self.trades.values()) + + +def _finite_positive(value) -> bool: + try: + number = float(value) + except (TypeError, ValueError): + return False + return math.isfinite(number) and number > 0 + + +def _candidates(current: int, target_qty: float, can_buy: bool) -> list[int]: + low = max(0, math.floor(target_qty)) + options = {current, low, max(0, low - 1), max(0, math.ceil(target_qty))} + if not can_buy: + options = {q for q in options if q <= current} + return sorted(options) + + +def choose_integer_holdings( + *, + total_value: float, + cash: float, + prices: dict[str, float], + current: dict[str, int], + targets: dict[str, float], + min_cash_ratio: float, + min_trade: float, + max_turnover: float, + unit_costs: dict[str, dict[str, float]] | None = None, + default_cost_rate: float = 0.001, + defensive_symbol: str | None = None, + idle_cash_weight: float = DEFAULT_IDLE_CASH_WEIGHT, + blocked_buys: set[str] | frozenset[str] = frozenset(), + max_symbols: int = MAX_SYMBOLS, +) -> IntegerPlan | None: + """총자산 대비 목표 비중(targets)에 가장 가까운 정수 수량 조합. + + unit_costs: {종목: {"buy": 1주 매수 비용, "sell": 1주 매도 비용}} (원, 체결가 대비). + 없으면 default_cost_rate × 가격으로 추정한다. + defensive_symbol: 현금과 위험이 비슷한 방어 자산. 그 비중 이탈은 세지 않는다. + 적용할 수 없는 입력(종목 수 초과, 가격·자산 누락)이면 None — 호출부는 기존 계획기를 쓴다. + """ + symbols = sorted(targets) + if not symbols or len(symbols) > max_symbols: + return None + if not _finite_positive(total_value) or not math.isfinite(float(cash)): + return None + if any(not _finite_positive(prices.get(s)) for s in symbols): + return None + if any((not math.isfinite(float(w))) or w < 0 for w in targets.values()): + return None + total = float(total_value) + reserve = max(0.0, float(min_cash_ratio)) * total + budget = max(0.0, float(max_turnover)) * total + costs = unit_costs or {} + + def unit(symbol: str, side: str) -> float: + raw = (costs.get(symbol) or {}).get(side) + if raw is not None and math.isfinite(float(raw)) and float(raw) >= 0: + return float(raw) + return float(prices[symbol]) * default_cost_rate + + cash_target = max(0.0, 1.0 - sum(float(w) for w in targets.values())) + risky = [s for s in symbols if s != defensive_symbol] + + def deviation(quantities: dict[str, int], cash_left: float) -> float: + risky_gap = sum( + (quantities[s] * float(prices[s]) / total - float(targets[s])) ** 2 for s in risky + ) + idle = max(0.0, cash_left / total - cash_target) + return risky_gap + idle_cash_weight * idle ** 2 + + now = {s: max(0, int(current.get(s, 0) or 0)) for s in symbols} + options = [ + _candidates(now[s], float(targets[s]) * total / float(prices[s]), s not in blocked_buys) + for s in symbols + ] + loss_before = deviation(now, float(cash)) + best = None + for combo in itertools.product(*options): + quantities = dict(zip(symbols, combo)) + cash_after = float(cash) + turnover = 0.0 + trade_cost = 0.0 + feasible = True + for s in symbols: + delta = quantities[s] - now[s] + if delta == 0: + continue + notional = abs(delta) * float(prices[s]) + if notional < min_trade: + feasible = False + break + turnover += notional + if delta > 0: + cost = delta * unit(s, "buy") + cash_after -= notional + cost + else: + cost = -delta * unit(s, "sell") + cash_after += notional - cost + trade_cost += cost + if not feasible or turnover > budget + 1e-6: + continue + # 현금 하한: 지금보다 현금을 줄이는 조합은 하한을 지켜야 한다(이미 하한 아래면 + # 더 줄이지 않는 조합만 허용 — 매도만 하는 조합은 언제나 가능). + if cash_after < min(reserve, float(cash)) - 1e-6: + continue + loss = deviation(quantities, cash_after) + trade_cost / total + key = (round(loss, 12), turnover) + if best is None or key < best[0]: + best = (key, quantities, turnover, cash_after) + + if best is None: + return None + _key, quantities, turnover, cash_after = best + loss_after = deviation(quantities, cash_after) + trades = {s: quantities[s] - now[s] for s in symbols} + if any(trades.values()) and not loss_after < loss_before: + # 비용만 들고 목표에 가까워지지 않는 조합은 택하지 않는다(위의 비용 항으로 이미 + # 걸러지지만 반올림 경계에서 확실히 막는다). + quantities, trades, turnover, cash_after, loss_after = dict(now), {s: 0 for s in symbols}, 0.0, float(cash), loss_before + return IntegerPlan( + quantities=quantities, + trades=trades, + loss_before=loss_before, + loss_after=loss_after, + turnover=turnover, + cash_after=cash_after, + weights_before={s: now[s] * float(prices[s]) / total for s in symbols}, + weights_after={s: quantities[s] * float(prices[s]) / total for s in symbols}, + ) diff --git a/core/order_executor.py b/core/order_executor.py index ff33a39..efe8b36 100644 --- a/core/order_executor.py +++ b/core/order_executor.py @@ -1107,12 +1107,14 @@ def _drawdown_pre_order_check( 평균단가로 잰다 — paper에서는 시장이 움직여도 값이 변하지 않아, 하락장에선 낙폭을 작게 보고(9/22 kr_diversified_hold 9.7%, 실제로는 12.9%) 평가익이 쌓이면 가짜 '일일 손실'로 매수를 막는다(6/15 원장 재현 -5.5%). - delegated: 자기 낙폭 규칙(overlays.drawdown_guard — 리밸런서가 배수로 적용)을 - 켜 둔 바스켓의 목표 비중 주문. 계좌 단위 MDD·일일 손실 가드는 재량 매매용 - 안전판이라, 이런 주문은 그 바스켓 규칙을 따른다. 상관 거부권(#456)·노출 - 상한(#457)과 같은 원칙이다. 규칙을 꺼 둔 바스켓(kr_diversified_hold)은 - 넘기지 않는다 — 넘기면 계좌 가드를 아무도 적용하지 않게 된다. 판정은 계속 - 계산해 로그로 남긴다. + delegated: 낙폭 정책을 스스로 정한 바스켓의 목표 비중 주문 + (core.basket_risk.delegates_account_drawdown_guard). 자기 낙폭 규칙 + (overlays.drawdown_guard — 리밸런서가 배수로 적용)을 켰거나, 낙폭과 무관하게 + 선언한 주식 비중까지만 되사기로 한 바스켓(risk.account_drawdown_guard: + delegate)이다. 계좌 단위 MDD·일일 손실 가드는 재량 매매용 안전판이라, 이런 + 주문은 그 바스켓 규칙을 따른다. 상관 거부권(#456)·노출 상한(#457)과 같은 + 원칙이다. 넘겨도 노출 상한(설계 비중 + 밴드)은 아래 분산 검사가 그대로 막는다. + 어느 쪽도 정하지 않은 바스켓은 넘기지 않는다. 판정은 계속 계산해 로그로 남긴다. """ decision = self._drawdown_guard_decision(action, mark_prices=mark_prices) if ( diff --git a/core/weekly_report.py b/core/weekly_report.py index b32333b..1fd2efe 100644 --- a/core/weekly_report.py +++ b/core/weekly_report.py @@ -54,6 +54,11 @@ def build_weekly_summary( perf.append(f"주간 {week_nav_change_pct:+.2f}%") if nav is not None: perf.append(f"누적 {nav:+.2f}%") + # 적립 계좌는 넣은 돈 기준 수익률이 운용 규칙의 수익률(시간가중)과 갈린다 — 같이 보여 준다. + money_weighted = m.get("money_weighted_return_pct") + contributed = m.get("contributed_capital") + if money_weighted is not None and contributed is not None: + perf.append(f"넣은 돈 {contributed:,.0f}원 기준 {money_weighted:+.2f}%") if perf: fields.append({"name": "💰 성과", "value": " · ".join(perf), "inline": False}) diff --git a/docs/HOLD_RISK_REVIEW_20261020.md b/docs/HOLD_RISK_REVIEW_20261020.md new file mode 100644 index 0000000..4377807 --- /dev/null +++ b/docs/HOLD_RISK_REVIEW_20261020.md @@ -0,0 +1,111 @@ +# 대형주 분산 계좌 손절·낙폭 차단 점검 + +기준일 **2026-10-20** · 마지막 확정 종가 **10월 19일**(백테스트), 운영 장부는 10월 20일 10:11 기록까지 + +대형주 계좌(`kr_diversified_hold`)에는 종목별 손절(평균 매입가 −25%), 손절 종목 60일 재매수 차단, 계좌 낙폭 15% 이상이면 모든 매수 차단이 함께 걸려 있었다. 셋을 하나씩 검토한 적은 있지만, 한꺼번에 걸었을 때 어떻게 되는지는 시험한 적이 없다. 10월 시장 급락에서 이 조합이 어떻게 움직였는지 확인하고, 같은 9종목으로 과거 구간을 다시 계산했다. + +## 무슨 일이 있었나 + +| 날짜 | 종목 | 매도 | 실현손익 | 10/20 종가 | +|---|---|---:|---:|---:| +| 8/07 | 현대차 1주 | 394,500원 | −207,349원 | — (같은 날 다시 사들임, 8/07 점검 참고) | +| 9/21 | 현대모비스 2주 | 373,000원 | −274,106원 | 347,500원 | +| 10/15 | 현대차 1주 | 295,500원 | −100,636원 | 309,500원 | +| 10/15 | 삼성전자 3주 | 213,000원 | −237,377원 | 212,000원 | + +- 손절 실현손익 합계 **−819,468원**(자본 1,000만 원의 8.2%). +- 같은 기간 KODEX 200은 6/10 123,546원에서 10/20 86,755원으로 **−29.8%**. 손절이 '한 종목의 사고'가 아니라 시장 전체 하락에 몰려서 걸렸다. +- 손절 대금이 쌓인 뒤 계좌 낙폭이 16~19%가 되자, 계좌 전체에 걸린 낙폭 15% 한도가 보충 매수를 매일 막았다(10/12~10/20 7거래일 `ORDER_REJECTED` 10건). 주식 비중은 설계 60%에서 **45%**로 내려앉았다. 매도는 항상 되고 매수는 막히는 한쪽 방향 장치다(운영 원칙 4). + +## 같은 9종목으로 다시 계산 + +정수 주, 수수료 0.015%, 슬리피지 0.05%, 매도세 0.20%, 현금 이자 0%, 8%p·3%p 이탈 리밸런싱(운영 규칙과 같은 보충 매수 포함). 판단·체결은 당일 종가. + +**2014-07 ~ 2026-10** (9종목 자료가 모두 있는 첫날부터, 2,999거래일) + +| 규칙 | 연수익률 | 최대낙폭 | 샤프 | 손절 | 손절 60일 뒤 시장 대비 | +|---|---:|---:|---:|---:|---:| +| 손절 없음 · 차단 없음 | +5.58% | −28.6% | 0.48 | — | — | +| 낙폭 15% 매수 차단만 | +5.26% | −27.6% | 0.46 | — | — | +| 절대 손절 −25%만 | +5.51% | −26.2% | 0.48 | 17회 | +1.2%p | +| **절대 손절 + 낙폭 차단 (지금까지 운영)** | **+4.12%** | −27.7% | **0.38** | 20회 | +3.4%p | +| 시장 대비 손절 −25% | +6.19% | −28.0% | 0.53 | 32회 | −0.8%p | +| 시장 대비 손절 + 낙폭 차단 | +5.44% | −29.0% | 0.51 | 35회 | +0.6%p | + +**2021-12 ~ 2026-10** (2022년 약세장, 2026년 두 번의 급락 포함) + +| 규칙 | 연수익률 | 최대낙폭 | 샤프 | 손절 | 손절 60일 뒤 시장 대비 | +|---|---:|---:|---:|---:|---:| +| 손절 없음 · 차단 없음 | +5.99% | −20.9% | 0.45 | — | — | +| 낙폭 15% 매수 차단만 | +4.95% | −21.8% | 0.39 | — | — | +| 절대 손절 −25%만 | +5.09% | −21.5% | 0.40 | 14회 | +2.4%p | +| **절대 손절 + 낙폭 차단 (지금까지 운영)** | **+3.46%** | −22.3% | **0.30** | 14회 | +3.2%p | +| 시장 대비 손절 −25% | +6.20% | −19.1% | 0.49 | 23회 | −3.3%p | +| 시장 대비 손절 + 낙폭 차단 | +6.20% | −19.1% | 0.49 | 23회 | −3.3%p | + +'손절 60일 뒤 시장 대비'는 판 종목이 그 뒤 60거래일 동안 KODEX 200보다 얼마나 더 오르거나 내렸는지다. 양수면 판 뒤에 더 올랐다는 뜻이라 손절이 손해였다. + +임계값을 20%·30%로 바꿔 본 점검(운영값을 고르는 데 쓰지 않음): + +| 규칙 | 2014~ 연수익률 | 2021~ 연수익률 | +|---|---:|---:| +| 손절 없음 | +5.58% | +5.99% | +| 절대 −20% / −25% / −30% | +5.42 / +5.51 / +5.43% | +5.49 / +5.09 / +4.78% | +| 시장 대비 −20% / −25% / −30% | +5.36 / +6.19 / +5.22% | +6.07 / +6.20 / +6.69% | + +## 해석 + +1. **낙폭 15% 매수 차단은 두 구간 모두 수익을 깎았다**(−0.3 ~ −1.0%p). 손절과 겹치면 −1.5 ~ −2.5%p로 가장 나빴다. 지금까지 쓰던 규칙이 두 구간 모두 가장 나빴다. +2. **절대 손절은 시장 전체가 빠질 때 바닥 근처에서 판다.** 판 뒤 60일 동안 시장보다 +1 ~ +3%p 더 올랐다. +3. **시장 대비 손절은 수익률을 크게 바꾸지 않는다.** 2021년 이후는 임계값과 상관없이 손절 없음과 같거나 조금 나았지만(+0.1 ~ +0.7%p), 2014년부터 보면 25%만 좋아 보이고 20%·30%는 손절 없음보다 조금 낮다. 초과수익 장치가 아니라 **'한 종목 사고 방어는 남기고 시장 하락 손절은 없애는' 장치**로 본다. 판 뒤의 흐름도 최근 구간에서는 시장보다 더 빠져(−3 ~ −8%p) 제대로 잘라낸 쪽이었다. +4. 2026년까지 살아남은 9종목만 썼다. 상장폐지·장기 몰락 종목이 빠져 있어 손절의 사고 방어 효과는 과소평가돼 있다. 손절을 아예 없애지 않은 이유다. + +## 바꾼 것 + +| 항목 | 이전 | 이후 | +|---|---|---| +| 손절 기준 | 평균 매입가 대비 −25% | **같은 보유 기간 KODEX 200 대비 −25%** (`stop_basis: relative`) | +| 계좌 낙폭 15% 매수 차단 | 계좌 전체 한도가 모든 매수를 막음 | **정해 둔 주식 비중(60% + 3%p)까지는 다시 산다** (`account_drawdown_guard: delegate`) | +| 재매수 차단 60일, 손절 비율 25% | 유지 | 유지 | + +- 매입 시점 시장 수준은 현재 보유 구간의 매수마다 그날 KODEX 200 종가를 수량 가중 평균해서 만든다. 평균 매입가와 같은 규칙이라 둘이 짝이 맞는다. +- 지금 시장 가격을 모르거나 보유 종목 시세와 날짜가 다르면 그날은 **판단을 미룬다**. 절대 기준으로 대신 팔면 자료 장애가 시장 하락 손절을 다시 만든다. 이 경우 `RELATIVE_STOP_DATA_ISSUE`를 하루 한 번 남긴다. +- 한 종목의 매입 시점 자료만 없으면(매수 기록과 보유 수량이 다른 경우 등) 그 종목만 절대 기준으로 판단한다. +- 한도를 넘겨받아도 비중 상한은 그대로다. 주문 단계에서 주식 비중이 60% + 3%p를 넘게 되는 매수는 막힌다. +- 평가 화면의 매수 가능 상태(`entry_status`)도 같은 함수로 판단하므로, 이 계좌가 계좌 낙폭 때문에 '매수 차단'으로 표시되지 않는다. + +**소급 적용은 없다.** 지금 보유한 6종목의 시장 대비 수익률은 NAVER +7.7%, LG화학 +15.9%, 카카오 +17.5%, POSCO홀딩스 +18.8%, 신한지주 +30.3%, KB금융 +46.7%로 모두 손절선과 멀다. 바꾼 규칙이 켜지는 날 팔리는 종목은 없다. + +## 운영 장부 사본으로 확인 + +10월 20일 운영 DB를 읽기 전용으로 복사하고 10월 20일 종가를 고정해 실제 계획 → 실행 경로를 태웠다. 원본 DB 해시는 실행 전후 같다. + +| 확인 | 결과 | +|---|---| +| 시장 대비 손절 평가 | 청산 0건 | +| 보충 매수 계획 | 신한지주 3주(91,300원), 카카오 7주(31,500원) | +| 체결 | 2건 모두 체결 — 낙폭 18%대에서도 거절 없음 | +| 주식 비중 | 44.6% → 50.4% (상한 63% 이내) | +| 재매수 차단 종목 | 삼성전자·현대차·현대모비스는 사지 않음 | + +60%까지 바로 채우지 못하는 이유는 재매수 차단 때문이다. 보충 매수는 목표보다 덜 가진 종목만 사는데, 남은 6종목 중 4종목은 이미 목표 비중(1/9) 이상이다. 차단이 풀리면(현대모비스 11/20, 삼성전자·현대차 12/14) 그 종목을 다시 사면서 60%로 돌아간다. 실적 발표일 필터는 그대로라 10월 실적 시즌에는 일부 매수가 며칠 보류될 수 있다. + +## 운영자가 정할 것 + +- 예전 절대 기준으로 손절한 3종목의 60일 재매수 차단을 그대로 둘지. 지금은 그대로 둔다(규칙 변경을 과거 매매에 소급하지 않는다). +- 손절 자체를 없앨지. 수익률만 보면 손절 없음과 시장 대비 손절이 비슷하다. 손절을 둔 목적(한 종목의 큰 사고 방어) 때문에 남겨 뒀다. +- 되돌리려면 `config/baskets.yaml`에서 `stop_basis`와 `account_drawdown_guard` 두 줄을 지우면 된다. + +## 재현 + +```powershell +# 규칙 조합 비교 (FDR 가격, data/research_prices 캐시) +.\.venv\Scripts\python.exe tools/hold_policy_backtest.py --as-of 2026-10-20 --sensitivity +# 운영 DB 사본 + 고정 시세로 실제 주문 경로 확인 (원본 DB는 읽기만 한다) +.\.venv\Scripts\python.exe tools/verify_policy_changes_20261020.py --source-db data/quant_trader.db --out-dir output/policy_20261020 +.\.venv\Scripts\python.exe -m pytest tests/test_hold_relative_stop.py tests/test_basket_risk_policy.py +``` + +결과 원본: [reports/research/hold_policy_backtest.json](../reports/research/hold_policy_backtest.json) + +관련 근거: 손절 규칙은 수익률이 추세를 띨 때만 도움이 되고 무작위 보행에서는 기대수익을 낮춘다(Kaminski·Lo, "When do stop-loss rules stop losses?", Journal of Financial Markets 2014). 이 계좌는 시장 방향을 맞히려는 계좌가 아니라 정한 비중을 유지하는 계좌라, 시장 하락에 반응하는 장치를 줄이는 쪽이 설계와 맞다. diff --git a/docs/MARKET_RESEARCH_20261020.md b/docs/MARKET_RESEARCH_20261020.md new file mode 100644 index 0000000..8206b7d --- /dev/null +++ b/docs/MARKET_RESEARCH_20261020.md @@ -0,0 +1,94 @@ +# 투자 방식 조사와 운용 개선 — 2026-10-20 + +사람들이 실제로 어떻게 거래했고 결과가 어땠는지, 수익은 높고 손실은 적다고 검증된 방식이 무엇인지, 지금 시장이 어떤지 조사했다. 조사해 보니 새 매매 신호를 더할 이유는 없었다. 대신 **이미 정해 둔 규칙이 설계대로 돌지 않던 곳**을 고쳤다. + +- 대형주 계좌: 시장 전체가 빠질 때 한꺼번에 걸리던 손절을 시장 대비 손절로 바꿨다. 손절로 생긴 현금이 계좌 낙폭 한도에 막혀 다시 투자되지 못하던 문제도, 정해 둔 주식 비중(60%)까지는 다시 사도록 고쳤다. +- ETF 적립 계좌: 1주 단위 때문에 비중이 크게 어긋나도 주문이 나오지 않던 문제를 고쳤다. 종목마다 따로 내림하지 않고 수량 조합을 함께 고른다. +- 공통: 넣은 돈 기준 수익률을 같이 보여 주고, 오래된 시세로는 주문하지 않으며, KIS의 새 주문 규격을 쓴다. + +숫자마다 기준일과 출처를 달았고, 백테스트·표본외·실거래 결과를 구분해 적었다. 전체 조사 노트와 보고서는 로컬 `research_notes/`, `reports/`에 있다(저장소 제외). + +## 1. 사람들은 어떻게 거래했고 결과는 어땠나 + +**개인 계좌를 실제로 들여다보면 '자주, 빨리, 크게' 거래할수록 결과가 나빴다.** + +- 자본시장연구원 26-04(2026-02, 개인 약 10.2만 명, 2020~2022 계좌 자료): 국내 시장 투자자의 누적 순수익은 −10.3%로 시장보다 12.9%p 낮았다. 수익을 낸 투자자 비율은 시간가중 기준 28%, **금액가중 기준 23%**였다. 사고판 시점 때문에 성과가 더 깎였다는 뜻이다. 레버리지·인버스 상품의 수익 기여도는 −7.5%였다. [KCMI](https://www.kcmi.re.kr/report/report_view?report_no=2254) +- NH투자증권 계좌 분석(2025-10-30 기준): 국내 주식 계좌 240만 개 중 54.6%가 평가손실이었다. KOSPI가 처음 4,100을 넘던 날이다. [인베스팅닷컴 2025-11-10](https://kr.investing.com/news/stock-market-news/article-1708696) +- 해외도 다르지 않다. 미국 대형주 액티브 펀드의 79%가 2025년 S&P 500에 뒤졌다(SPIVA). 대만 데이트레이더 중 꾸준히 이익을 낸 사람은 1%가 안 됐고(1992~2006), 브라질 선물 데이트레이더는 300일 넘게 버틴 사람의 97%가 돈을 잃었다. +- AI·LLM 매매: Alpha Arena 1시즌(2025-10~11, 모델마다 1만 달러 실거래)에서 6개 중 4개가 손실을 냈다(GPT-5 −62.7%, Gemini 2.5 Pro −56.7%). 매매가 잦아 수수료 부담도 컸다. 2017년부터 운용한 AI ETF(AIEQ)는 SPY보다 해마다 4%p 넘게 뒤처졌다. + +**만족도가 높은 쪽은 거래가 적은 '지루한' 방식이었다.** 토스 '주식 모으기' 183만 명(2025-03), 카카오페이 160만 명 이상(2025-10), ISA 1,005만 명·76.5조 원(2026-08), 퇴직연금 ETF 48.7조 원(2025년 말, 1년 새 +131.9%). 커뮤니티에서 후회가 컸던 쪽은 국내 개별주 집중 투자·레버리지·리딩방이었다. 'AI 자동매매'를 내세운 판매는 금감원 소비자경보 대상이다(경찰 집계 리딩방 피해 약 1.4만 건, 1.2조 원 이상). + +## 2. 검증된 방식 — 백테스트가 아니라 공개 뒤 성과로 본다 + +| 방식 | 근거 | 이 프로젝트에서 | +|---|---|---| +| 공개된 전술 배분(GTAA·듀얼 모멘텀 등) | 공개 뒤에는 낙폭을 줄이는 효과만 남고 초과수익은 사라졌다. GEM 2014~2026 연 8.4%·샤프 0.70, 같은 기간 SPY 13.6%·0.94 [표본외] | 새 신호를 넣지 않는다 | +| 방어 자산 | 2022년 금리 급등기에 금리 영향을 덜 받는 안전자산을 쓴 전략은 −8.0%, 많이 받는 전략은 −17.3% [Allocate Smartly 추적] | CD금리 ETF를 그대로 방어 자산으로 쓴다 | +| 지수 200일선 추세 필터 | 표본외에서 샤프를 7~12% 정도 올렸다(미국). 빠졌다 다시 들어간 구간의 약 80%는 손해였고, 몇 번의 큰 약세장에서 만회했다. 개별 종목에는 잘 안 통한다 | 적립 계좌만 켜고 대형주 계좌는 끈다 | +| 변동성 목표 | 레버리지 없이 쓰면 비중을 줄이기만 해서 수익이 깎인다 | 꺼 둔다(9월 결과와 같다) | +| 손절 | 수익률이 무작위로 움직이면 기대수익을 낮추고, 추세가 있을 때만 도움이 된다(Kaminski·Lo 2014) | 대형주 손절을 시장 대비로 바꿨다 | +| 리밸런싱 | 달력 기준보다 이탈 폭 기준이 비용과 이탈 모두에서 낫다(Vanguard 2024: 2%p를 넘으면 1.75%p까지만 되돌림). 적립금으로 모자란 자산부터 산다 | 적립 계좌는 팔기 전에 적립금부터 쓴다 | +| 백테스트 할인 | 투자은행 상품 215개의 샤프가 백테스트 1.20에서 실전 0.31로 떨어졌다. Quantopian 전략 888개는 백테스트 샤프로 실전 성과를 거의 설명하지 못했다(R² < 0.025) | 60거래일 모의 기록은 '제대로 돌아가는지' 확인하는 용도이지 초과수익의 증거가 아니다 | + +공개 전략 63개의 샤프가 발표 전 0.98에서 발표 뒤 0.67로 내려갔다는 자료도 있었지만, 업체 블로그 분석이고 유의성 검정을 철회한 상태라 표에서 뺐다. + +**원화·소액 계좌에 맞춰 보면** + +- 국내 동일가중 ETF는 두 구간 모두 KODEX 200에 뒤졌다(KODEX Top10동일가중 연 −9.8%p, 200종목 동일가중 연 −7.6~−7.9%p). 우리 9종목 동일가중과 가장 비슷한 실거래 사례다. 동일가중은 '반도체 두 종목 쏠림을 피한다'는 설계 결정이라 이번에는 바꾸지 않았다. +- 2005~2024년에는 KOSPI가 빠질 때 원화도 약해져(월간 상관 −0.35) 달러 자산이 충격을 덜어 줬다. KOSPI·S&P 500·미국채 7~10년·금을 25%씩 섞은 원화 기준 백테스트는 연 10.7%, 변동성 9.8%, 월말 기준 최대 낙폭 −14.6%였다(KOSPI 단독 9.8%·22.4%·−48.5%). **2026년에는 이게 통하지 않았다.** 원화가 강해지면서(6월 말 1,549원 → 1,327원) 5~9월 원화 기준으로 S&P 500 −8.8%, 미국채 7~10년 −13.9%, 금 −17.8%였고, 원화 현금과 CD금리 ETF만 버텼다. +- 소액 계좌에서 제일 큰 제약은 1주 가격이다. KODEX 200 1주 86,755원은 30만 원의 28.9%다. KODEX 200TR(31,435원, 보수 0.05%)이나 SOL 200TR(32,600원, 0.01%)이면 10.5%p 단위로 맞출 수 있다(2026-10-20 종가, 네이버). +- 거래비용은 주식 왕복 약 0.28~0.33%(2026년 코스피 매도세 0.20% 포함), ETF 왕복 약 0.03~0.04%다. + +## 3. 지금 시장 (2026-10-20 종가) + +- KOSPI 5,552.74(−3.73%). 6/22 사상 최고 종가 9,114.55보다 **39.1% 낮고**, 연초보다는 31.8% 높다. KOSPI 200 858.78. 6/23 −9.99%, 7/28 −10.84%(서킷브레이커), 7/31 +17.91%를 거쳐 9/23 7,080.92까지 반등했다가 10월에 다시 18.8% 빠졌다. [서울경제 2026-10-20](https://en.sedaily.com/finance/2026/10/20/kospi-closes-down-373-percent-at-555274) +- 삼성전자·SK하이닉스가 KOSPI 200의 약 61%(7/28 기준)라, KODEX 200을 들고 있으면 사실상 메모리 반도체에 60%를 건 셈이다. 외국인은 올해 200조 원 넘게 순매도했고, 두 회사의 자사주 매입도 10월에 끝났다. +- 한국은행 기준금리 3.00%(8/27 인상, 다음 결정 10/22). 원/달러 1,327~1,343원. 미국 기준금리 3.75~4.00%, 미국 10년물 약 5.1%. +- **규칙으로 보면:** KOSPI 200은 10/13부터 200일선 아래에 있고 지금은 13.4% 밑이다. 6~7월 급락 때는 종가가 한 번도 200일선 아래로 내려가지 않아, 추세 필터는 고점에서 약 36% 빠진 뒤에야 켜졌다. 급등 뒤에는 200일선 필터가 늦게 켜진다는 점을 적어 둔다. 우리 적립 계좌는 지금 이 필터 때문에 주식 목표가 절반(23.75%)이다. +- 12개월 원화 수익률: KODEX 200 +63%, S&P 500 +10%, CD금리 +2.9%, 국고채 −1~−6%, 금 −6~−8%, 미국채 −9~−14%. 대형주 9종목 가운데 200일선 위에 있는 종목은 KB금융 하나다. + +## 4. 운영·규제에서 확인한 것 + +- KIS 공식 예제의 현금 주문은 `TTTC0012U`(매수)·`TTTC0011U`(매도), 모의투자는 `VTTC0012U`·`VTTC0011U`이고 거래소ID구분코드가 필수다. 모의투자는 KRX 주문만 받는다. [order_cash.py](https://github.com/koreainvestment/open-trading-api/blob/main/examples_llm/domestic_stock/order_cash/order_cash.py) +- ETF는 2026년 10월 현재 KRX 정규장에서만 거래된다. 9/14에 생긴 KRX 애프터마켓(16:00~20:00)에는 ETF가 빠져 있고, NXT의 ETF 거래도 미뤄졌다. 우리 10:07 실행은 장 시작·마감 동시호가 시간을 피한다. +- 2012년 Knight Capital 사고 뒤 업계 기준(FIA, MiFID II RTS 6, SEC 15c3-5)이 공통으로 요구하는 점검은 기준 가격 대비 가격 범위, 오래된 시세 거부, 미체결 주문까지 넣은 한도, 잦은 잔고 대사다. +- 2023년 알고리즘 거래자 등록은 증권사 서버에 설치한 고속 거래만 대상이다. 하루 한 번 내 계좌를 API로 매매하는 경우는 해당하지 않는다. 다만 프로그램이나 매매 신호를 남에게 파는 순간 무등록 투자일임 문제가 생긴다. + +## 5. 프로그램에 반영한 것 + +| 근거 | 바꾼 것 | 확인 | +|---|---|---| +| 손절 연구, 10/15 시장 하락 손절, 같은 9종목 재계산 | 대형주 손절을 KODEX 200 대비로 판단(`stop_basis: relative`) | [HOLD_RISK_REVIEW_20261020](HOLD_RISK_REVIEW_20261020.md) | +| 운영 원칙 3·4, 낙폭 15% 매수 차단이 두 구간 모두 연 −0.3~−1.0%p | 계좌 낙폭 한도에 걸려도 정해 둔 주식 비중(60% + 3%p)까지는 다시 산다(`account_drawdown_guard: delegate`) | 장부 사본: 낙폭 18%대에서 2건 체결, 주식 비중 44.6% → 50.4% | +| 10/13 점검 P1(1주 단위 때문에 주문 0건) | 적립 계좌 주문을 1주 단위 수량 조합으로 계획(`planner: integer`, `core/integer_allocation.py`) | 2020-07~2026-10 재생: 연 12.53% → 12.56%, 최대 낙폭 −15.82% → −15.44%, 비용 3배에서도 같다. 10/12 상태에서 CD 2주 매도 → KODEX 200 1주 매수 | +| KCMI의 금액가중·시간가중 차이 | 평가·주간 보고에 넣은 돈 기준 수익률(Modified Dietz) 추가 | 적립 계좌 시간가중 −9.89%, 넣은 돈 40만 원 기준 −9.81% | +| Knight Capital·FIA, 9/17~10/12 지수 자료 멈춤 | 주문 직전에 시세 날짜가 오늘인지, 전일 대비 ±30% 안인지 확인 | 10시 측정은 매일 당일 시세였다(10/15~10/20 확인) | +| KIS 공식 예제 | 현금 주문을 새 거래코드와 거래소 코드로(취소는 이미 새 규격) | 요청 구성 테스트. 실전 전환 전에 모의 서버로 한 번 주문해 볼 것 | +| — | 실행 날짜·시각에 따라 실패하던 테스트 3건, 다른 테스트로 새던 환경변수 1건 | 전체 테스트 2,709건 통과 | + +계획기 재생은 이미 본 과거 자료로 다시 돌려 본 비교라 앞으로의 수익을 보장하지 않는다. 계획기를 바꾼 이유는 수익률이 아니라 '설계 비중에서 크게 벗어났는데 주문이 0건'인 상태를 없애려는 것이다. + +## 6. 반영하지 않은 것 + +- **새 매매 신호(팩터·모멘텀·AI 판단).** 공개되고 나면 초과수익이 사라진다는 것이 연구들의 공통된 결론이고, 우리가 넓은 종목군으로 검증했을 때도 통과한 신호가 없었다. AI 매매의 실거래 결과도 나빴다. +- **추세 필터 조정.** 지금 필터가 켜져 있다. 성과를 보고 검증 중인 규칙을 고치면 검증 자체가 의미를 잃는다. +- **적립 계좌 다자산 편입(미국 주식·채권·금).** 원화 기준 장기 백테스트는 유리하지만 2026년에는 통하지 않았고, 해외 ETF 과세(매매차익 15.4%)도 비용 모형에 맞춰 넣어야 한다. 실제 ETF 가격으로 따로 검증한 뒤 운영자가 정한다. +- **KODEX 200을 200TR ETF로 교체.** 1주 가격이 1/3이라 비중을 더 정확히 맞출 수 있고 보수도 낮다. 다만 검증 중인 계좌 구성이 바뀌고, TR ETF 안에서 재투자되는 분배금의 과세를 비용 모형에 넣어야 한다. 실전 전환 때 함께 정한다. +- **대형주 계좌의 현금 40%를 CD금리 ETF로 운용.** 지금 모의 장부의 현금은 이자가 0이다. CD금리 ETF(1년 +2.9%)에 두면 세전으로 연 1.1~1.3%p를 더 받는다(40% × 2.9%). 위험은 거의 그대로인 확실한 개선이지만, 실전 승인을 기다리는 계좌의 구성을 바꾸는 일이고 방어 자산을 손절 대상에서 빼는 처리도 필요하다(KOSPI가 크게 오르면 시장 대비 손절이 CD ETF를 '뒤처진 종목'으로 볼 수 있다). 운영자가 정한다. +- **같은 주식 비중의 비교지수.** 지금 주간 보고는 주식 100%인 KS11과 비교한다. 주식 60% 계좌가 급락장에서 지수를 이기는 건 현금 비중 덕분이다. 대형주 계좌는 'KODEX 200 60% + 현금 40%', 적립 계좌는 '47.5/47.5/5 고정 비중'과도 함께 비교하도록 바꾸는 것이 다음 순서다. + +## 7. 운영자가 할 일 + +1. 9·10월 적립금 기록. 장부에는 8/26 10만 원 한 건뿐이다(입금했다면 기록만 하면 되고, 안 했다면 어떻게 할지 정한다). +2. DART 인증키 재발급(공시 수집이 010 미등록 키로 실패 중), 이메일 알림 비밀번호 재설정. +3. 대형주 계좌 규칙 변경을 유지할지 결정. 되돌리려면 `config/baskets.yaml`에서 `stop_basis`, `account_drawdown_guard` 두 줄을 지운다. +4. 실전 전환 전에 KIS 모의 서버에서 새 주문 규격으로 1주 매수·매도를 한 번 해 본다. + +## 재현 + +```powershell +.\.venv\Scripts\python.exe tools/hold_policy_backtest.py --as-of 2026-10-20 --sensitivity +.\.venv\Scripts\python.exe tools/rebalance_review.py --baseline c6c0d98 --as-of 2026-10-20 --output output/rebalance_review_20261020.json +.\.venv\Scripts\python.exe tools/verify_policy_changes_20261020.py --source-db data/quant_trader.db --out-dir output/policy_20261020 +``` diff --git a/docs/PROJECT_GUIDE.md b/docs/PROJECT_GUIDE.md index c255852..0b794d1 100644 --- a/docs/PROJECT_GUIDE.md +++ b/docs/PROJECT_GUIDE.md @@ -5,6 +5,8 @@ > **최종 수정**: 2026-07-06 (weekly_report 모드, 사이클 관측성·집계 배치율 감시·성과 귀속 분해 반영 — 한 달 운영 리뷰 후속. 이전: 2026-06-10 guide/health/deploy_check) > **참고**: 전체 아키텍처·지표 공식·전략 상세·시스템 진단은 루트의 `quant_trader_design.md` 참고. +> **2026-10-20 추가**: [투자 방식 조사와 운용 개선](MARKET_RESEARCH_20261020.md). 바스켓 `risk:`에 `stop_basis`(absolute|relative)·`stop_benchmark`·`account_drawdown_guard`(block|delegate), `rebalance:`에 `planner: integer`(종목 5개 이하 정수 수량 조합 계획, `core/integer_allocation.py`)가 생겼다. 주문 직전에 시세 기준일·전일 대비 ±30%를 확인하고(`BasketRebalancer._quote_guard_reason`), 평가 지표에 `money_weighted_return_pct`가 들어간다. 근거: [대형주 손절·낙폭 차단 점검](HOLD_RISK_REVIEW_20261020.md). + > **2026-10-13 추가**: [시장 관찰·투자 가설·연구 후보 검증](INVESTMENT_RESEARCH_20261013.md). `tools/market_research.py`와 `tools/research_evidence.py`는 주문 경로 밖에서 연구 자료를 관리한다. `/api/research`는 저장 자료만 조회하며 `/api/research/refresh`는 시간 제한이 있는 별도 수집 프로세스를 실행한다. 새 canonical 승격에는 `--research-admission-artifact`로 후보·자본·코드·설정·기간·유니버스가 일치하는 연구 근거가 필요하다. --- diff --git a/reports/research/hold_policy_backtest.json b/reports/research/hold_policy_backtest.json new file mode 100644 index 0000000..af84650 --- /dev/null +++ b/reports/research/hold_policy_backtest.json @@ -0,0 +1,1164 @@ +{ + "as_of": "2026-10-20", + "windows": { + "2012-01-02": { + "symbols": [ + "005930", + "035420", + "005380", + "051910", + "005490", + "055550", + "035720", + "012330", + "105560" + ], + "benchmark": "069500", + "start": "2014-07-25", + "end": "2026-10-19", + "days": 2999, + "results": { + "static": { + "years": 12.24, + "cagr_pct": 5.58, + "period_return_pct": 94.34, + "vol_pct": 13.65, + "sharpe": 0.48, + "mdd_pct": -28.6, + "calmar": 0.2, + "worst_year_pct": -14.52, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 46.6, + "avg_exposure_pct": 60.6, + "turnover_per_year_pct": 20.2, + "final_value": 19433873, + "contributed": 10000000, + "profit": 9433873, + "yearly": { + "2014": -7.75, + "2015": -2.84, + "2016": 6.49, + "2017": 18.63, + "2018": -14.52, + "2019": 10.99, + "2020": 29.78, + "2021": 9.15, + "2022": -13.76, + "2023": 13.41, + "2024": -6.47, + "2025": 30.75, + "2026": 6.77 + }, + "label": "손절 없음 · 차단 없음", + "stop_events": 0, + "stop_realized_krw": 0, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": {}, + "min_stock_weight_pct": 13.2 + }, + "lock15": { + "years": 12.24, + "cagr_pct": 5.26, + "period_return_pct": 87.24, + "vol_pct": 13.58, + "sharpe": 0.46, + "mdd_pct": -27.63, + "calmar": 0.19, + "worst_year_pct": -14.52, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 46.6, + "avg_exposure_pct": 60.8, + "turnover_per_year_pct": 17.6, + "final_value": 18724107, + "contributed": 10000000, + "profit": 8724107, + "yearly": { + "2014": -7.75, + "2015": -2.84, + "2016": 6.49, + "2017": 18.63, + "2018": -14.52, + "2019": 10.99, + "2020": 29.84, + "2021": 7.28, + "2022": -14.39, + "2023": 12.18, + "2024": -5.27, + "2025": 28.18, + "2026": 7.3 + }, + "label": "계좌 낙폭 15% 매수 차단만", + "stop_events": 0, + "stop_realized_krw": 0, + "locked_days": 713, + "locked_days_pct": 23.8, + "blocked_buy_orders": 264, + "after_stop": {}, + "min_stock_weight_pct": 13.2 + }, + "abs25": { + "years": 12.24, + "cagr_pct": 5.51, + "period_return_pct": 92.85, + "vol_pct": 13.34, + "sharpe": 0.48, + "mdd_pct": -26.21, + "calmar": 0.21, + "worst_year_pct": -15.09, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 47.3, + "avg_exposure_pct": 60.7, + "turnover_per_year_pct": 42.9, + "final_value": 19285302, + "contributed": 10000000, + "profit": 9285302, + "yearly": { + "2014": -8.27, + "2015": -1.93, + "2016": 6.28, + "2017": 18.89, + "2018": -15.09, + "2019": 12.35, + "2020": 25.72, + "2021": 8.37, + "2022": -14.68, + "2023": 13.94, + "2024": -7.05, + "2025": 34.02, + "2026": 7.81 + }, + "label": "절대 손절 -25%만", + "stop_events": 17, + "stop_realized_krw": -3875434, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 16, + "mean_excess_pct": 2.79, + "rebound_share_pct": 62.5 + }, + "60d": { + "events": 16, + "mean_excess_pct": 1.25, + "rebound_share_pct": 43.8 + }, + "120d": { + "events": 16, + "mean_excess_pct": 2.74, + "rebound_share_pct": 50.0 + } + }, + "min_stock_weight_pct": 13.2 + }, + "abs25_lock15": { + "years": 12.24, + "cagr_pct": 4.12, + "period_return_pct": 63.82, + "vol_pct": 13.01, + "sharpe": 0.38, + "mdd_pct": -27.7, + "calmar": 0.15, + "worst_year_pct": -17.24, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 47.3, + "avg_exposure_pct": 58.8, + "turnover_per_year_pct": 41.9, + "final_value": 16382096, + "contributed": 10000000, + "profit": 6382096, + "yearly": { + "2014": -8.27, + "2015": -4.8, + "2016": 6.1, + "2017": 19.06, + "2018": -17.24, + "2019": 8.27, + "2020": 21.29, + "2021": 7.14, + "2022": -13.93, + "2023": 12.65, + "2024": -7.59, + "2025": 32.56, + "2026": 7.37 + }, + "label": "절대 손절 + 낙폭 차단 (운영 규칙)", + "stop_events": 20, + "stop_realized_krw": -4013910, + "locked_days": 1018, + "locked_days_pct": 33.9, + "blocked_buy_orders": 1106, + "after_stop": { + "20d": { + "events": 20, + "mean_excess_pct": 6.22, + "rebound_share_pct": 60.0 + }, + "60d": { + "events": 20, + "mean_excess_pct": 3.37, + "rebound_share_pct": 50.0 + }, + "120d": { + "events": 19, + "mean_excess_pct": 7.7, + "rebound_share_pct": 63.2 + } + }, + "min_stock_weight_pct": 13.2 + }, + "rel25": { + "years": 12.24, + "cagr_pct": 6.19, + "period_return_pct": 108.6, + "vol_pct": 13.22, + "sharpe": 0.53, + "mdd_pct": -27.99, + "calmar": 0.22, + "worst_year_pct": -15.21, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 48.0, + "avg_exposure_pct": 60.3, + "turnover_per_year_pct": 84.3, + "final_value": 20860248, + "contributed": 10000000, + "profit": 10860248, + "yearly": { + "2014": -8.21, + "2015": -1.44, + "2016": 6.83, + "2017": 16.78, + "2018": -15.21, + "2019": 11.63, + "2020": 33.36, + "2021": 7.18, + "2022": -13.29, + "2023": 13.49, + "2024": -6.19, + "2025": 29.13, + "2026": 14.6 + }, + "label": "시장 대비 상대 손절 -25%", + "stop_events": 32, + "stop_realized_krw": 673890, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 32, + "mean_excess_pct": -2.16, + "rebound_share_pct": 53.1 + }, + "60d": { + "events": 32, + "mean_excess_pct": -0.77, + "rebound_share_pct": 46.9 + }, + "120d": { + "events": 24, + "mean_excess_pct": -5.75, + "rebound_share_pct": 54.2 + } + }, + "min_stock_weight_pct": 7.7 + }, + "rel25_lock15": { + "years": 12.24, + "cagr_pct": 5.44, + "period_return_pct": 91.25, + "vol_pct": 12.24, + "sharpe": 0.51, + "mdd_pct": -28.97, + "calmar": 0.19, + "worst_year_pct": -16.68, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 47.3, + "avg_exposure_pct": 57.2, + "turnover_per_year_pct": 88.5, + "final_value": 19125500, + "contributed": 10000000, + "profit": 9125500, + "yearly": { + "2014": -8.21, + "2015": -1.39, + "2016": 7.38, + "2017": 16.67, + "2018": -15.81, + "2019": 10.32, + "2020": 27.33, + "2021": 7.73, + "2022": -16.68, + "2023": 12.6, + "2024": -4.32, + "2025": 30.66, + "2026": 12.86 + }, + "label": "상대 손절 + 낙폭 차단", + "stop_events": 35, + "stop_realized_krw": -987480, + "locked_days": 933, + "locked_days_pct": 31.1, + "blocked_buy_orders": 1712, + "after_stop": { + "20d": { + "events": 35, + "mean_excess_pct": -0.7, + "rebound_share_pct": 51.4 + }, + "60d": { + "events": 35, + "mean_excess_pct": 0.59, + "rebound_share_pct": 48.6 + }, + "120d": { + "events": 27, + "mean_excess_pct": -2.36, + "rebound_share_pct": 59.3 + } + }, + "min_stock_weight_pct": 6.8 + } + } + }, + "2021-12-01": { + "symbols": [ + "005930", + "035420", + "005380", + "051910", + "005490", + "055550", + "035720", + "012330", + "105560" + ], + "benchmark": "069500", + "start": "2021-12-01", + "end": "2026-10-19", + "days": 1192, + "results": { + "static": { + "years": 4.88, + "cagr_pct": 5.99, + "period_return_pct": 32.84, + "vol_pct": 16.28, + "sharpe": 0.45, + "mdd_pct": -20.86, + "calmar": 0.29, + "worst_year_pct": -14.62, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 49.2, + "avg_exposure_pct": 60.3, + "turnover_per_year_pct": 38.2, + "final_value": 13283863, + "contributed": 10000000, + "profit": 3283863, + "yearly": { + "2021": -1.13, + "2022": -14.62, + "2023": 15.19, + "2024": -5.12, + "2025": 33.02, + "2026": 8.23 + }, + "label": "손절 없음 · 차단 없음", + "stop_events": 0, + "stop_realized_krw": 0, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": {}, + "min_stock_weight_pct": 14.8 + }, + "lock15": { + "years": 4.88, + "cagr_pct": 4.95, + "period_return_pct": 26.61, + "vol_pct": 16.31, + "sharpe": 0.39, + "mdd_pct": -21.76, + "calmar": 0.23, + "worst_year_pct": -14.69, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 50.8, + "avg_exposure_pct": 60.3, + "turnover_per_year_pct": 31.8, + "final_value": 12661035, + "contributed": 10000000, + "profit": 2661035, + "yearly": { + "2021": -1.13, + "2022": -14.69, + "2023": 17.24, + "2024": -8.23, + "2025": 30.36, + "2026": 7.03 + }, + "label": "계좌 낙폭 15% 매수 차단만", + "stop_events": 0, + "stop_realized_krw": 0, + "locked_days": 69, + "locked_days_pct": 5.8, + "blocked_buy_orders": 5, + "after_stop": {}, + "min_stock_weight_pct": 14.8 + }, + "abs25": { + "years": 4.88, + "cagr_pct": 5.09, + "period_return_pct": 27.42, + "vol_pct": 16.3, + "sharpe": 0.4, + "mdd_pct": -21.53, + "calmar": 0.24, + "worst_year_pct": -17.31, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 47.5, + "avg_exposure_pct": 60.7, + "turnover_per_year_pct": 77.4, + "final_value": 12741525, + "contributed": 10000000, + "profit": 2741525, + "yearly": { + "2021": -1.13, + "2022": -17.31, + "2023": 15.76, + "2024": -6.07, + "2025": 32.19, + "2026": 8.43 + }, + "label": "절대 손절 -25%만", + "stop_events": 14, + "stop_realized_krw": -2486426, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 14, + "mean_excess_pct": -0.15, + "rebound_share_pct": 42.9 + }, + "60d": { + "events": 14, + "mean_excess_pct": 2.39, + "rebound_share_pct": 50.0 + }, + "120d": { + "events": 14, + "mean_excess_pct": -0.83, + "rebound_share_pct": 50.0 + } + }, + "min_stock_weight_pct": 14.8 + }, + "abs25_lock15": { + "years": 4.88, + "cagr_pct": 3.46, + "period_return_pct": 18.04, + "vol_pct": 16.03, + "sharpe": 0.3, + "mdd_pct": -22.26, + "calmar": 0.16, + "worst_year_pct": -17.5, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 50.8, + "avg_exposure_pct": 59.7, + "turnover_per_year_pct": 69.8, + "final_value": 11803554, + "contributed": 10000000, + "profit": 1803554, + "yearly": { + "2021": -1.13, + "2022": -17.5, + "2023": 10.41, + "2024": -5.3, + "2025": 31.58, + "2026": 5.18 + }, + "label": "절대 손절 + 낙폭 차단 (운영 규칙)", + "stop_events": 14, + "stop_realized_krw": -2511018, + "locked_days": 248, + "locked_days_pct": 20.8, + "blocked_buy_orders": 213, + "after_stop": { + "20d": { + "events": 14, + "mean_excess_pct": -2.28, + "rebound_share_pct": 28.6 + }, + "60d": { + "events": 14, + "mean_excess_pct": 3.25, + "rebound_share_pct": 57.1 + }, + "120d": { + "events": 14, + "mean_excess_pct": -1.65, + "rebound_share_pct": 50.0 + } + }, + "min_stock_weight_pct": 14.8 + }, + "rel25": { + "years": 4.88, + "cagr_pct": 6.2, + "period_return_pct": 34.11, + "vol_pct": 15.05, + "sharpe": 0.49, + "mdd_pct": -19.09, + "calmar": 0.32, + "worst_year_pct": -14.4, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 47.5, + "avg_exposure_pct": 59.9, + "turnover_per_year_pct": 146.5, + "final_value": 13410708, + "contributed": 10000000, + "profit": 3410708, + "yearly": { + "2021": -1.13, + "2022": -14.4, + "2023": 15.77, + "2024": -7.44, + "2025": 30.32, + "2026": 13.47 + }, + "label": "시장 대비 상대 손절 -25%", + "stop_events": 23, + "stop_realized_krw": 191615, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 23, + "mean_excess_pct": -5.38, + "rebound_share_pct": 34.8 + }, + "60d": { + "events": 23, + "mean_excess_pct": -3.26, + "rebound_share_pct": 39.1 + }, + "120d": { + "events": 15, + "mean_excess_pct": -15.89, + "rebound_share_pct": 46.7 + } + }, + "min_stock_weight_pct": 7.0 + }, + "rel25_lock15": { + "years": 4.88, + "cagr_pct": 6.2, + "period_return_pct": 34.11, + "vol_pct": 15.05, + "sharpe": 0.49, + "mdd_pct": -19.09, + "calmar": 0.32, + "worst_year_pct": -14.4, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 47.5, + "avg_exposure_pct": 59.9, + "turnover_per_year_pct": 146.5, + "final_value": 13410708, + "contributed": 10000000, + "profit": 3410708, + "yearly": { + "2021": -1.13, + "2022": -14.4, + "2023": 15.77, + "2024": -7.44, + "2025": 30.32, + "2026": 13.47 + }, + "label": "상대 손절 + 낙폭 차단", + "stop_events": 23, + "stop_realized_krw": 191615, + "locked_days": 48, + "locked_days_pct": 4.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 23, + "mean_excess_pct": -5.38, + "rebound_share_pct": 34.8 + }, + "60d": { + "events": 23, + "mean_excess_pct": -3.26, + "rebound_share_pct": 39.1 + }, + "120d": { + "events": 15, + "mean_excess_pct": -15.89, + "rebound_share_pct": 46.7 + } + }, + "min_stock_weight_pct": 7.0 + } + } + } + }, + "sensitivity": { + "2012-01-02": { + "symbols": [ + "005930", + "035420", + "005380", + "051910", + "005490", + "055550", + "035720", + "012330", + "105560" + ], + "benchmark": "069500", + "start": "2014-07-25", + "end": "2026-10-19", + "days": 2999, + "results": { + "static": { + "years": 12.24, + "cagr_pct": 5.58, + "period_return_pct": 94.34, + "vol_pct": 13.65, + "sharpe": 0.48, + "mdd_pct": -28.6, + "calmar": 0.2, + "worst_year_pct": -14.52, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 46.6, + "avg_exposure_pct": 60.6, + "turnover_per_year_pct": 20.2, + "final_value": 19433873, + "contributed": 10000000, + "profit": 9433873, + "yearly": { + "2014": -7.75, + "2015": -2.84, + "2016": 6.49, + "2017": 18.63, + "2018": -14.52, + "2019": 10.99, + "2020": 29.78, + "2021": 9.15, + "2022": -13.76, + "2023": 13.41, + "2024": -6.47, + "2025": 30.75, + "2026": 6.77 + }, + "label": "손절 없음 · 차단 없음", + "stop_events": 0, + "stop_realized_krw": 0, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": {}, + "min_stock_weight_pct": 13.2 + }, + "abs20": { + "years": 12.24, + "cagr_pct": 5.42, + "period_return_pct": 90.86, + "vol_pct": 13.38, + "sharpe": 0.47, + "mdd_pct": -25.0, + "calmar": 0.22, + "worst_year_pct": -14.52, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 45.9, + "avg_exposure_pct": 60.6, + "turnover_per_year_pct": 53.8, + "final_value": 19086102, + "contributed": 10000000, + "profit": 9086102, + "yearly": { + "2014": -8.93, + "2015": -5.52, + "2016": 7.98, + "2017": 19.01, + "2018": -14.19, + "2019": 12.43, + "2020": 25.83, + "2021": 9.25, + "2022": -14.52, + "2023": 17.25, + "2024": -7.95, + "2025": 32.48, + "2026": 6.48 + }, + "label": "절대 손절 -20%", + "stop_events": 24, + "stop_realized_krw": -4255777, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 24, + "mean_excess_pct": 5.86, + "rebound_share_pct": 66.7 + }, + "60d": { + "events": 24, + "mean_excess_pct": 4.45, + "rebound_share_pct": 58.3 + }, + "120d": { + "events": 23, + "mean_excess_pct": 0.81, + "rebound_share_pct": 47.8 + } + }, + "min_stock_weight_pct": 13.2 + }, + "abs30": { + "years": 12.24, + "cagr_pct": 5.43, + "period_return_pct": 91.03, + "vol_pct": 13.62, + "sharpe": 0.47, + "mdd_pct": -28.92, + "calmar": 0.19, + "worst_year_pct": -16.06, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 46.6, + "avg_exposure_pct": 61.3, + "turnover_per_year_pct": 34.1, + "final_value": 19102896, + "contributed": 10000000, + "profit": 9102896, + "yearly": { + "2014": -8.13, + "2015": -2.53, + "2016": 7.46, + "2017": 19.83, + "2018": -16.06, + "2019": 12.06, + "2020": 27.24, + "2021": 9.04, + "2022": -14.47, + "2023": 13.8, + "2024": -7.77, + "2025": 33.04, + "2026": 6.32 + }, + "label": "절대 손절 -30%", + "stop_events": 12, + "stop_realized_krw": -3055400, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 12, + "mean_excess_pct": 2.32, + "rebound_share_pct": 50.0 + }, + "60d": { + "events": 12, + "mean_excess_pct": 2.84, + "rebound_share_pct": 66.7 + }, + "120d": { + "events": 12, + "mean_excess_pct": 8.28, + "rebound_share_pct": 50.0 + } + }, + "min_stock_weight_pct": 13.2 + }, + "rel20": { + "years": 12.24, + "cagr_pct": 5.36, + "period_return_pct": 89.54, + "vol_pct": 12.91, + "sharpe": 0.48, + "mdd_pct": -26.72, + "calmar": 0.2, + "worst_year_pct": -14.86, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 48.6, + "avg_exposure_pct": 59.7, + "turnover_per_year_pct": 100.7, + "final_value": 18953609, + "contributed": 10000000, + "profit": 8953609, + "yearly": { + "2014": -8.34, + "2015": -5.16, + "2016": 6.34, + "2017": 16.64, + "2018": -14.86, + "2019": 11.82, + "2020": 32.26, + "2021": 6.33, + "2022": -14.85, + "2023": 15.94, + "2024": -7.28, + "2025": 25.93, + "2026": 13.9 + }, + "label": "상대 손절 -20%", + "stop_events": 47, + "stop_realized_krw": -1347189, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 45, + "mean_excess_pct": -0.5, + "rebound_share_pct": 51.1 + }, + "60d": { + "events": 45, + "mean_excess_pct": -0.7, + "rebound_share_pct": 48.9 + }, + "120d": { + "events": 37, + "mean_excess_pct": -9.01, + "rebound_share_pct": 35.1 + } + }, + "min_stock_weight_pct": 13.2 + }, + "rel30": { + "years": 12.24, + "cagr_pct": 5.22, + "period_return_pct": 86.44, + "vol_pct": 13.0, + "sharpe": 0.47, + "mdd_pct": -28.49, + "calmar": 0.18, + "worst_year_pct": -15.34, + "losing_years": 5, + "total_years": 13, + "negative_months_pct": 47.3, + "avg_exposure_pct": 60.7, + "turnover_per_year_pct": 78.2, + "final_value": 18643783, + "contributed": 10000000, + "profit": 8643783, + "yearly": { + "2014": -8.04, + "2015": -1.03, + "2016": 7.02, + "2017": 15.65, + "2018": -15.34, + "2019": 12.37, + "2020": 29.85, + "2021": 5.41, + "2022": -15.16, + "2023": 12.52, + "2024": -7.08, + "2025": 31.79, + "2026": 8.72 + }, + "label": "상대 손절 -30%", + "stop_events": 27, + "stop_realized_krw": 51602, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 27, + "mean_excess_pct": 1.69, + "rebound_share_pct": 48.1 + }, + "60d": { + "events": 27, + "mean_excess_pct": 3.9, + "rebound_share_pct": 59.3 + }, + "120d": { + "events": 20, + "mean_excess_pct": -5.71, + "rebound_share_pct": 60.0 + } + }, + "min_stock_weight_pct": 13.2 + } + } + }, + "2021-12-01": { + "symbols": [ + "005930", + "035420", + "005380", + "051910", + "005490", + "055550", + "035720", + "012330", + "105560" + ], + "benchmark": "069500", + "start": "2021-12-01", + "end": "2026-10-19", + "days": 1192, + "results": { + "static": { + "years": 4.88, + "cagr_pct": 5.99, + "period_return_pct": 32.84, + "vol_pct": 16.28, + "sharpe": 0.45, + "mdd_pct": -20.86, + "calmar": 0.29, + "worst_year_pct": -14.62, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 49.2, + "avg_exposure_pct": 60.3, + "turnover_per_year_pct": 38.2, + "final_value": 13283863, + "contributed": 10000000, + "profit": 3283863, + "yearly": { + "2021": -1.13, + "2022": -14.62, + "2023": 15.19, + "2024": -5.12, + "2025": 33.02, + "2026": 8.23 + }, + "label": "손절 없음 · 차단 없음", + "stop_events": 0, + "stop_realized_krw": 0, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": {}, + "min_stock_weight_pct": 14.8 + }, + "abs20": { + "years": 4.88, + "cagr_pct": 5.49, + "period_return_pct": 29.84, + "vol_pct": 16.31, + "sharpe": 0.42, + "mdd_pct": -22.52, + "calmar": 0.24, + "worst_year_pct": -14.4, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 49.2, + "avg_exposure_pct": 60.7, + "turnover_per_year_pct": 89.8, + "final_value": 12983634, + "contributed": 10000000, + "profit": 2983634, + "yearly": { + "2021": -1.13, + "2022": -14.4, + "2023": 14.6, + "2024": -6.9, + "2025": 34.36, + "2026": 7.01 + }, + "label": "절대 손절 -20%", + "stop_events": 17, + "stop_realized_krw": -2599363, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 17, + "mean_excess_pct": -1.57, + "rebound_share_pct": 47.1 + }, + "60d": { + "events": 17, + "mean_excess_pct": -1.35, + "rebound_share_pct": 41.2 + }, + "120d": { + "events": 16, + "mean_excess_pct": -5.74, + "rebound_share_pct": 37.5 + } + }, + "min_stock_weight_pct": 14.8 + }, + "abs30": { + "years": 4.88, + "cagr_pct": 4.78, + "period_return_pct": 25.61, + "vol_pct": 16.51, + "sharpe": 0.37, + "mdd_pct": -21.42, + "calmar": 0.22, + "worst_year_pct": -17.13, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 47.5, + "avg_exposure_pct": 62.0, + "turnover_per_year_pct": 65.3, + "final_value": 12560644, + "contributed": 10000000, + "profit": 2560644, + "yearly": { + "2021": -1.13, + "2022": -17.13, + "2023": 15.65, + "2024": -7.69, + "2025": 33.14, + "2026": 7.85 + }, + "label": "절대 손절 -30%", + "stop_events": 11, + "stop_realized_krw": -2418732, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 11, + "mean_excess_pct": 1.84, + "rebound_share_pct": 63.6 + }, + "60d": { + "events": 11, + "mean_excess_pct": 6.37, + "rebound_share_pct": 54.5 + }, + "120d": { + "events": 11, + "mean_excess_pct": 2.98, + "rebound_share_pct": 54.5 + } + }, + "min_stock_weight_pct": 14.8 + }, + "rel20": { + "years": 4.88, + "cagr_pct": 6.07, + "period_return_pct": 33.32, + "vol_pct": 14.6, + "sharpe": 0.49, + "mdd_pct": -19.3, + "calmar": 0.31, + "worst_year_pct": -14.98, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 44.1, + "avg_exposure_pct": 58.1, + "turnover_per_year_pct": 186.5, + "final_value": 13332296, + "contributed": 10000000, + "profit": 3332296, + "yearly": { + "2021": -1.13, + "2022": -14.98, + "2023": 18.9, + "2024": -5.35, + "2025": 26.07, + "2026": 11.78 + }, + "label": "상대 손절 -20%", + "stop_events": 33, + "stop_realized_krw": -467472, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 32, + "mean_excess_pct": -7.07, + "rebound_share_pct": 31.2 + }, + "60d": { + "events": 32, + "mean_excess_pct": -8.18, + "rebound_share_pct": 31.2 + }, + "120d": { + "events": 24, + "mean_excess_pct": -19.44, + "rebound_share_pct": 29.2 + } + }, + "min_stock_weight_pct": 14.8 + }, + "rel30": { + "years": 4.88, + "cagr_pct": 6.69, + "period_return_pct": 37.16, + "vol_pct": 15.43, + "sharpe": 0.51, + "mdd_pct": -19.08, + "calmar": 0.35, + "worst_year_pct": -14.81, + "losing_years": 3, + "total_years": 6, + "negative_months_pct": 45.8, + "avg_exposure_pct": 59.7, + "turnover_per_year_pct": 119.4, + "final_value": 13715659, + "contributed": 10000000, + "profit": 3715659, + "yearly": { + "2021": -1.13, + "2022": -14.81, + "2023": 18.39, + "2024": -3.87, + "2025": 30.44, + "2026": 9.68 + }, + "label": "상대 손절 -30%", + "stop_events": 18, + "stop_realized_krw": 785350, + "locked_days": 0, + "locked_days_pct": 0.0, + "blocked_buy_orders": 0, + "after_stop": { + "20d": { + "events": 18, + "mean_excess_pct": -6.12, + "rebound_share_pct": 22.2 + }, + "60d": { + "events": 18, + "mean_excess_pct": -5.23, + "rebound_share_pct": 38.9 + }, + "120d": { + "events": 11, + "mean_excess_pct": -18.38, + "rebound_share_pct": 45.5 + } + }, + "min_stock_weight_pct": 13.8 + } + } + } + } +} \ No newline at end of file diff --git a/reports/research/hold_policy_backtest.md b/reports/research/hold_policy_backtest.md new file mode 100644 index 0000000..61bf234 --- /dev/null +++ b/reports/research/hold_policy_backtest.md @@ -0,0 +1,51 @@ +# 대형주 계좌 손절·낙폭 차단 규칙 비교 + +### 2012-01-02부터 + +- 기간: 2014-07-25 → 2026-10-19 (2999거래일), 종목 9개, 상대 손절 기준 069500 + +| 규칙 | 연수익률 | 최대낙폭 | 샤프(금리 0%) | 칼마 | 최악 연도 | 평균 주식 비중 | 손절 횟수 | 손절 실현손익 | 매수 차단일 비율 | 손절 60일 뒤 시장 대비 | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 손절 없음 · 차단 없음 | +5.58% | -28.6% | 0.48 | 0.20 | -14.5% | 61% | 0 | +0원 | 0% | — | +| 계좌 낙폭 15% 매수 차단만 | +5.26% | -27.6% | 0.46 | 0.19 | -14.5% | 61% | 0 | +0원 | 24% | — | +| 절대 손절 -25%만 | +5.51% | -26.2% | 0.48 | 0.21 | -15.1% | 61% | 17 | -3,875,434원 | 0% | +1.2%p (16건) | +| 절대 손절 + 낙폭 차단 (운영 규칙) | +4.12% | -27.7% | 0.38 | 0.15 | -17.2% | 59% | 20 | -4,013,910원 | 34% | +3.4%p (20건) | +| 시장 대비 상대 손절 -25% | +6.19% | -28.0% | 0.53 | 0.22 | -15.2% | 60% | 32 | +673,890원 | 0% | -0.8%p (32건) | +| 상대 손절 + 낙폭 차단 | +5.44% | -29.0% | 0.51 | 0.19 | -16.7% | 57% | 35 | -987,480원 | 31% | +0.6%p (35건) | + +### 2012-01-02부터 · 임계값 점검(운영값 선택에 쓰지 않음) + +- 기간: 2014-07-25 → 2026-10-19 (2999거래일), 종목 9개, 상대 손절 기준 069500 + +| 규칙 | 연수익률 | 최대낙폭 | 샤프(금리 0%) | 칼마 | 최악 연도 | 평균 주식 비중 | 손절 횟수 | 손절 실현손익 | 매수 차단일 비율 | 손절 60일 뒤 시장 대비 | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 손절 없음 · 차단 없음 | +5.58% | -28.6% | 0.48 | 0.20 | -14.5% | 61% | 0 | +0원 | 0% | — | +| 절대 손절 -20% | +5.42% | -25.0% | 0.47 | 0.22 | -14.5% | 61% | 24 | -4,255,777원 | 0% | +4.5%p (24건) | +| 절대 손절 -30% | +5.43% | -28.9% | 0.47 | 0.19 | -16.1% | 61% | 12 | -3,055,400원 | 0% | +2.8%p (12건) | +| 상대 손절 -20% | +5.36% | -26.7% | 0.48 | 0.20 | -14.9% | 60% | 47 | -1,347,189원 | 0% | -0.7%p (45건) | +| 상대 손절 -30% | +5.22% | -28.5% | 0.47 | 0.18 | -15.3% | 61% | 27 | +51,602원 | 0% | +3.9%p (27건) | + +### 2021-12-01부터 + +- 기간: 2021-12-01 → 2026-10-19 (1192거래일), 종목 9개, 상대 손절 기준 069500 + +| 규칙 | 연수익률 | 최대낙폭 | 샤프(금리 0%) | 칼마 | 최악 연도 | 평균 주식 비중 | 손절 횟수 | 손절 실현손익 | 매수 차단일 비율 | 손절 60일 뒤 시장 대비 | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 손절 없음 · 차단 없음 | +5.99% | -20.9% | 0.45 | 0.29 | -14.6% | 60% | 0 | +0원 | 0% | — | +| 계좌 낙폭 15% 매수 차단만 | +4.95% | -21.8% | 0.39 | 0.23 | -14.7% | 60% | 0 | +0원 | 6% | — | +| 절대 손절 -25%만 | +5.09% | -21.5% | 0.40 | 0.24 | -17.3% | 61% | 14 | -2,486,426원 | 0% | +2.4%p (14건) | +| 절대 손절 + 낙폭 차단 (운영 규칙) | +3.46% | -22.3% | 0.30 | 0.16 | -17.5% | 60% | 14 | -2,511,018원 | 21% | +3.2%p (14건) | +| 시장 대비 상대 손절 -25% | +6.20% | -19.1% | 0.49 | 0.32 | -14.4% | 60% | 23 | +191,615원 | 0% | -3.3%p (23건) | +| 상대 손절 + 낙폭 차단 | +6.20% | -19.1% | 0.49 | 0.32 | -14.4% | 60% | 23 | +191,615원 | 4% | -3.3%p (23건) | + +### 2021-12-01부터 · 임계값 점검(운영값 선택에 쓰지 않음) + +- 기간: 2021-12-01 → 2026-10-19 (1192거래일), 종목 9개, 상대 손절 기준 069500 + +| 규칙 | 연수익률 | 최대낙폭 | 샤프(금리 0%) | 칼마 | 최악 연도 | 평균 주식 비중 | 손절 횟수 | 손절 실현손익 | 매수 차단일 비율 | 손절 60일 뒤 시장 대비 | +|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:| +| 손절 없음 · 차단 없음 | +5.99% | -20.9% | 0.45 | 0.29 | -14.6% | 60% | 0 | +0원 | 0% | — | +| 절대 손절 -20% | +5.49% | -22.5% | 0.42 | 0.24 | -14.4% | 61% | 17 | -2,599,363원 | 0% | -1.4%p (17건) | +| 절대 손절 -30% | +4.78% | -21.4% | 0.37 | 0.22 | -17.1% | 62% | 11 | -2,418,732원 | 0% | +6.4%p (11건) | +| 상대 손절 -20% | +6.07% | -19.3% | 0.49 | 0.31 | -15.0% | 58% | 33 | -467,472원 | 0% | -8.2%p (32건) | +| 상대 손절 -30% | +6.69% | -19.1% | 0.51 | 0.35 | -14.8% | 60% | 18 | +785,350원 | 0% | -5.2%p (18건) | diff --git a/tests/test_audit_cycle.py b/tests/test_audit_cycle.py index 7a9ea02..0eb678c 100644 --- a/tests/test_audit_cycle.py +++ b/tests/test_audit_cycle.py @@ -117,14 +117,12 @@ def test_backfill_is_wired_with_resolved_capital_before_status(cycle, monkeypatc main.run_rebalance(_args()) - from datetime import timedelta - from zoneinfo import ZoneInfo - assert "backfill" in calls, "결측 보충이 호출되지 않았다" assert calls["backfill"]["initial_capital"] == 300_000.0 assert calls["backfill"]["account_key"] == "basket_rebalance:kr_test" - kst_today = datetime.now(ZoneInfo("Asia/Seoul")).date() - assert calls["backfill"]["until"] == kst_today - timedelta(days=1) + # 사이클은 고정해 둔 시계(2026-10-13 10:10)로 '오늘'을 정한다. 실제 날짜와 비교하면 + # 이 테스트를 만든 날에만 통과한다(10/14부터 매일 실패했다). + assert calls["backfill"]["until"] == date(2026, 10, 12) # 오버레이 판단(상태 보고에서 계산·캐시)보다 먼저 보충돼야 한다 assert order.index("backfill") < order.index("status") diff --git a/tests/test_audit_runtime_post_market.py b/tests/test_audit_runtime_post_market.py index 28944a2..b4ee7ef 100644 --- a/tests/test_audit_runtime_post_market.py +++ b/tests/test_audit_runtime_post_market.py @@ -6,6 +6,7 @@ DB는 conftest가 격리한 임시 DB를 쓴다. """ +import time from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import MagicMock @@ -68,6 +69,9 @@ def _seed_snapshots(account, prev_total, last_total, deposit): ) if deposit: # 직전 스냅샷 측정(created_at) 뒤의 입금 — TWR 분모에 더해 중화돼야 한다. + # 같은 시계 틱 안에서 기록하면 입금 시각이 직전 측정 시각과 같아져 (after, until] + # 구간에서 빠진다(전체 실행에서 가끔 실패). 시계가 넘어간 뒤 입금한다. + time.sleep(0.05) record_cash_flow(deposit, account_key=account, note="audit deposit", mode="paper") diff --git a/tests/test_config_auto_entry.py b/tests/test_config_auto_entry.py index 872fa2a..3dc0a06 100644 --- a/tests/test_config_auto_entry.py +++ b/tests/test_config_auto_entry.py @@ -185,9 +185,12 @@ def test_fallback_loads_simple_env_file_without_overriding_existing(self, tmp_pa ]), encoding="utf-8", ) - monkeypatch.delenv("DISCORD_WEBHOOK_URL", raising=False) - monkeypatch.delenv("KIS_ACCOUNT_NO", raising=False) - monkeypatch.delenv("QUOTED_VALUE", raising=False) + # 없던 변수를 delenv(raising=False)만 하면 monkeypatch가 아무것도 기록하지 않아, + # 아래 로더가 넣은 값이 테스트가 끝나도 남는다. 그러면 뒤 테스트의 디스코드 알림이 + # discord.test로 발송을 시도해 실패했다. 먼저 setenv로 원래 상태를 기록해 둔다. + for name in ("DISCORD_WEBHOOK_URL", "KIS_ACCOUNT_NO", "QUOTED_VALUE"): + monkeypatch.setenv(name, "") + monkeypatch.delenv(name) monkeypatch.setenv("EXISTING_VALUE", "from-env") _load_env_file_fallback(env_path) diff --git a/tests/test_critical_fixes.py b/tests/test_critical_fixes.py index b5228f8..f881b64 100644 --- a/tests/test_critical_fixes.py +++ b/tests/test_critical_fixes.py @@ -190,8 +190,10 @@ def save_daily_nav_snapshot(self): notifier = SimpleNamespace(send_message=MagicMock()) monkeypatch.setattr(main_mod.Config, "get", lambda: config) monkeypatch.setattr(main_mod, "_check_live_readiness_gate", gate) - # 휴장일에 테스트가 돌아도 매매 경로를 타도록 거래일로 고정 + # 휴장일·장 마감 뒤에 테스트가 돌아도 매매 경로를 타도록 거래일·정규장으로 고정 + # (정규장 검사가 추가된 뒤로 15:30 이후 실행에서만 실패했다) monkeypatch.setattr(main_mod, "_market_closed_today", lambda config, now: False) + monkeypatch.setattr(main_mod, "_basket_session_open", lambda config, now: True) monkeypatch.setattr("core.basket_rebalancer.BasketRebalancer", FakeRebalancer) monkeypatch.setattr("core.notifier.Notifier", lambda cfg: notifier) monkeypatch.setenv("ENABLE_LIVE_TRADING", "true") diff --git a/tests/test_hold_policy_backtest.py b/tests/test_hold_policy_backtest.py new file mode 100644 index 0000000..3674b42 --- /dev/null +++ b/tests/test_hold_policy_backtest.py @@ -0,0 +1,61 @@ +"""tools/hold_policy_backtest.py — 규칙 조합 비교 모형이 의도대로 움직이는지 합성 가격으로 확인한다.""" + +import pandas as pd +import pytest + +from tools.hold_policy_backtest import HoldPolicy, SimConfig, simulate_hold + +DAYS = pd.bdate_range("2024-01-02", periods=160) + + +def _panel(market_path, laggard_path): + """세 종목: 둘은 시장과 같이, 하나는 laggard_path로 움직인다.""" + return pd.DataFrame({ + "A": [100_000 * m for m in market_path], + "B": [50_000 * m for m in market_path], + "C": [80_000 * x for x in laggard_path], + }, index=DAYS) + + +def _path(drop_at, depth): + return [1.0 if i < drop_at else 1.0 - depth for i in range(len(DAYS))] + + +CFG = SimConfig(initial=3_000_000, min_trade=50_000, max_turnover=1.0) + + +def test_market_wide_drop_triggers_absolute_but_not_relative_stop(): + market = _path(40, 0.30) + panel = _panel(market, market) + bench = pd.Series([100.0 * m for m in market], index=DAYS) + _, _, absolute = simulate_hold(panel, bench, HoldPolicy("abs", "", stop="absolute"), CFG) + _, _, relative = simulate_hold(panel, bench, HoldPolicy("rel", "", stop="relative"), CFG) + assert {e["symbol"] for e in absolute} == {"A", "B", "C"} + assert relative == [] + + +def test_idiosyncratic_drop_triggers_relative_stop_and_cooldown_blocks_rebuy(): + market = [1.0] * len(DAYS) + panel = _panel(market, _path(40, 0.40)) + bench = pd.Series(100.0, index=DAYS) + frame, _, events = simulate_hold(panel, bench, HoldPolicy("rel", "", stop="relative"), CFG) + assert [e["symbol"] for e in events] == ["C"] + # 60일 재매수 차단: 손절 다음 날부터 60일 동안 C를 다시 사지 않는다 + stop_day = events[0]["date"] + after = frame.loc[stop_day:] + assert (after["held_names"].iloc[1:40] == 2).all() + + +def test_account_lock_blocks_buys_only_while_in_drawdown(): + market = _path(30, 0.35) + panel = _panel(market, market) + bench = pd.Series([100.0 * m for m in market], index=DAYS) + _, extra_lock, _ = simulate_hold(panel, bench, HoldPolicy("lock", "", account_lock=True), CFG) + _, extra_free, _ = simulate_hold(panel, bench, HoldPolicy("free", ""), CFG) + assert extra_lock["locked_days"] > 0 + assert extra_free["locked_days"] == 0 + + +def test_empty_price_panel_raises_instead_of_returning_empty_result(): + with pytest.raises(Exception): + simulate_hold(pd.DataFrame(), pd.Series(dtype=float), HoldPolicy("static", ""), CFG) diff --git a/tests/test_hold_relative_stop.py b/tests/test_hold_relative_stop.py new file mode 100644 index 0000000..8eddae4 --- /dev/null +++ b/tests/test_hold_relative_stop.py @@ -0,0 +1,269 @@ +"""대형주 관찰 계좌의 시장 대비 손절과 계좌 낙폭 가드 위임 (2026-10-20). + +배경: 평균 매입가 대비 -25% 손절은 시장 전체가 빠질 때 연달아 걸렸다(9/21 현대모비스, +10/15 삼성전자·현대차 — 그 사이 KODEX 200도 -30% 가까이 하락). 그렇게 판 현금은 전역 +계좌 낙폭 15% 가드에 막혀 다시 들어가지 못했고, 주식 비중이 설계 60%에서 45%로 내려앉았다. +""" + +from datetime import date, datetime +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pandas as pd +import pytest + +from core.basket_risk import ( + basket_risk_levels, + delegates_account_drawdown_guard, + evaluate_basket_stops, + relative_stop_basis, + stop_basis, +) + +REL = {"risk": {"stop_loss_pct": 0.25, "stop_basis": "relative", "stop_benchmark": "069500"}} + + +def _pos(symbol, avg_price, quantity=1): + return SimpleNamespace(symbol=symbol, avg_price=avg_price, quantity=quantity, highest_price=0) + + +def _trade(symbol, action, quantity, when): + return SimpleNamespace(symbol=symbol, action=action, quantity=quantity, executed_at=when) + + +# ------------------------------------------------------------------ 설정 해석 + +def test_stop_basis_defaults_to_absolute_and_unknown_falls_back(): + assert stop_basis({"risk": {"stop_loss_pct": 0.25}}) == ("absolute", None) + assert stop_basis(REL) == ("relative", "069500") + assert stop_basis({"risk": {"stop_basis": "relative"}}) == ("relative", "069500") + assert stop_basis({"risk": {"stop_basis": "relatve"}}) == ("absolute", None) + + +def test_account_guard_delegation_sources(): + assert delegates_account_drawdown_guard({}) is False + assert delegates_account_drawdown_guard({"risk": {"account_drawdown_guard": "block"}}) is False + assert delegates_account_drawdown_guard({"risk": {"account_drawdown_guard": "delegate"}}) is True + assert delegates_account_drawdown_guard({"overlays": {"drawdown_guard": {"enabled": True}}}) is True + # 오타는 기존 동작(차단)으로 — 조용히 가드를 끄면 안 된다 + assert delegates_account_drawdown_guard({"risk": {"account_drawdown_guard": "delegat"}}) is False + + +def test_relative_basis_records_no_fixed_stop_price(): + """상대 손절은 시장이 움직이면 손절 가격도 움직인다 — 고정 가격을 장부에 적지 않는다.""" + levels = basket_risk_levels(REL, 300_000) + assert levels["stop_loss_price"] is None + assert basket_risk_levels({"risk": {"stop_loss_pct": 0.25}}, 300_000)["stop_loss_price"] == 225_000 + + +# ------------------------------------------------------------- 매입 시점 기준 + +def test_relative_basis_is_quantity_weighted_like_average_price(): + closes = {date(2026, 6, 10): 120_000, date(2026, 9, 21): 100_000} + trades = [ + _trade("005930", "BUY", 2, datetime(2026, 6, 10, 10, 1)), + _trade("005930", "BUY", 1, datetime(2026, 9, 21, 10, 8)), + ] + basis = relative_stop_basis(trades, closes) + assert basis["005930"]["quantity"] == 3 + assert basis["005930"]["basis"] == pytest.approx((120_000 * 2 + 100_000) / 3) + + +def test_full_sell_starts_a_new_holding_period(): + closes = {date(2026, 6, 10): 120_000, date(2026, 8, 7): 110_000, date(2026, 8, 8): 105_000} + trades = [ + _trade("005380", "BUY", 1, datetime(2026, 6, 10, 10)), + _trade("005380", "SELL", 1, datetime(2026, 8, 7, 10)), + _trade("005380", "BUY", 1, datetime(2026, 8, 8, 10)), + ] + assert relative_stop_basis(trades, closes)["005380"]["basis"] == pytest.approx(105_000) + + +def test_partial_sell_keeps_basis_and_missing_close_voids_it(): + closes = {date(2026, 6, 10): 120_000} + trades = [ + _trade("055550", "BUY", 4, datetime(2026, 6, 10, 10)), + _trade("055550", "SELL", 1, datetime(2026, 7, 1, 10)), + _trade("035720", "BUY", 3, datetime(2026, 1, 5, 10)), # 종가 자료 없음 + ] + basis = relative_stop_basis(trades, closes) + assert basis["055550"] == {"basis": pytest.approx(120_000), "quantity": 3, "missing": []} + assert basis["035720"]["basis"] is None + assert basis["035720"]["missing"] == ["2026-01-05"] + + +def test_basis_uses_previous_close_within_a_week_only(): + closes = {date(2026, 10, 2): 100_000} + trades = [_trade("012330", "BUY", 1, datetime(2026, 10, 6, 10))] # 연휴 뒤 첫 거래일 + assert relative_stop_basis(trades, closes)["012330"]["basis"] == pytest.approx(100_000) + far = [_trade("012330", "BUY", 1, datetime(2026, 10, 20, 10))] + assert relative_stop_basis(far, closes)["012330"]["basis"] is None + + +# ------------------------------------------------------------------ 손절 판단 + +def test_market_wide_drop_does_not_trigger_relative_stop(): + """2026-10-15 재현: 삼성전자 -27%였지만 같은 기간 KODEX 200도 -30% — 시장보다 낫다.""" + ctx = {"benchmark": "069500", "price": 86_755, "basis": {"005930": 123_546}} + hits = evaluate_basket_stops(REL, [_pos("005930", 291_667, 3)], {"005930": 213_000}, ctx) + assert hits == [] + # 같은 상황을 절대 기준으로 재면 손절이 걸린다(바뀐 동작의 근거) + assert evaluate_basket_stops({"risk": {"stop_loss_pct": 0.25}}, [_pos("005930", 291_667, 3)], + {"005930": 213_000}) + + +def test_idiosyncratic_drop_triggers_relative_stop_even_in_flat_market(): + ctx = {"benchmark": "069500", "price": 100_000, "basis": {"035720": 100_000}} + hits = evaluate_basket_stops(REL, [_pos("035720", 40_000, 10)], {"035720": 29_000}, ctx) + assert [h["action"] for h in hits] == ["STOP_LOSS"] + assert hits[0]["basis"] == "relative" + assert hits[0]["relative_return"] == pytest.approx(-0.275) + assert "시장 대비 손절" in hits[0]["reason"] + # 지금 시장 수준에서 걸리는 가격(참고): 40,000 × 0.75 × 1.0 + assert hits[0]["level"] == 30_000 + + +def test_relative_stop_can_fire_when_stock_lags_a_rising_market(): + """시장이 +40%일 때 제자리인 종목은 시장 대비 -29% — 포트폴리오를 끌어내리는 종목이다.""" + ctx = {"benchmark": "069500", "price": 140_000, "basis": {"051910": 100_000}} + hits = evaluate_basket_stops(REL, [_pos("051910", 300_000)], {"051910": 300_000}, ctx) + assert hits and hits[0]["relative_return"] == pytest.approx(1 / 1.4 - 1, abs=1e-4) + + +def test_missing_current_benchmark_defers_decision_instead_of_absolute_sell(): + """지금 시장 수준을 모르면 판단을 미룬다 — 절대 기준으로 팔면 자료 장애가 시장 전체 + 하락 손절을 다시 만든다.""" + ctx = {"benchmark": "069500", "price": None, "basis": {"005930": 123_546}} + assert evaluate_basket_stops(REL, [_pos("005930", 291_667, 3)], {"005930": 150_000}, ctx) == [] + assert evaluate_basket_stops(REL, [_pos("005930", 291_667, 3)], {"005930": 150_000}, None) == [] + + +def test_missing_entry_basis_falls_back_to_absolute_for_that_symbol_only(): + ctx = {"benchmark": "069500", "price": 86_755, "basis": {"005930": 123_546}} + positions = [_pos("005930", 291_667, 3), _pos("035420", 229_500, 4)] + hits = evaluate_basket_stops(REL, positions, {"005930": 213_000, "035420": 160_000}, ctx) + assert [h["symbol"] for h in hits] == ["035420"] + assert hits[0]["basis"] == "absolute" + assert "절대 기준으로 판단" in hits[0]["reason"] + + +# ------------------------------------------------------------- 리밸런서 연결 + +class _Collector: + def __init__(self, frame): + self.frame = frame + self.calls = [] + + def fetch_korean_stock(self, symbol, start, end): + self.calls.append((symbol, start, end)) + return self.frame + + +def _rebalancer(basket, monkeypatch, positions, trades, frame, snapshot=None): + from core.basket_rebalancer import BasketRebalancer + + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.basket_name = "hold" + rb.basket = basket + rb.account_key = "basket_rebalance:hold" + rb.config = MagicMock() + rb.config.trading = {"mode": "paper"} + rb.data_collector = _Collector(frame) + rb._market_snapshot = snapshot or {} + monkeypatch.setattr("core.basket_rebalancer.get_all_positions", lambda **kw: positions) + monkeypatch.setattr("database.repositories.get_trade_history", lambda **kw: list(trades)) + events = [] + monkeypatch.setattr( + "core.cycle_observability.record_event_once_per_day", + lambda *a, **k: events.append((a, k)), + ) + return rb, events + + +def _bench_frame(): + return pd.DataFrame({ + "date": pd.to_datetime(["2026-06-10", "2026-10-19", "2026-10-20"]), + "close": [123_546.0, 88_000.0, 86_755.0], + }) + + +def test_plan_risk_exits_uses_market_relative_context(monkeypatch): + positions = [_pos("005930", 291_667, 3), _pos("035720", 37_262.5, 18)] + trades = [ + _trade("005930", "BUY", 3, datetime(2026, 6, 10, 10)), + _trade("035720", "BUY", 18, datetime(2026, 6, 10, 10)), + ] + snapshot = {"005930": {"quote_date": "2026-10-20"}, "035720": {"quote_date": "2026-10-20"}} + rb, events = _rebalancer(REL, monkeypatch, positions, trades, _bench_frame(), snapshot) + + # 삼성전자 -27%(시장 -30%) → 유지, 카카오 -47%(시장 대비 -24%) → 유지 + assert rb.plan_risk_exits(prices={"005930": 213_000, "035720": 19_800}) == [] + # 카카오가 시장보다 25% 넘게 더 빠지면 판다 + orders = rb.plan_risk_exits(prices={"005930": 213_000, "035720": 18_000}) + assert [(o.symbol, o.action, o.quantity) for o in orders] == [("035720", "SELL", 18)] + assert "RISK_EXIT STOP_LOSS" in orders[0].reason + assert events == [] + + +def test_stale_benchmark_defers_relative_stops_and_records_issue(monkeypatch): + positions = [_pos("005930", 291_667, 3)] + trades = [_trade("005930", "BUY", 3, datetime(2026, 6, 10, 10))] + snapshot = {"005930": {"quote_date": "2026-10-21"}} # 시세는 하루 더 새 것 + rb, events = _rebalancer(REL, monkeypatch, positions, trades, _bench_frame(), snapshot) + + assert rb.plan_risk_exits(prices={"005930": 100_000}) == [] + assert events and "판단 보류" in events[0][0][1] + + +def test_ledger_mismatch_falls_back_to_absolute_for_that_symbol(monkeypatch): + positions = [_pos("005930", 291_667, 4)] # 장부 4주, 매수 기록은 3주 + trades = [_trade("005930", "BUY", 3, datetime(2026, 6, 10, 10))] + snapshot = {"005930": {"quote_date": "2026-10-20"}} + rb, events = _rebalancer(REL, monkeypatch, positions, trades, _bench_frame(), snapshot) + + orders = rb.plan_risk_exits(prices={"005930": 213_000}) + assert [o.symbol for o in orders] == ["005930"] + assert "절대 기준" in orders[0].reason + assert events and "보유 수량이 달라" in events[0][0][1] + + +def test_absolute_basket_does_not_fetch_benchmark(monkeypatch): + positions = [_pos("005930", 291_667, 3)] + rb, _ = _rebalancer({"risk": {"stop_loss_pct": 0.25}}, monkeypatch, positions, [], _bench_frame()) + orders = rb.plan_risk_exits(prices={"005930": 213_000}) + assert [o.symbol for o in orders] == ["005930"] + assert rb.data_collector.calls == [] + + +def test_rebalancer_delegation_follows_basket_policy(): + from core.basket_rebalancer import BasketRebalancer + + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.basket = {"risk": {"account_drawdown_guard": "delegate"}} + assert rb._has_drawdown_rule() is True + rb.basket = {"risk": {"stop_loss_pct": 0.25}} + assert rb._has_drawdown_rule() is False + + +def test_entry_status_is_not_blocked_by_global_mdd_when_delegated(): + from core.basket_observability import snapshot_entry_status + + snap = {"created_at": "2026-10-20 10:11", "mdd": 18.3, "daily_return": 0.5} + limits = {"drawdown": {"max_portfolio_mdd": 0.15, "max_daily_loss": 0.03}} + blocked = snapshot_entry_status(snap, {"risk": {}}, limits) + assert blocked["status"] == "BLOCKED" + delegated = snapshot_entry_status(snap, {"risk": {"account_drawdown_guard": "delegate"}}, limits) + assert delegated["status"] != "BLOCKED" + + +# ----------------------------------------------------------- 운영 설정 고정 + +def test_shipped_hold_basket_uses_relative_stop_and_delegation(): + """운영 설정에서 이 결정이 조용히 되돌아가지 않게 고정한다(근거: HOLD_RISK_REVIEW_20261020).""" + from core.basket_rebalancer import BasketRebalancer + + cfg = BasketRebalancer._load_baskets_config()["kr_diversified_hold"] + assert stop_basis(cfg) == ("relative", "069500") + assert delegates_account_drawdown_guard(cfg) is True + assert float(cfg["risk"]["stop_loss_pct"]) == 0.25 + assert int(cfg["risk"]["reentry_cooldown_days"]) == 60 diff --git a/tests/test_integer_allocation.py b/tests/test_integer_allocation.py new file mode 100644 index 0000000..bdb4256 --- /dev/null +++ b/tests/test_integer_allocation.py @@ -0,0 +1,199 @@ +"""소액 ETF 계좌의 정수 주 조합 계획 (2026-10-20). + +10/12 적립 계좌: KODEX 200 1주(101,605원)·CD ETF 4주(58,195원)·현금 45,707원. +목표는 주식 47.5%·CD 47.5%인데 주식이 26.7%였다. 종목별로 부족분(약 7.9만 원)과 +초과분(약 5.2만 원)을 1주 가격으로 내림하면 둘 다 0주라 계획이 0건이었다. +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from core.integer_allocation import choose_integer_holdings + +KODEX, CD = "069500", "357870" + + +def _plan(**over): + args = dict( + total_value=380_092.0, cash=45_707.0, + prices={KODEX: 101_605.0, CD: 58_195.0}, + current={KODEX: 1, CD: 4}, + targets={KODEX: 0.475, CD: 0.475}, + min_cash_ratio=0.05, min_trade=50_000.0, max_turnover=0.6, + defensive_symbol=CD, + ) + args.update(over) + return choose_integer_holdings(**args) + + +def test_swaps_defensive_shares_into_equity_when_rounding_blocks_both_sides(): + plan = _plan() + assert plan.trades == {KODEX: 1, CD: -2} + assert plan.weights_after[KODEX] == pytest.approx(203_210 / 380_092) + assert plan.loss_after < plan.loss_before + assert plan.cash_after >= 0.05 * 380_092 + + +def test_keeps_holdings_when_current_combination_is_closest(): + """10/20 재현: 추세 필터로 주식 목표 23.75% — KODEX 1주가 정확히 목표, CD 1주 더 사면 현금 부족.""" + plan = _plan( + total_value=365_267.0, prices={KODEX: 86_755.0, CD: 58_300.0}, + targets={KODEX: 0.2375, CD: 0.7125}, + ) + assert not plan.changed + assert plan.trades == {KODEX: 0, CD: 0} + + +def test_new_deposit_buys_underweight_without_selling(): + """적립금은 매도 없이 부족한 자산부터 산다 — 같은 개선이면 매도 비용이 없는 쪽.""" + plan = _plan( + total_value=465_267.0, cash=145_707.0, prices={KODEX: 86_755.0, CD: 58_300.0}, + targets={KODEX: 0.2375, CD: 0.7125}, + ) + assert plan.trades == {KODEX: 0, CD: 2} + assert all(delta >= 0 for delta in plan.trades.values()) + + +def test_never_spends_cash_below_reserve(): + plan = _plan(min_cash_ratio=0.30) + reserve = 0.30 * 380_092 + assert plan.cash_after >= min(reserve, 45_707.0) - 1e-6 + # 이미 하한 아래였다면 현금을 더 줄이는 조합은 고르지 않는다 + assert plan.cash_after >= 45_707.0 - 1e-6 + + +def test_turnover_cap_and_min_trade_are_respected(): + assert not _plan(max_turnover=0.2).changed # 스왑 거래액 21.8만 원 > 한도 7.6만 원 + assert not _plan(min_trade=110_000.0).changed # KODEX 1주 10.2만 원 < 최소 거래 + assert _plan(max_turnover=0.6).changed + + +def test_blocked_buy_symbol_is_not_bought(): + plan = _plan(blocked_buys={KODEX}) + assert plan.trades[KODEX] <= 0 + + +def test_sell_costs_can_outweigh_a_marginal_improvement(): + """개선이 비용보다 작으면 거래하지 않는다 — 매도 1주 비용을 아주 크게 두면 스왑을 포기.""" + costly = {KODEX: {"buy": 50.0, "sell": 50.0}, CD: {"buy": 20.0, "sell": 30_000.0}} + assert not _plan(unit_costs=costly).changed + + +def test_not_applicable_inputs_return_none(): + many = {str(i): 0.1 for i in range(6)} + assert _plan(targets=many, prices={s: 10_000.0 for s in many}, current={}) is None + assert _plan(prices={KODEX: 101_605.0, CD: 0.0}) is None + assert _plan(total_value=0.0) is None + + +# ------------------------------------------------------------- 리밸런서 연결 + +def _pocket_rebalancer(monkeypatch, positions, summary, planner="integer"): + from config.config_loader import Config + from core.basket_rebalancer import BasketRebalancer + + Config._instance = None + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.basket_name = "kr_pocket_test" + rb.basket = { + "target_stock_weight": 0.95, "min_cash_ratio": 0.05, + "risk": {"stop_loss_pct": 0}, + "overlays": {"defensive_symbol": CD}, + } + rb.account_key = "basket_rebalance:kr_pocket_test" + rb.execution_strategy = rb.account_key + rb.holdings = {KODEX: 0.5, CD: 0.5} + rb.rebalance_cfg = { + "trigger": "drift", "drift_threshold": 0.08, "min_trade_amount": 50_000, + "max_turnover_ratio": 0.6, "deployment_band": 0.03, "planner": planner, + } + rb._target_stock_weight = 0.95 + rb._risk_params = Config.get().risk_params + rb.portfolio_mgr = MagicMock() + rb.portfolio_mgr.get_portfolio_summary.return_value = summary + rb.config = MagicMock() + rb.config.trading = {"mode": "paper"} + rb._market_snapshot = {} + monkeypatch.setattr("core.basket_rebalancer.get_all_positions", lambda **kw: positions) + monkeypatch.setattr("core.basket_rebalancer.symbols_in_reentry_cooldown", lambda *a, **k: {}) + return rb + + +def _positions(): + return [ + SimpleNamespace(symbol=KODEX, quantity=1, avg_price=122_345.0), + SimpleNamespace(symbol=CD, quantity=4, avg_price=57_973.5), + ] + + +def test_pocket_plan_uses_integer_planner_and_sells_first(monkeypatch): + rb = _pocket_rebalancer(monkeypatch, _positions(), {"total_value": 380_092.0, "cash": 45_707.0}) + orders = rb.plan_rebalance(prices={KODEX: 101_605.0, CD: 58_195.0}) + assert [(o.action, o.symbol, o.quantity) for o in orders] == [("SELL", CD, 2), ("BUY", KODEX, 1)] + assert "1주 단위 조합" in orders[1].reason + + +def test_legacy_planner_reproduces_the_zero_order_case(monkeypatch): + """비교 기준: 기존 계획기는 같은 상태에서 주문을 하나도 만들지 못한다(10/13 보고).""" + rb = _pocket_rebalancer(monkeypatch, _positions(), {"total_value": 380_092.0, "cash": 45_707.0}, planner="") + assert rb.plan_rebalance(prices={KODEX: 101_605.0, CD: 58_195.0}) == [] + + +def test_integer_hold_is_explained_in_diagnostics(monkeypatch): + positions = [ + SimpleNamespace(symbol=KODEX, quantity=1, avg_price=122_345.0), + SimpleNamespace(symbol=CD, quantity=4, avg_price=57_973.5), + ] + rb = _pocket_rebalancer(monkeypatch, positions, {"total_value": 365_267.0, "cash": 45_707.0}) + rb.basket["overlays"]["trend_filter"] = {"enabled": False} + monkeypatch.setattr(type(rb), "_overlay_scale", lambda self: 0.5) + assert rb.plan_rebalance(prices={KODEX: 86_755.0, CD: 58_300.0}) == [] + codes = {(d["symbol"], d["code"]) for d in rb._plan_diagnostics} + assert (CD, "INTEGER_HOLD") in codes + + +def test_shipped_pocket_uses_integer_planner(): + from core.basket_rebalancer import BasketRebalancer + + cfg = BasketRebalancer._load_baskets_config()["kr_pocket"] + assert cfg["rebalance"]["planner"] == "integer" + # 소액 특화값은 그대로다(바꾸면 바스켓이 조용히 멈춘다 — OPERATING_PRINCIPLES) + assert cfg["rebalance"]["min_trade_amount"] == 50000 + assert cfg["rebalance"]["max_turnover_ratio"] == 0.6 + + +# ------------------------------------------------- 배분 진단의 구조적 차이 + +def test_cd_cash_split_from_one_share_rounding_is_not_an_alarm(): + """스왑 뒤 상태: 주식은 목표 근처, CD·현금 합계도 목표 근처인데 둘 사이만 1주 단위로 갈렸다.""" + from core.basket_observability import allocation_diagnostics + + total, cash = 380_092.0, 60_492.0 + positions = [{"symbol": KODEX, "quantity": 2, "price": 101_605.0}, + {"symbol": CD, "quantity": 2, "price": 58_195.0}] + diag = allocation_diagnostics(total, cash, positions, {KODEX: 0.475, CD: 0.475}, CD, 0.08) + assert diag["status"] == "OK" + assert any(a.get("structural") for a in diag["assets"] if a["asset"] == "defensive") + + +def test_real_equity_shortfall_is_still_an_alarm(): + """10/12 실제 상태: 주식 26.7% — 이건 조치할 수 있는 위험 비중 차이다.""" + from core.basket_observability import allocation_diagnostics + + positions = [{"symbol": KODEX, "quantity": 1, "price": 101_605.0}, + {"symbol": CD, "quantity": 4, "price": 58_195.0}] + diag = allocation_diagnostics(380_092.0, 45_707.0, positions, {KODEX: 0.475, CD: 0.475}, CD, 0.08) + assert diag["status"] == "ATTENTION" + assert any("주식" in issue for issue in diag["issues"]) + + +def test_idle_cash_that_can_buy_a_defensive_share_is_an_alarm(): + from core.basket_observability import allocation_diagnostics + + positions = [{"symbol": KODEX, "quantity": 2, "price": 90_000.0}, + {"symbol": CD, "quantity": 2, "price": 58_000.0}] + # 현금 13.4만 원: 목표 현금(5%)을 빼도 CD 1주(5.8만)를 살 수 있다 + diag = allocation_diagnostics(430_000.0, 134_000.0, positions, {KODEX: 0.475, CD: 0.475}, CD, 0.08) + assert diag["status"] == "ATTENTION" diff --git a/tests/test_order_safety_hardening.py b/tests/test_order_safety_hardening.py index dbb52aa..26818d0 100644 --- a/tests/test_order_safety_hardening.py +++ b/tests/test_order_safety_hardening.py @@ -968,3 +968,52 @@ def test_executor_treats_unconfirmed_mock_domain_as_real_money(): executor = _executor_without_external_initialization(config=config, mode="live") assert executor._is_real_money_live() is True + + +@pytest.mark.parametrize("use_mock, buy_tr, sell_tr", [ + (False, "TTTC0012U", "TTTC0011U"), + (True, "VTTC0012U", "VTTC0011U"), +]) +def test_cash_orders_use_current_kis_order_spec(use_mock, buy_tr, sell_tr): + """현금 주문은 현행 KIS 규격(TTTC0012U·0011U + 거래소ID구분코드)을 쓴다. + + 예전 TTTC0802U·0801U는 대체거래소 도입 전 규격이다. 취소(TTTC0013U)는 이미 새 규격이라 + 같은 주문의 접수와 취소가 서로 다른 규격을 쓰고 있었다(2026-10-20 공식 예제 대조). + """ + api = object.__new__(KISApi) + api.use_mock = use_mock + api.base_url = ( + "https://openapivts.koreainvestment.com:29443" if use_mock + else "https://openapi.koreainvestment.com:9443" + ) + api.cano = "12345678" + api.acnt_prdt_cd = "01" + calls = [] + api._request = lambda *args, **kwargs: calls.append((args, kwargs)) or { + "rt_cd": "0", "output": {"ODNO": "1"}, + } + + with authorized_kis_order_submission(): + api.buy_order("069500", 1, 86_755) + api.sell_order("357870", 2, 58_235) + + (buy_args, buy_kwargs), (sell_args, sell_kwargs) = calls + assert buy_args[1] == sell_args[1] == "/uapi/domestic-stock/v1/trading/order-cash" + assert buy_args[2] == buy_tr and sell_args[2] == sell_tr + assert buy_kwargs["body"]["EXCG_ID_DVSN_CD"] == "KRX" + assert sell_kwargs["body"]["EXCG_ID_DVSN_CD"] == "KRX" + assert buy_kwargs["idempotent"] is False and sell_kwargs["idempotent"] is False + + +def test_mock_orders_reject_non_krx_exchange(): + """모의투자는 KRX 주문만 받는다 — NXT·SOR를 모의에서 검증했다고 착각하지 않게 막는다.""" + api = object.__new__(KISApi) + api.use_mock = True + api.base_url = "https://openapivts.koreainvestment.com:29443" + api.cano = "12345678" + api.acnt_prdt_cd = "01" + api._request = lambda *args, **kwargs: {"rt_cd": "0", "output": {}} + + with authorized_kis_order_submission(): + with pytest.raises(ValueError, match="KRX"): + api.buy_order("069500", 1, 86_755, exchange_id="NXT") diff --git a/tests/test_quote_guard.py b/tests/test_quote_guard.py new file mode 100644 index 0000000..5bcaf8e --- /dev/null +++ b/tests/test_quote_guard.py @@ -0,0 +1,105 @@ +"""주문 직전 시세 점검 (2026-10-20). + +- 시세 기준일이 오늘이 아니면 주문하지 않는다. 공급자 자료가 멈추면(9/17~10/12 KS200처럼) + 전날 가격으로 수량을 정하고 체결까지 그 가격으로 기록된다. +- 전일 종가 대비 ±30%를 넘는 가격은 가격제한폭 밖이라 자료 오류로 본다. +""" + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from core.basket_rebalancer import BasketRebalancer, RebalanceOrder + +SESSION = datetime(2026, 10, 20, 10, 10) + + +@pytest.fixture +def rebalancer(monkeypatch): + from config.config_loader import Config + + Config._instance = None + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.basket_name = "guard_test" + rb.basket = {"risk": {"stop_loss_pct": 0}} + rb.account_key = rb.execution_strategy = "basket_rebalance:guard_test" + rb.holdings = {"069500": 1.0} + rb.rebalance_cfg = {} + rb._risk_params = Config.get().risk_params + rb.config = Config.get() + monkeypatch.setitem(rb.config.settings["trading"], "mode", "paper") + rb.portfolio_mgr = MagicMock() + rb.portfolio_mgr.get_available_cash.return_value = 1_000_000 + rb.portfolio_mgr.get_portfolio_summary.return_value = {"total_value": 1_000_000} + rb._policy_exposure_limits = lambda symbol: None + monkeypatch.setattr("core.trading_hours._now_kst", lambda: SESSION) + executor = MagicMock() + executor.execute_buy_quantity.return_value = {"success": True} + executor.execute_sell.return_value = {"success": True} + monkeypatch.setattr("core.order_executor.OrderExecutor", MagicMock(return_value=executor)) + events = [] + monkeypatch.setattr( + "core.cycle_observability.record_event_once_per_day", lambda *a, **k: events.append(a), + ) + rb._events = events + rb._executor = executor + return rb + + +def _snapshot(rb, **quote): + rb._market_snapshot = {"069500": {"price": 86_755.0, "avg_volume": 1e6, **quote}} + + +def test_stale_quote_date_blocks_order(rebalancer): + _snapshot(rebalancer, quote_date="2026-10-17", prev_close=88_000.0) + result = rebalancer.execute([RebalanceOrder("069500", "BUY", 1, 86_755.0)]) + assert result["executed"] == 0 and result["skipped"] == 1 + assert result["details"][0]["status"] == "blocked" + assert "시세 기준일 2026-10-17" in result["details"][0]["reason"] + rebalancer._executor.execute_buy_quantity.assert_not_called() + assert rebalancer._events and rebalancer._events[0][0] == "STALE_QUOTE_BLOCKED" + + +def test_price_outside_daily_limit_is_treated_as_bad_data(rebalancer): + _snapshot(rebalancer, quote_date="2026-10-20", prev_close=60_000.0) # +44.6% + result = rebalancer.execute([RebalanceOrder("069500", "BUY", 1, 86_755.0)]) + assert result["executed"] == 0 + assert "가격제한폭 밖" in result["details"][0]["reason"] + rebalancer._executor.execute_buy_quantity.assert_not_called() + + +def test_fresh_quote_reaches_executor(rebalancer): + _snapshot(rebalancer, quote_date="2026-10-20", prev_close=90_000.0) # -3.6% + result = rebalancer.execute([ + RebalanceOrder("069500", "SELL", 1, 86_755.0), + RebalanceOrder("069500", "BUY", 1, 86_755.0), + ]) + assert result["executed"] == 2 + rebalancer._executor.execute_buy_quantity.assert_called_once() + rebalancer._executor.execute_sell.assert_called_once() + assert rebalancer._events == [] + + +def test_quote_without_date_keeps_previous_behaviour(rebalancer): + _snapshot(rebalancer) + result = rebalancer.execute([RebalanceOrder("069500", "BUY", 1, 86_755.0)]) + assert result["executed"] == 1 + + +def test_snapshot_records_previous_close(monkeypatch): + import pandas as pd + + rb = BasketRebalancer.__new__(BasketRebalancer) + rb.holdings = {"069500": 1.0} + frame = pd.DataFrame({ + "date": pd.to_datetime(["2026-10-17", "2026-10-20"]), + "close": [88_000.0, 86_755.0], "volume": [1e6, 2e6], + }) + rb.data_collector = SimpleNamespace( + fetch_korean_stock=lambda *a, **k: frame, get_last_source_info=lambda: {"source": "test"}, + ) + snapshot = rb._fetch_market_snapshot() + assert snapshot["069500"]["prev_close"] == 88_000.0 + assert snapshot["069500"]["quote_date"] == "2026-10-20" diff --git a/tests/test_rebalance_review.py b/tests/test_rebalance_review.py index dbf3625..181d227 100644 --- a/tests/test_rebalance_review.py +++ b/tests/test_rebalance_review.py @@ -40,7 +40,9 @@ def record(self, current_prices): no_gap = replay(basket_rebalancer, stable, basket) gap = replay(basket_rebalancer, jumped, basket) - assert plans[0] == plans[1] == [("069500", 4, 30_000.0)] + # 목표 4.75주 — 정수 배분 계획기(2026-10-20)는 내림(4주) 대신 목표에 가장 가까운 5주를 고른다. + # 이 테스트의 요점은 수량 자체가 아니라 '전일 종가로 수량을 정한다'는 것이다. + assert plans[0] == plans[1] == [("069500", 5, 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 diff --git a/tests/test_weekly_report.py b/tests/test_weekly_report.py index 1c631f5..fd31e47 100644 --- a/tests/test_weekly_report.py +++ b/tests/test_weekly_report.py @@ -92,3 +92,36 @@ def test_missing_attribution_still_builds(self): names = [f["name"] for f in out["fields"]] assert "🔎 귀속 분해" not in names # 귀속 데이터 없으면 생략 assert "🛠 주간 이벤트" in names # 이벤트 필드는 항상 + + +def test_weekly_summary_shows_money_weighted_return_next_to_twr(): + """적립 계좌: 시간가중(운용 규칙)과 넣은 돈 기준(금액가중)을 함께 보여 준다.""" + from core.weekly_report import build_weekly_summary + + out = build_weekly_summary( + basket_name="kr_pocket", + eval_result={"verdict": "WAIT", "metrics": { + "nav_return_pct": -9.89, "money_weighted_return_pct": -9.81, "contributed_capital": 400_000.0, + }}, + ) + perf = next(f for f in out["fields"] if f["name"] == "💰 성과")["value"] + assert "누적 -9.89%" in perf + assert "넣은 돈 400,000원 기준 -9.81%" in perf + + +def test_money_weighted_metrics_match_modified_dietz(): + from datetime import date + + from core.basket_evaluation import money_weighted_metrics + + # 7/10 30만 원 시작, 8/26 10만 원 입금, 10/20 평가 365,267원 + flows = [(date(2026, 8, 26), 100_000.0)] + m = money_weighted_metrics(300_000.0, flows, date(2026, 7, 10), date(2026, 10, 20), 365_267.0) + weight = (date(2026, 10, 20) - date(2026, 8, 26)).days / (date(2026, 10, 20) - date(2026, 7, 10)).days + expected = (365_267 - 400_000) / (300_000 + 100_000 * weight) * 100 + assert m["contributed_capital"] == 400_000.0 + assert abs(m["money_weighted_return_pct"] - expected) < 1e-9 + # 입금이 없으면 단순 수익률과 같다 + plain = money_weighted_metrics(10_000_000.0, [], date(2026, 6, 10), date(2026, 10, 20), 8_647_654.0) + assert abs(plain["money_weighted_return_pct"] - (8_647_654 / 10_000_000 - 1) * 100) < 1e-9 + assert money_weighted_metrics(300_000.0, [], date(2026, 7, 10), date(2026, 10, 20), None)["money_weighted_return_pct"] is None diff --git a/tools/hold_policy_backtest.py b/tools/hold_policy_backtest.py new file mode 100644 index 0000000..ff17948 --- /dev/null +++ b/tools/hold_policy_backtest.py @@ -0,0 +1,420 @@ +#!/usr/bin/env python3 +"""대형주 분산 계좌의 손절·계좌 낙폭 매수 차단 조합을 과거 자료로 비교한다. + +운영 계좌(kr_diversified_hold)에는 세 장치가 함께 걸려 있다. + +1. 종목별 손절: 평균 매입가 대비 -25%면 전량 매도 +2. 손절 종목 60일 재매수 차단 +3. 계좌 낙폭 15% 이상이면 모든 신규 매수 차단(전역 risk_params) + +각각은 따로 검토된 적이 있지만 셋이 겹친 결과는 시험한 적이 없다. 2026년 10월에는 +시장 전체가 내리자 손절이 연달아 나갔고(10/15 삼성전자·현대차), 그 매도 대금은 3번 때문에 +다시 들어가지 못해 주식 비중이 설계 60%에서 45%로 내려앉았다. 이 도구는 같은 9종목과 +같은 리밸런싱 규칙에서 위 장치의 조합만 바꿔 비교한다. + +모형 +- 정수 주. 수수료 0.015%, 슬리피지 0.05%, 매도 거래세 0.20%, 현금 이자 0%. +- 판단과 체결 모두 당일 종가(운영은 오전 10시 가격 — 근사). 손절을 리밸런싱보다 먼저 본다. +- 리밸런싱: 종목 비중 8%p 이탈 또는 총 주식 비중 3%p 이탈. 최소 거래 20만원, 1회 회전 + 15%, 매도 먼저. 종목별 교정으로 못 채운 부족분은 20만원 단위 보충 매수로 메운다. +- 상대 손절: (종목 가격 / 평균 매입가) ÷ (069500 가격 / 매입 시점 069500 평균) − 1 ≤ −25%. + 시장 전체 하락에는 걸리지 않고, 시장보다 25% 더 빠진 종목만 판다. + +한계 +- 지금 보유한 9종목은 2026년까지 살아남은 대형주다. 상장폐지·장기 몰락 종목이 빠져 있어 + 손절의 '대형 사고 방지' 효과는 과소평가된다. 상대 손절도 개별 종목 사고에는 걸리도록 했다. +- 이미 본 기간을 다시 비교한 사후 검증이다. 임계값(25%, 15%, 60일)은 운영값 그대로이며 + 수익률을 높이려고 탐색하지 않았다. + +실행: python tools/hold_policy_backtest.py --as-of 2026-10-20 +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from dataclasses import dataclass, field +from datetime import timedelta +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_ROOT)) + +from tools import risk_overlay_backtest as rob # noqa: E402 + +COMMISSION = 0.00015 +SLIPPAGE = 0.0005 +STOCK_TAX = 0.0020 +BENCHMARK = "069500" + + +@dataclass(frozen=True) +class HoldPolicy: + name: str + label: str + stop: str = "none" # none | absolute | relative + stop_pct: float = 0.25 + cooldown_days: int = 60 + account_lock: bool = False # 계좌 낙폭이 lock_mdd 이상이면 매수 차단 + lock_mdd: float = 0.15 + + +POLICIES = [ + HoldPolicy("static", "손절 없음 · 차단 없음"), + HoldPolicy("lock15", "계좌 낙폭 15% 매수 차단만", account_lock=True), + HoldPolicy("abs25", "절대 손절 -25%만", stop="absolute"), + HoldPolicy("abs25_lock15", "절대 손절 + 낙폭 차단 (운영 규칙)", stop="absolute", account_lock=True), + HoldPolicy("rel25", "시장 대비 상대 손절 -25%", stop="relative"), + HoldPolicy("rel25_lock15", "상대 손절 + 낙폭 차단", stop="relative", account_lock=True), +] + +# 결론이 임계값 하나에 기대는지 보는 점검용. 운영값(25%)을 고르는 데 쓰지 않는다. +SENSITIVITY = [ + HoldPolicy("abs20", "절대 손절 -20%", stop="absolute", stop_pct=0.20), + HoldPolicy("abs30", "절대 손절 -30%", stop="absolute", stop_pct=0.30), + HoldPolicy("rel20", "상대 손절 -20%", stop="relative", stop_pct=0.20), + HoldPolicy("rel30", "상대 손절 -30%", stop="relative", stop_pct=0.30), +] + + +@dataclass +class _Position: + qty: int = 0 + avg_price: float = 0.0 + bench_basis: float = 0.0 # 매입 시점 기준지수의 수량 가중 평균(평균 매입가와 같은 방식) + + +@dataclass +class SimConfig: + initial: float = 10_000_000.0 + stock_fraction: float = 0.60 + drift_threshold: float = 0.08 + deployment_band: float = 0.03 + min_trade: float = 200_000.0 + max_turnover: float = 0.15 + min_cash_ratio: float = 0.20 + extra: dict = field(default_factory=dict) + + +def _buy_cost(price: float, qty: int) -> tuple[float, float]: + fill = price * (1 + SLIPPAGE) + return fill, fill * qty * COMMISSION + + +def _sell_proceeds(price: float, qty: int) -> tuple[float, float]: + fill = price * (1 - SLIPPAGE) + gross = fill * qty + return fill, gross * (COMMISSION + STOCK_TAX) + + +def simulate_hold(panel, bench, policy: HoldPolicy, cfg: SimConfig | None = None): + """정수 주 9종목 동일비중 + 현금. 반환 (일별 frame, 부가 통계, 손절 이벤트 목록).""" + import pandas as pd + + cfg = cfg or SimConfig() + symbols = list(panel.columns) + n = len(symbols) + target_w = 1.0 / n + dates = panel.index + cash = cfg.initial + positions = {s: _Position() for s in symbols} + blocked_until: dict[str, object] = {} + peak = 1.0 + twr = 1.0 + prev_total = cfg.initial + rows = [] + stop_events = [] + turnover_value = 0.0 + exposure_sum = 0.0 + locked_days = 0 + blocked_buys = 0 + + for day in dates: + prices = {s: float(panel.at[day, s]) for s in symbols} + b = float(bench.at[day]) + total_before = cash + sum(p.qty * prices[s] for s, p in positions.items()) + drawdown_before = twr / peak - 1.0 + locked = policy.account_lock and drawdown_before <= -policy.lock_mdd + locked_days += int(locked) + traded_today = 0.0 + + # 1) 손절 — 리밸런싱보다 먼저 + if policy.stop != "none": + for s, p in positions.items(): + if p.qty <= 0: + continue + own = prices[s] / p.avg_price - 1.0 + if policy.stop == "absolute": + hit = own <= -policy.stop_pct + else: + rel = (prices[s] / p.avg_price) / (b / p.bench_basis) - 1.0 + hit = rel <= -policy.stop_pct + if not hit: + continue + fill, cost = _sell_proceeds(prices[s], p.qty) + proceeds = fill * p.qty - cost + realized = proceeds - p.avg_price * p.qty + stop_events.append({ + "date": day, "symbol": s, "qty": p.qty, "price": prices[s], + "avg_price": p.avg_price, "own_return": own, + "bench_return": b / p.bench_basis - 1.0, "realized": realized, + }) + cash += proceeds + turnover_value += fill * p.qty + traded_today += fill * p.qty + positions[s] = _Position() + blocked_until[s] = day + timedelta(days=policy.cooldown_days) + + blocked = {s for s, until in blocked_until.items() if day < until} + + # 2) 리밸런싱 판단 + total = cash + sum(p.qty * prices[s] for s, p in positions.items()) + investable = total * cfg.stock_fraction + values = {s: positions[s].qty * prices[s] for s in symbols} + stock_value = sum(values.values()) + weights = {s: (values[s] / investable if investable > 0 else 0.0) for s in symbols} + max_drift = max((abs(target_w - weights[s]) for s in symbols if s not in blocked), default=0.0) + gap = stock_value / total - cfg.stock_fraction if total > 0 else 0.0 + trigger = (max_drift >= cfg.drift_threshold) or (abs(gap) >= cfg.deployment_band) + + if trigger and total > 0: + budget = total * cfg.max_turnover + used = 0.0 + candidates = [] + for s in symbols: + diff = investable * target_w - values[s] + if diff > 0 and s in blocked: + continue + trade_value = abs(diff) + if trade_value < cfg.min_trade: + continue + qty = int(trade_value / prices[s]) + if qty <= 0: + continue + if diff > 0: + candidates.append(("BUY", s, qty)) + else: + candidates.append(("SELL", s, min(qty, positions[s].qty))) + candidates.sort(key=lambda c: (0 if c[0] == "SELL" else 1, -c[2] * prices[c[1]])) + planned = [] + for action, s, qty in candidates: + left = budget - used + if left < cfg.min_trade: + break + notional = qty * prices[s] + if notional > left: + qty = int(left / prices[s]) + notional = qty * prices[s] + if qty <= 0 or notional < cfg.min_trade: + continue + planned.append((action, s, qty)) + used += notional + + # 보충 매수: 종목별 교정 후에도 총 주식 비중이 밴드 밖으로 모자라면 채운다 + projected = stock_value + sum( + (1 if a == "BUY" else -1) * q * prices[s] for a, s, q in planned + ) + shortfall = investable - projected + band = cfg.deployment_band * total + ordered = {s for _, s, _ in planned} + if shortfall > band and budget - used >= cfg.min_trade: + remaining = shortfall + topups = [] + for s in symbols: + if s in ordered or s in blocked: + continue + if investable * target_w - values[s] <= 0: + continue + qty = max(1, int(cfg.min_trade // prices[s]) + (1 if cfg.min_trade % prices[s] else 0)) + notional = qty * prices[s] + residual = abs(remaining - notional) + if residual >= abs(remaining): + continue + projected_w = (values[s] + notional) / investable + if projected_w > target_w + cfg.drift_threshold: + continue + topups.append((residual, notional, s, qty)) + topups.sort() + for _residual, notional, s, qty in topups: + if remaining <= band: + break + if notional > budget - used: + continue + if abs(remaining - notional) >= abs(remaining): + continue + planned.append(("BUY", s, qty)) + remaining -= notional + used += notional + + # 3) 체결 — 매도 먼저, 매수는 현금 하한과 계좌 낙폭 차단을 지킨다 + reserve = total * cfg.min_cash_ratio + for action, s, qty in [x for x in planned if x[0] == "SELL"] + [x for x in planned if x[0] == "BUY"]: + p = positions[s] + if action == "SELL": + qty = min(qty, p.qty) + if qty <= 0: + continue + fill, cost = _sell_proceeds(prices[s], qty) + cash += fill * qty - cost + p.qty -= qty + if p.qty == 0: + positions[s] = _Position() + turnover_value += fill * qty + traded_today += fill * qty + continue + if locked: + blocked_buys += 1 + continue + fill, cost = _buy_cost(prices[s], qty) + while qty > 0 and cash - (fill * qty + cost) < reserve: + qty -= 1 + fill, cost = _buy_cost(prices[s], qty) + if qty <= 0 or qty * prices[s] < cfg.min_trade: + continue + cash -= fill * qty + cost + new_qty = p.qty + qty + p.avg_price = (p.avg_price * p.qty + fill * qty) / new_qty + p.bench_basis = (p.bench_basis * p.qty + b * qty) / new_qty + p.qty = new_qty + turnover_value += fill * qty + traded_today += fill * qty + + total = cash + sum(p.qty * prices[s] for s, p in positions.items()) + daily_return = total / prev_total - 1.0 + twr *= 1.0 + daily_return + peak = max(peak, twr) + prev_total = total + stock_now = total - cash + exposure_sum += stock_now / total if total > 0 else 0.0 + rows.append({ + "date": day, "total": total, "twr": twr, "contributed": cfg.initial, + "daily_return": daily_return, "drawdown": twr / peak - 1.0, + "stock_w": stock_now / total if total > 0 else 0.0, + "cash": cash, "locked": locked, "trade_value": traded_today, + "held_names": sum(1 for p in positions.values() if p.qty > 0), + }) + + frame = pd.DataFrame(rows).set_index("date") + extra = { + "turnover_value": turnover_value, + "avg_exposure": exposure_sum / max(len(frame), 1), + "locked_days": locked_days, + "blocked_buy_orders": blocked_buys, + } + return frame, extra, stop_events + + +def stop_forward_returns(stop_events, panel, bench, horizons=(20, 60, 120)): + """손절 뒤 판 종목이 어떻게 됐는가 — 시장 대비 초과수익(판 뒤 오르면 양수 = 손절이 손해).""" + out = {} + index = list(panel.index) + pos = {d: i for i, d in enumerate(index)} + for h in horizons: + excess = [] + for ev in stop_events: + i = pos.get(ev["date"]) + if i is None or i + h >= len(index): + continue + later = index[i + h] + own = float(panel.at[later, ev["symbol"]]) / float(panel.at[ev["date"], ev["symbol"]]) - 1.0 + mkt = float(bench.at[later]) / float(bench.at[ev["date"]]) - 1.0 + excess.append(own - mkt) + if excess: + out[f"{h}d"] = { + "events": len(excess), + "mean_excess_pct": round(sum(excess) / len(excess) * 100, 2), + "rebound_share_pct": round(sum(1 for x in excess if x > 0) / len(excess) * 100, 1), + } + return out + + +def run(start: str, symbols=None, policies=None, cfg: SimConfig | None = None): + import pandas as pd + + symbols = list(symbols or rob.configured_basket_symbols()) + panel = pd.DataFrame({s: rob._fdr(s, start) for s in symbols}).dropna(how="any") + bench = rob._fdr(BENCHMARK, start).reindex(panel.index).ffill() + if bench.isna().any(): + raise ValueError("기준 ETF 자료가 비어 있는 날이 있습니다") + results = {} + frames = {} + for policy in (policies or POLICIES): + frame, extra, events = simulate_hold(panel, bench, policy, cfg) + m = rob.metrics(frame, extra, rf_annual=0.0) + m.update({ + "label": policy.label, + "stop_events": len(events), + "stop_realized_krw": round(sum(e["realized"] for e in events)), + "locked_days": extra["locked_days"], + "locked_days_pct": round(extra["locked_days"] / max(len(frame), 1) * 100, 1), + "blocked_buy_orders": extra["blocked_buy_orders"], + "after_stop": stop_forward_returns(events, panel, bench), + "min_stock_weight_pct": round(float(frame["stock_w"].min()) * 100, 1), + }) + results[policy.name] = m + frames[policy.name] = frame + return { + "symbols": symbols, + "benchmark": BENCHMARK, + "start": str(panel.index[0].date()), + "end": str(panel.index[-1].date()), + "days": len(panel), + "results": results, + }, frames + + +def _markdown(title: str, summary: dict) -> str: + lines = [ + f"### {title}", + "", + f"- 기간: {summary['start']} → {summary['end']} ({summary['days']}거래일), 종목 {len(summary['symbols'])}개, 상대 손절 기준 {summary['benchmark']}", + "", + "| 규칙 | 연수익률 | 최대낙폭 | 샤프(금리 0%) | 칼마 | 최악 연도 | 평균 주식 비중 | 손절 횟수 | 손절 실현손익 | 매수 차단일 비율 | 손절 60일 뒤 시장 대비 |", + "|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|", + ] + for r in summary["results"].values(): + after = r["after_stop"].get("60d") + after_txt = f"{after['mean_excess_pct']:+.1f}%p ({after['events']}건)" if after else "—" + calmar = f"{r['calmar']:.2f}" if r["calmar"] is not None else "—" + lines.append( + f"| {r['label']} | {r['cagr_pct']:+.2f}% | {r['mdd_pct']:.1f}% | {r['sharpe']:.2f} | {calmar} | " + f"{r['worst_year_pct']:+.1f}% | {r['avg_exposure_pct']:.0f}% | {r['stop_events']} | " + f"{r['stop_realized_krw']:+,}원 | {r['locked_days_pct']:.0f}% | {after_txt} |" + ) + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--as-of", default=rob.AS_OF, help="이 날짜 미만의 확정 일봉만 사용 (YYYY-MM-DD)") + parser.add_argument("--start", action="append", help="시작일. 여러 번 주면 구간마다 따로 계산") + parser.add_argument("--out-dir", default=str(_ROOT / "reports" / "research")) + parser.add_argument("--sensitivity", action="store_true", help="손절 임계값 20%/30% 점검 표도 만든다") + args = parser.parse_args() + from datetime import date + + rob.AS_OF = date.fromisoformat(args.as_of).isoformat() + starts = args.start or ["2012-01-02", "2021-12-01"] + report = {"as_of": rob.AS_OF, "windows": {}} + md = ["# 대형주 계좌 손절·낙폭 차단 규칙 비교", ""] + for start in starts: + summary, _ = run(start) + report["windows"][start] = summary + md += [_markdown(f"{start}부터", summary), ""] + if args.sensitivity: + check, _ = run(start, policies=[POLICIES[0], *SENSITIVITY]) + report.setdefault("sensitivity", {})[start] = check + md += [_markdown(f"{start}부터 · 임계값 점검(운영값 선택에 쓰지 않음)", check), ""] + out_dir = Path(args.out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + (out_dir / "hold_policy_backtest.json").write_text( + json.dumps(report, ensure_ascii=False, indent=1, default=str), encoding="utf-8" + ) + (out_dir / "hold_policy_backtest.md").write_text("\n".join(md), encoding="utf-8") + print("\n".join(md)) + + +if __name__ == "__main__": + main() diff --git a/tools/verify_basket_snapshot_fixes.py b/tools/verify_basket_snapshot_fixes.py index 58610d7..317148d 100644 --- a/tools/verify_basket_snapshot_fixes.py +++ b/tools/verify_basket_snapshot_fixes.py @@ -3,6 +3,10 @@ Usage: python tools/verify_basket_snapshot_fixes.py --source-db --prices-dir --out-dir Never uses the source DB as the runtime ledger, and prohibits network access. + +2026-10-20 정책 변경(정수 배분 계획기, 대형주 계좌 낙폭 가드 위임, 시세 기준일 점검) 뒤에도 +10/13 검증을 그대로 재현할 수 있게, 이 도구 안에서만 10/13 당시 정책으로 고정한다. +새 정책의 검증은 tools/verify_policy_changes_20261020.py. """ import argparse from contextlib import ExitStack @@ -70,6 +74,10 @@ def fetch(self, symbol, start_date=None, end_date=None): def make(name): rb = BasketRebalancer(name, config=cfg) rb.overlay_decision = lambda: OverlayDecision(scale=1.0) if name == "kr_pocket" else None + # 10/13 당시 정책으로 고정: 종목별 내림 계획기, 전역 낙폭 가드 적용. + rb.rebalance_cfg = {k: v for k, v in rb.rebalance_cfg.items() if k != "planner"} + rb.basket = {**rb.basket, "risk": {k: v for k, v in (rb.basket.get("risk") or {}).items() + if k != "account_drawdown_guard"}} snapshot = {s: {"price": float(frames[s].iloc[-1]["close"]), "avg_volume": float(frames[s].volume.tail(20).mean()), "quote_date": "2026-10-12", "source": "frozen_csv", "observed_at": "2026-10-12T10:10:00"} for s in rb.holdings if s in frames} @@ -88,6 +96,13 @@ def count(): stack.enter_context(patch.object(DataCollector, "fetch_korean_stock", fetch)) stack.enter_context(patch.object(DataCollector, "get_last_source_info", return_value={"source": "frozen_csv"})) stack.enter_context(patch("core.data_collector.SECTOR_MAP_CACHE_PATH", output / "sector_map.json")) + # 10/12 종가를 10/13 주문에 쓰는 재현이다. 시세 기준일 점검(10/20 추가)은 이 재현의 범위 밖. + stack.enter_context(patch.object(BasketRebalancer, "_quote_guard_reason", return_value=None)) + # 10/13 당시에는 자기 낙폭 규칙(overlays.drawdown_guard)이 있는 바스켓만 계좌 가드를 넘겼다. + stack.enter_context(patch( + "core.basket_risk.delegates_account_drawdown_guard", + lambda basket: bool((((basket or {}).get("overlays") or {}).get("drawdown_guard") or {}).get("enabled")), + )) pocket = make("kr_pocket") orders = pocket.plan_rebalance() assert orders == [] diff --git a/tools/verify_policy_changes_20261020.py b/tools/verify_policy_changes_20261020.py new file mode 100644 index 0000000..5b9b7ce --- /dev/null +++ b/tools/verify_policy_changes_20261020.py @@ -0,0 +1,208 @@ +"""2026-10-20 규칙 변경을 운영 장부 사본과 고정 시세로 실제 주문 경로에 태워 확인한다. + +확인하는 것 +1. 대형주 계좌: 시장 대비 손절은 지금 보유 종목을 팔지 않는다. 계좌 낙폭 18%대에서도 + 선언 비중(60%)까지 보충 매수가 체결되고, 체결 뒤 주식 비중은 60% + 밴드 3%p 안이다. + 재매수 차단 중인 종목(손절 후 60일)은 사지 않는다. 평가의 진입 상태가 낙폭으로 막히지 않는다. +2. 적립 계좌: 지금(추세 필터로 주식 목표 23.75%)은 주문이 없고 이유가 남는다. 추세가 복귀해 + 목표가 47.5%로 돌아가면 CD를 팔아 주식 ETF를 산다. 적립금 10만 원은 매도 없이 산다. + +원본 DB는 읽기 전용으로 복사만 하고 해시로 불변을 확인한다. 시세는 처음 한 번 받아 +--out-dir/prices에 저장하고, 주문 경로는 네트워크를 막은 채 그 파일만 쓴다. + +실행: python tools/verify_policy_changes_20261020.py --source-db data/quant_trader.db --out-dir output/policy_20261020 + (이미 받은 시세로 다시 돌리려면 --prices-dir <이전 out-dir>/prices) +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sqlite3 +import sys +from contextlib import ExitStack +from datetime import datetime +from pathlib import Path +from unittest.mock import patch + +HOLD = "kr_diversified_hold" +POCKET = "kr_pocket" + + +def _download_prices(symbols, start, end, out_dir: Path): + import FinanceDataReader as fdr + + out_dir.mkdir(parents=True, exist_ok=True) + for symbol in symbols: + frame = fdr.DataReader(symbol, start, end) + frame = frame.rename(columns={c: c.lower() for c in frame.columns}) + frame.index.name = "date" + frame.reset_index().to_csv(out_dir / f"{symbol}.csv", index=False) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--source-db", required=True, type=Path) + parser.add_argument("--out-dir", required=True, type=Path) + parser.add_argument("--prices-dir", type=Path) + parser.add_argument("--as-of", default="2026-10-20", help="시세 기준일(이 날짜 종가까지 사용)") + args = parser.parse_args() + + source = args.source_db.resolve(strict=True) + output = args.out_dir.resolve() + output.mkdir(parents=True, exist_ok=False) + target = output / "verification.db" + digest_before = hashlib.sha256(source.read_bytes()).hexdigest() + with sqlite3.connect(source.as_uri() + "?mode=ro", uri=True) as src, sqlite3.connect(target) as dest: + src.backup(dest) + + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(root)) + import yaml + + baskets = (yaml.safe_load((root / "config" / "baskets.yaml").read_text(encoding="utf-8")) or {})["baskets"] + symbols = sorted(set(baskets[HOLD]["holdings"]) | set(baskets[POCKET]["holdings"]) | {"069500"}) + prices_dir = args.prices_dir.resolve() if args.prices_dir else output / "prices" + if not args.prices_dir: + _download_prices(symbols, "2026-05-01", args.as_of, prices_dir) + + # 업종 비중 검사는 운영과 같은 업종 맵 사본으로 한다(없으면 fail-closed로 매수가 막힌다). + sector_map = output / "sector_map.json" + sector_map.write_bytes((root / "reports" / "sector_map_cache.json").read_bytes()) + os.environ.update( + QUANT_DB_PATH=str(target), QUANT_BACKUP_PATH=str(output / "backups"), + QUANT_OVERLAY_STATE_DIR=str(output / "overlays"), + QUANT_DASHBOARD_STATE_PATH=str(output / "dashboard.json"), + QUANT_RUNTIME_LOCK_DIR=str(output / "locks"), + ) + import pandas as pd + from loguru import logger + from config.config_loader import Config + from core.basket_evaluation import collect_basket_paper_evaluation + from core.basket_rebalancer import BasketRebalancer + from core.data_collector import DataCollector + from core.risk_overlays import OverlayDecision + from database.models import TradeHistory, get_engine, get_session, init_database + from database.repositories import get_all_positions, record_cash_flow + + logger.remove() + logger.add(output / "execution.log", encoding="utf-8") + cfg = Config.get() + cfg.settings["trading"]["mode"] = "paper" + init_database() + assert Path(get_engine().url.database).resolve() == target + + frames = {} + for path in prices_dir.glob("*.csv"): + frame = pd.read_csv(path) + frame.columns = [str(c).lower() for c in frame.columns] + frame["date"] = pd.to_datetime(frame["date"]).dt.tz_localize(None) + frames[path.stem] = frame[frame["date"] <= pd.Timestamp(args.as_of)].reset_index(drop=True) + as_of = pd.Timestamp(args.as_of) + session_now = datetime(as_of.year, as_of.month, as_of.day, 10, 10) + + def fetch(self, symbol, start_date=None, end_date=None): + frame = frames[symbol].copy() + days = frame["date"].dt.strftime("%Y-%m-%d") + lower = str(start_date)[:10] if start_date else "0001-01-01" + upper = str(end_date)[:10] if end_date else "9999-12-31" + return frame.loc[(days >= lower) & (days <= upper)].copy() + + def make(name, scale=None): + rb = BasketRebalancer(name, config=cfg) + if scale is not None: + rb.overlay_decision = lambda: OverlayDecision(scale=scale) + snapshot = { + s: {"price": float(frames[s].iloc[-1]["close"]), "avg_volume": float(frames[s]["volume"].tail(20).mean()), + "quote_date": str(frames[s].iloc[-1]["date"].date()), "source": "frozen_csv", + "observed_at": session_now.isoformat()} + for s in rb.holdings + } + rb._market_snapshot = snapshot + rb._fetch_market_snapshot = lambda: snapshot + return rb + + def fills(): + with get_session() as s: + return s.query(TradeHistory).count() + + def exposure(rb): + prices = {s: q["price"] for s, q in rb._market_snapshot.items()} + summary = rb.portfolio_mgr.get_portfolio_summary(current_prices=prices) + held = sum(prices.get(p.symbol, p.avg_price) * p.quantity + for p in get_all_positions(rb.account_key, mode="paper") if p.symbol in rb.holdings) + return held / float(summary["total_value"]), float(summary["total_value"]) + + results = {"as_of": args.as_of, "prices": {s: float(f.iloc[-1]["close"]) for s, f in frames.items()}} + with ExitStack() as stack: + stack.enter_context(patch("socket.socket.connect", side_effect=AssertionError("network forbidden"))) + stack.enter_context(patch("core.notifier.Notifier")) + stack.enter_context(patch.object(DataCollector, "fetch_korean_stock", fetch)) + stack.enter_context(patch.object(DataCollector, "get_last_source_info", return_value={"source": "frozen_csv"})) + stack.enter_context(patch("core.data_collector.SECTOR_MAP_CACHE_PATH", output / "sector_map.json")) + stack.enter_context(patch("core.trading_hours._now_kst", return_value=session_now)) + # 실적일 확인은 yfinance 조회가 필요해 네트워크를 막은 이 검증에서는 '근처 아님'으로 둔다. + # 실제 운영에서는 그대로 검사한다(10월은 실적 시즌이라 일부 보충 매수가 며칠 보류될 수 있다). + stack.enter_context(patch("core.earnings_filter.is_near_earnings", return_value=(False, ""))) + results["note"] = "실적일 필터는 이 검증에서 제외(네트워크 차단). 운영에서는 그대로 적용된다." + + # 1) 대형주 계좌 + hold = make(HOLD) + exits = hold.plan_risk_exits() + assert exits == [], f"시장 대비 기준으로는 손절 대상이 없어야 한다: {exits}" + before_w, total = exposure(hold) + orders = hold.plan_rebalance() + cooldown = {"005930", "005380", "012330"} + assert orders and all(o.action == "BUY" for o in orders), orders + assert not cooldown & {o.symbol for o in orders}, "재매수 차단 종목을 샀다" + n0 = fills() + outcome = hold.execute(orders) + after_w, _ = exposure(hold) + assert outcome["executed"] == len(orders) and outcome["failed"] == 0, outcome + assert fills() - n0 == len(orders) + assert after_w <= 0.60 + 0.03 + 1e-9, after_w + evaluation, _ = collect_basket_paper_evaluation(config=cfg, basket_name=HOLD, include_benchmark=False) + assert evaluation["entry_status"]["status"] != "BLOCKED", evaluation["entry_status"] + results["hold"] = { + "risk_exits": [], "stock_weight_before": round(before_w, 4), "stock_weight_after": round(after_w, 4), + "orders": [repr(o) for o in orders], "execution": outcome, + "entry_status": evaluation["entry_status"], "verdict": evaluation.get("verdict"), + } + + # 2) 적립 계좌 — 지금 상태(추세 필터 배수 0.5) + pocket = make(POCKET, scale=0.5) + assert pocket.plan_rebalance() == [] + held_reasons = [d for d in pocket._plan_diagnostics if d["code"] in ("INTEGER_HOLD", "MIN_LOT")] + results["pocket_now"] = {"orders": [], "diagnostics": pocket._plan_diagnostics, + "allocation": pocket.diagnose_deployment()["allocation"]} + assert held_reasons, "주문이 없을 때 이유가 남아야 한다" + + # 추세가 복귀했다고 가정(배수 1.0) — 실행하지 않고 계획만 본다 + restored = make(POCKET, scale=1.0) + plan_restored = restored.plan_rebalance() + assert [(o.action, o.symbol) for o in plan_restored] == [("SELL", "357870"), ("BUY", "069500")], plan_restored + results["pocket_trend_restored_plan"] = [repr(o) for o in plan_restored] + + # 사본에만 적립금 10만 원을 넣고 실제 실행 경로까지 + record_cash_flow(100_000, account_key=pocket.account_key, note="COPY ONLY: verification 2026-10-20") + deposit = make(POCKET, scale=0.5) + orders = deposit.plan_rebalance() + assert orders and all(o.action == "BUY" for o in orders), orders + n0 = fills() + outcome = deposit.execute(orders) + assert outcome["executed"] == len(orders) and outcome["failed"] == 0, outcome + results["pocket_deposit_copy_only"] = { + "orders": [repr(o) for o in orders], "execution": outcome, "new_fills": fills() - n0, + "allocation_after": deposit.diagnose_deployment()["allocation"], + } + + assert hashlib.sha256(source.read_bytes()).hexdigest() == digest_before + results["source_unchanged"] = True + (output / "result.json").write_text(json.dumps(results, ensure_ascii=False, indent=2, default=str), encoding="utf-8") + print(json.dumps({"verified": [k for k in results if k not in ("as_of", "prices")], + "report": str(output / "result.json")}, ensure_ascii=False)) + + +if __name__ == "__main__": + main()