From 4b08ab99de7b88a02f742ed64e266b497f6447dd Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Tue, 20 Oct 2026 17:37:53 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=C2=B7=EC=8B=9C=EA=B0=81=EC=97=90=20=EB=94=B0=EB=9D=BC=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=ED=95=98=EB=8D=98=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=204=EA=B1=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit main에서 전체 테스트를 돌리면 3건이 늘 실패했고, 1건은 가끔 실패했다. 코드 문제가 아니라 테스트가 실행 날짜와 시각, 앞선 테스트가 남긴 환경에 따라 결과가 달라지는 문제였다. - test_audit_cycle: 시계를 10/13 10:10으로 고정해 두고 기대값은 실제 오늘 날짜로 계산하고 있었다. 10/13 하루만 통과했다. 고정한 날짜로 비교한다. - test_critical_fixes: 정규장 검사가 들어간 뒤로 15:30 이후에 돌리면 매매 경로를 타지 않아 실패했다. 정규장 여부도 고정한다. - test_config_auto_entry: 원래 없던 환경변수를 delenv(raising=False)로 지우면 monkeypatch가 아무것도 기록하지 않는다. 그래서 테스트가 넣은 디스코드 주소가 끝난 뒤에도 남아, 뒤에 도는 알림 테스트가 discord.test로 발송을 시도했다. - test_audit_runtime_post_market: 스냅샷과 입금을 같은 시계 단위 안에 기록하면 입금 시각이 직전 측정 시각과 같아져 집계 구간에서 빠졌다. 시계가 넘어간 뒤 입금하도록 했다. --- tests/test_audit_cycle.py | 8 +++----- tests/test_audit_runtime_post_market.py | 4 ++++ tests/test_config_auto_entry.py | 9 ++++++--- tests/test_critical_fixes.py | 4 +++- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/test_audit_cycle.py b/tests/test_audit_cycle.py index 7a9ea02f..0eb678ce 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 28944a29..b4ee7efd 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 872fa2af..3dc0a06d 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 b5228f82..f881b64d 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") From 466afbe5db176c766d414e4e3251b64e5052cf9e Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Tue, 20 Oct 2026 17:38:06 +0900 Subject: [PATCH 2/7] =?UTF-8?q?KIS=20=ED=98=84=EA=B8=88=20=EC=A3=BC?= =?UTF-8?q?=EB=AC=B8=EC=9D=84=20=EC=83=88=20=EA=B1=B0=EB=9E=98=EC=BD=94?= =?UTF-8?q?=EB=93=9C(TTTC0012U=C2=B70011U)=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 국내주식 현금 주문이 아직 대체거래소(NXT) 도입 전 거래코드인 TTTC0802U·0801U를 쓰고 있었다. KIS 공식 예제(open-trading-api의 order_cash.py)는 매수 TTTC0012U, 매도 TTTC0011U, 모의투자는 VTTC0012U·VTTC0011U를 쓰고 거래소ID구분코드를 필수로 받는다. 같은 주문의 취소(TTTC0013U)는 이미 새 규격이라, 접수와 취소가 서로 다른 규격을 쓰고 있던 셈이다. 거래소 코드는 기본으로 KRX를 보낸다. 모의투자는 KRX 주문만 받으므로 다른 값은 요청을 보내기 전에 막는다. 이번에는 요청 구성만 테스트로 확인했고, 모의 서버로 실제 주문을 넣어 보는 것은 실전 전환 전에 한 번 해야 한다. --- api/kis_api.py | 23 +++++++++++-- tests/test_order_safety_hardening.py | 49 ++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/api/kis_api.py b/api/kis_api.py index 00116b4d..d4d971fc 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/tests/test_order_safety_hardening.py b/tests/test_order_safety_hardening.py index dbb52aa7..26818d04 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") From 4c12d1a1edc87d00c3e1ff451ff1f6ff3945867b Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Tue, 20 Oct 2026 17:38:20 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=ED=8F=89=EA=B0=80=C2=B7=EC=A3=BC=EA=B0=84?= =?UTF-8?q?=20=EB=B3=B4=EA=B3=A0=EC=97=90=20=EB=84=A3=EC=9D=80=20=EB=8F=88?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=20=EC=88=98=EC=9D=B5=EB=A5=A0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 적립 계좌는 운용 규칙의 성과(시간가중)와 실제로 넣은 돈의 성과(금액가중)가 다르다. 자본시장연구원 26-04 보고서에서도 개인 계좌 가운데 수익을 낸 비율이 시간가중으로는 28%, 금액가중으로는 23%로 갈렸다. 지금까지는 시간가중만 보여 줬다. 평가 지표에 money_weighted_return_pct(Modified Dietz)와 넣은 돈 합계를 넣고, 주간 보고의 성과 줄에 같이 적는다. 판정에는 쓰지 않는다. 몇 달짜리 기록을 연율로 늘리면 과장되므로 연환산도 하지 않는다. 운영 DB 사본 기준 적립 계좌는 시간가중 -9.89%, 넣은 돈 40만 원 기준 -9.81%다. --- core/basket_evaluation.py | 40 ++++++++++++++++++++++++++++++++++++- core/weekly_report.py | 5 +++++ tests/test_weekly_report.py | 33 ++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/core/basket_evaluation.py b/core/basket_evaluation.py index 3eabedeb..cb31d41d 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/weekly_report.py b/core/weekly_report.py index b32333b3..1fd2efea 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/tests/test_weekly_report.py b/tests/test_weekly_report.py index 1c631f5c..fd31e478 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 From e5844dc8866348ea6501d007da630db4928c3ad9 Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Tue, 20 Oct 2026 17:38:49 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=EB=8C=80=ED=98=95=EC=A3=BC=20=EA=B3=84?= =?UTF-8?q?=EC=A2=8C=20=EC=86=90=EC=A0=88=EC=9D=84=20=EC=8B=9C=EC=9E=A5=20?= =?UTF-8?q?=EB=8C=80=EB=B9=84=EB=A1=9C=20=EB=B0=94=EA=BE=B8=EA=B3=A0=20?= =?UTF-8?q?=EB=82=99=ED=8F=AD=20=ED=95=9C=EB=8F=84=EC=97=90=20=EB=A7=89?= =?UTF-8?q?=ED=9E=88=EB=8D=98=20=EB=A7=A4=EC=88=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10/15 삼성전자·현대차가 평균 매입가 -25% 손절로 팔렸는데, 같은 기간 KODEX 200도 -30% 가까이 빠져 있었다. 한 종목의 사고가 아니라 시장 전체 하락에 손절이 몰린 것이다. 8/07 이후 손절로 확정한 손실은 -819,468원(자본의 8.2%)이다. 그렇게 생긴 현금은 계좌 낙폭 15% 한도에 막혀 10/12부터 매일 매수가 거절됐고, 주식 비중은 설계 60%에서 45%까지 내려앉았다. 매도는 되는데 매수는 막히는 한쪽 방향 장치였다. 같은 9종목으로 다시 계산해 보니(tools/hold_policy_backtest.py) 지금까지 쓰던 조합 (평균 매입가 손절 + 낙폭 한도)이 두 구간 모두 가장 나빴다. 2014-07~: 연 4.12%, 샤프 0.38 (손절 없음 5.58%, 0.48) 2021-12~: 연 3.46%, 샤프 0.30 (손절 없음 5.99%, 0.45) 낙폭 한도만 걸어도 연 0.3~1.0%p가 깎였고, 평균 매입가 기준으로 손절한 종목은 그 뒤 60일 동안 시장보다 1~3%p 더 올랐다. 시장 대비 손절은 수익률이 손절 없음과 비슷하다. 수익을 올리는 장치가 아니라 한 종목 사고만 막는 장치로 남겨 둔다. - stop_basis: relative 같은 보유 기간의 KODEX 200 수익률보다 25% 넘게 뒤처지면 판다. 매입 시점의 시장 수준은 매수 기록마다 그날 종가를 수량 가중 평균해서 구한다(평균 매입가와 같은 방식). 지금 시장 가격을 모르면 그날은 판단을 미루고, 한 종목의 매수 기록만 맞지 않으면 그 종목만 평균 매입가 기준으로 본다. - account_drawdown_guard: delegate 계좌 낙폭이 15%를 넘어도 정해 둔 주식 비중(60% + 3%p)까지는 다시 산다. 비중 상한은 주문 단계에서 그대로 검사한다. 평가 화면의 매수 가능 상태도 같은 함수로 판단한다. 지금 들고 있는 6종목은 시장 대비 +7.7~+46.7%라 규칙을 바꾼 날 팔리는 종목은 없다. 운영 DB 사본으로 실제 주문 경로를 돌려 보니 낙폭 18%대에서도 보충 매수 2건이 체결돼 주식 비중이 44.6%에서 50.4%가 됐다. 손절한 3종목은 60일 재매수 제한이 풀려야 (11/20, 12/14) 60%까지 돌아간다. --- config/baskets.yaml | 12 + core/basket_observability.py | 4 +- core/basket_rebalancer.py | 107 +- core/basket_risk.py | 208 +++- core/order_executor.py | 14 +- docs/HOLD_RISK_REVIEW_20261020.md | 111 ++ reports/research/hold_policy_backtest.json | 1164 ++++++++++++++++++++ reports/research/hold_policy_backtest.md | 51 + tests/test_hold_policy_backtest.py | 61 + tests/test_hold_relative_stop.py | 269 +++++ tools/hold_policy_backtest.py | 420 +++++++ 11 files changed, 2394 insertions(+), 27 deletions(-) create mode 100644 docs/HOLD_RISK_REVIEW_20261020.md create mode 100644 reports/research/hold_policy_backtest.json create mode 100644 reports/research/hold_policy_backtest.md create mode 100644 tests/test_hold_policy_backtest.py create mode 100644 tests/test_hold_relative_stop.py create mode 100644 tools/hold_policy_backtest.py diff --git a/config/baskets.yaml b/config/baskets.yaml index 04be0531..734e5ae6 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 미달은 조치 불가'였는데, 그 슬롯을 제거하고 목표 배치율을 diff --git a/core/basket_observability.py b/core/basket_observability.py index 77760d57..ae674efb 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 ( diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index 6ea22861..cfc648ce 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 + # ------------------------------------------------------------------ # 리밸런싱 주문 계획 # ------------------------------------------------------------------ diff --git a/core/basket_risk.py b/core/basket_risk.py index a0e992d5..f6562b88 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/order_executor.py b/core/order_executor.py index ff33a392..efe8b367 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/docs/HOLD_RISK_REVIEW_20261020.md b/docs/HOLD_RISK_REVIEW_20261020.md new file mode 100644 index 00000000..43778072 --- /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/reports/research/hold_policy_backtest.json b/reports/research/hold_policy_backtest.json new file mode 100644 index 00000000..af84650e --- /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 00000000..61bf234e --- /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_hold_policy_backtest.py b/tests/test_hold_policy_backtest.py new file mode 100644 index 00000000..3674b42b --- /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 00000000..8eddae4b --- /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/tools/hold_policy_backtest.py b/tools/hold_policy_backtest.py new file mode 100644 index 00000000..ff179488 --- /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() From de15e9b38e1aab10d76166735eabcc85b4eedeb5 Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Tue, 20 Oct 2026 17:39:45 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EC=A0=81=EB=A6=BD=20=EA=B3=84=EC=A2=8C=20?= =?UTF-8?q?=EC=A3=BC=EB=AC=B8=EC=9D=84=201=EC=A3=BC=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=EC=88=98=EB=9F=89=20=EC=A1=B0=ED=95=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=84=ED=9A=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10/12 적립 계좌는 주식 ETF 26.7%, CD ETF 61.2%(목표는 각각 47.5%)였는데 주문이 하나도 나오지 않았다. 종목마다 '목표 금액 - 보유 금액'을 1주 가격으로 내림하다 보니 부족분(약 7.9만 원)도 초과분(약 5.2만 원)도 0주가 됐다. CD ETF 2주를 팔아 KODEX 200 1주를 사면 주식 비중이 53.5%가 되고, 목표와의 차이는 20.8%p에서 6.0%p로 준다. 종목이 5개 이하인 바스켓은 planner: integer로 수량 조합을 함께 고른다 (core/integer_allocation.py). 종목마다 지금 수량, 목표 수량의 내림·올림, 내림-1을 후보로 두고, 현금 하한·최소 거래금액·회전 한도·재매수 제한을 지키는 조합 가운데 '위험자산 비중 차이 + 남는 현금 + 거래비용'이 가장 작은 것을 고른다. CD ETF와 현금은 위험이 거의 같아서 둘 사이의 차이는 따지지 않는다. 그래야 비용만 드는 CD 매도를 하지 않고, 적립금이 들어오면 팔지 않고 모자란 자산부터 산다. 2020-07~2026-10 과거 재생(tools/rebalance_review.py, 기존 계획기와 비교)에서는 연 12.53% → 12.56%, 최대 낙폭 -15.82% → -15.44%로 거의 같았다. 수익을 올리려는 변경이 아니라, 비중이 크게 어긋났는데도 주문이 0건인 상태를 없애려는 변경이다. 배분 진단도 손봤다. CD와 현금을 합친 안전 자산 비중이 목표 근처이고, 남는 현금으로 CD를 1주 더 살 수도 없다면 둘 사이의 차이는 경보로 세지 않는다. 1주 단위 때문에 생긴 차이라 손쓸 방법이 없기 때문이다. --- config/baskets.yaml | 4 + core/basket_observability.py | 17 ++- core/basket_rebalancer.py | 104 ++++++++++++++++ core/integer_allocation.py | 177 +++++++++++++++++++++++++++ tests/test_integer_allocation.py | 199 +++++++++++++++++++++++++++++++ tests/test_rebalance_review.py | 4 +- 6 files changed, 503 insertions(+), 2 deletions(-) create mode 100644 core/integer_allocation.py create mode 100644 tests/test_integer_allocation.py diff --git a/config/baskets.yaml b/config/baskets.yaml index 734e5ae6..5cdcb4b8 100644 --- a/config/baskets.yaml +++ b/config/baskets.yaml @@ -234,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_observability.py b/core/basket_observability.py index ae674efb..bd1f71b5 100644 --- a/core/basket_observability.py +++ b/core/basket_observability.py @@ -64,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 cfc648ce..808d20f2 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -963,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: @@ -1150,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} @@ -1170,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): """매도 후 남는 현금과 예상 비용 안에서 매수 수량을 정한다. diff --git a/core/integer_allocation.py b/core/integer_allocation.py new file mode 100644 index 00000000..9156e0df --- /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/tests/test_integer_allocation.py b/tests/test_integer_allocation.py new file mode 100644 index 00000000..bdb42562 --- /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_rebalance_review.py b/tests/test_rebalance_review.py index dbf3625b..181d227a 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 From fca215d47a3e0c7db7456e67f2be63410fd99a5b Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Tue, 20 Oct 2026 17:40:14 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=EC=A3=BC=EB=AC=B8=20=EC=A7=81=EC=A0=84?= =?UTF-8?q?=EC=97=90=20=EC=8B=9C=EC=84=B8=20=EB=82=A0=EC=A7=9C=EC=99=80=20?= =?UTF-8?q?=EA=B0=80=EA=B2=A9=20=EB=B2=94=EC=9C=84=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 바스켓 주문 가격은 데이터 공급자가 준 마지막 봉을 그대로 쓴다. 9/17~10/12처럼 공급자 자료가 멈추면 전날 가격으로 수량을 정하고, 모의 체결도 그 가격으로 남는다. 그래서 주문을 넣기 직전에 두 가지를 확인한다. - 시세 날짜가 오늘이 아니면 주문하지 않는다. 날짜는 거래 시간 판정과 같은 시계로 본다. - 전일 종가보다 30% 넘게 오르거나 내린 가격은 가격제한폭 밖이라 자료 오류로 보고 주문하지 않는다. 주문을 보류하면 STALE_QUOTE_BLOCKED를 하루에 한 번 남긴다. 10/15~10/20 오전 측정은 모두 당일 시세였으므로 평소 운영을 막지는 않는다. 10/13 검증 도구(verify_basket_snapshot_fixes.py)는 10/12 종가로 10/13 주문을 재현하는 도구라, 그 안에서만 이번 점검과 이번 브랜치에서 바꾼 다른 규칙을 당시 기준으로 고정했다. 고정한 상태로 다시 돌려서 예전 결과가 그대로 나오는 것을 확인했다. --- core/basket_rebalancer.py | 55 ++++++++++++++ tests/test_quote_guard.py | 105 ++++++++++++++++++++++++++ tools/verify_basket_snapshot_fixes.py | 15 ++++ 3 files changed, 175 insertions(+) create mode 100644 tests/test_quote_guard.py diff --git a/core/basket_rebalancer.py b/core/basket_rebalancer.py index 808d20f2..e7aa03da 100644 --- a/core/basket_rebalancer.py +++ b/core/basket_rebalancer.py @@ -1417,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() @@ -1483,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 + # ------------------------------------------------------------------ # 리포트 # ------------------------------------------------------------------ @@ -1558,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/tests/test_quote_guard.py b/tests/test_quote_guard.py new file mode 100644 index 00000000..5bcaf8e4 --- /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/tools/verify_basket_snapshot_fixes.py b/tools/verify_basket_snapshot_fixes.py index 58610d7f..317148d0 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 == [] From 470c8b0c2a204475cc9bbaa087137fc5af4212d8 Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Tue, 20 Oct 2026 17:40:29 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=ED=88=AC=EC=9E=90=20=EB=B0=A9=EC=8B=9D=20?= =?UTF-8?q?=EC=A1=B0=EC=82=AC=20=EB=AC=B8=EC=84=9C=EC=99=80=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EB=B3=80=EA=B2=BD=20=EA=B2=80=EC=A6=9D=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 10월 급락 뒤 개인 투자자의 거래 실태, 많이 쓰는 자동매매 도구, 공개된 뒤에도 성과가 남은 운용 방식, 지금 시장 상황을 조사해 docs/MARKET_RESEARCH_20261020.md에 정리했다. 조사해 보니 새 매매 신호를 더할 근거는 없었고, 이미 정해 둔 규칙이 설계대로 돌게 고치는 쪽이 맞았다. 이번 브랜치의 변경이 그 결과다. 반영하지 않은 것(다자산 편입, 200TR 교체, 대형주 계좌 현금의 CD 운용, 같은 주식 비중의 비교지수)과 그 이유도 적었다. tools/verify_policy_changes_20261020.py는 운영 DB를 읽기 전용으로 복사하고 시세를 고정한 뒤, 이번에 바꾼 규칙을 실제 계획 → 실행 경로로 돌려 본다. 원본 DB 해시가 그대로인지도 확인한다. README와 프로젝트 안내서에 새 문서와 설정 항목을 연결했다. --- README.md | 2 + docs/MARKET_RESEARCH_20261020.md | 94 +++++++++++ docs/PROJECT_GUIDE.md | 2 + tools/verify_policy_changes_20261020.py | 208 ++++++++++++++++++++++++ 4 files changed, 306 insertions(+) create mode 100644 docs/MARKET_RESEARCH_20261020.md create mode 100644 tools/verify_policy_changes_20261020.py diff --git a/README.md b/README.md index 004e7b60..0a3cb291 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/docs/MARKET_RESEARCH_20261020.md b/docs/MARKET_RESEARCH_20261020.md new file mode 100644 index 00000000..8206b7df --- /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 c255852b..0b794d10 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/tools/verify_policy_changes_20261020.py b/tools/verify_policy_changes_20261020.py new file mode 100644 index 00000000..5b9b7cec --- /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()