diff --git a/spec/55_execution_simulator_contract.yaml b/spec/55_execution_simulator_contract.yaml index 482dc6f..878703d 100644 --- a/spec/55_execution_simulator_contract.yaml +++ b/spec/55_execution_simulator_contract.yaml @@ -19,8 +19,12 @@ simulation_parameters: etf: 1주 slippage_model: type: fixed_spread - bps: 5 - note: 시장가 주문 기준 평균 슬리피지. 추후 실측 데이터로 보정 예정. + bps: calibration_registry.EXECUTION_SLIPPAGE_BPS + note: > + 시장가 주문 기준 평균 슬리피지. WBS-7.6(2026-06-22)에서 + spec/calibration_registry.yaml의 EXECUTION_SLIPPAGE_BPS(5bps, EXPERT_PRIOR)로 + 정규화. 실측 거래 데이터 20건 이상 누적 후 actual_slippage 추적해 + 필요시 보정 (차이 > 1bps 시). cash_floor: d_plus_2_recognition: true minimum_reserve_krw: 10000000 diff --git a/spec/calibration_registry.yaml b/spec/calibration_registry.yaml index 67d7f23..574d1d6 100644 --- a/spec/calibration_registry.yaml +++ b/spec/calibration_registry.yaml @@ -1861,6 +1861,28 @@ thresholds: 사례에서 cluster 과다노출 시 손실 회피 효과 측정. sunset_date: '2026-12-31' live_sample_requirement: 5 +- id: EXECUTION_SLIPPAGE_BPS + value: 5 + unit: basis_points + source: EXPERT_PRIOR + sample_n: 0 + last_calibrated: null + owner_formula: EXECUTION_SIMULATOR_V1 + spec_location: spec/55_execution_simulator_contract.yaml:slippage_model.bps + notes: > + WBS-7.6(2026-06-22) — 시장가 주문 기준 평균 슬리피지를 5bps로 하드코딩하던 + 값을 정규화. 지정가 주문 전략(호가단위 내림, limit_price 설정)과는 별개로, + 슬리피지 미예측 시나리오나 시장가 반강제 주문 시 적용되는 일괄 손실률. + 실측: 현금화 거래 20건 이상에서 actual_price vs limit_price 차이를 + 추적해 (Close × 시간대별 호가스프레드 모델) 반영해야 함. + 기존 "5bps는 이론치, 실측 보정 예정"이라는 spec 주석이 더 이상 유효하려면 + 이 threshold로 정규화 필수. + sunset_date: '2026-12-31' + live_sample_requirement: 20 + calibration_trigger: > + EXECUTION_QUALITY_SCORE_V1 → actual_slippage(Close 기준) 추적. + 20건 이상 거래 누적 시 average_actual_slippage 계산 후 + 현재 5bps와 비교. 차이 > 1bps이면 실측값으로 갱신. calibration_policy: honest_disclosure_required: true diff --git a/spec/exit/qualitative_sell_strategy_v1.yaml b/spec/exit/qualitative_sell_strategy_v1.yaml index 0740bde..f884e8c 100644 --- a/spec/exit/qualitative_sell_strategy_v1.yaml +++ b/spec/exit/qualitative_sell_strategy_v1.yaml @@ -80,8 +80,18 @@ qualitative_sell_strategy: 가중치로 종합." data_sources: - note: "2026-06-21 세션 실측 결과. investing.com 직접 스크래핑은 403(Cloudflare) 차단 확인 — - 자동 수집 경로로 채택하지 않는다." + note: > + 2026-06-21 세션 실측 결과. investing.com 직접 스크래핑은 403(Cloudflare) 차단 확인 — + 자동 수집 경로로 채택하지 않는다. + + WBS-7.9(2026-06-22): Naver 도메인(finance.naver.com)은 현재 무인증 접근 가능(sise_day, frgn 엔드포인트). + 다만 향후 Cloudflare 차단 가능성에 대비해 fetch_naver_market_data_v1.py에서: + - HTTP 403 응답 감지 시 status="CLOUDFLARE_BLOCKED_403" 반환 (무조건 실패 대신 구조화된 에러) + - requests.RequestException 캐치로 네트워크 오류 처리 + - 호출부(build_qualitative_sell_inputs_v1.py)에서 상태 확인 후 DATA_MISSING_SAFE 처리 + + 실제 차단 발생 시 대체 경로 없음(KRX는 OTP 필수, investing.com은 차단됨). + 운영: Cloudflare_BLOCKED_403 상태 발생 시 slack/로그 경고 + 수동 실행. relative_return_20d: tool: "tools/fetch_naver_market_data_v1.py:compute_relative_return_20d" source: "finance.naver.com/item/sise_day.naver (무인증, 동작 확인)" diff --git a/tools/fetch_naver_market_data_v1.py b/tools/fetch_naver_market_data_v1.py index d869307..7125585 100644 --- a/tools/fetch_naver_market_data_v1.py +++ b/tools/fetch_naver_market_data_v1.py @@ -55,7 +55,24 @@ def fetch_price_history(session: requests.Session, code: str, pages: int = 3) -> rows: list[dict[str, Any]] = [] for page in range(1, pages + 1): url = f"https://finance.naver.com/item/sise_day.naver?code={code}&page={page}" - resp = session.get(url, timeout=10) + try: + resp = session.get(url, timeout=10) + if resp.status_code == 403: + return { + "status": "CLOUDFLARE_BLOCKED_403", + "rows": [], + "error": "Cloudflare rejected request (403 Forbidden)", + "source_url": url, + "wbs_ref": "WBS-7.9: Naver 스크래핑 Cloudflare 모니터링", + } + resp.raise_for_status() + except requests.RequestException as e: + return { + "status": "FETCH_ERROR", + "rows": [], + "error": str(e), + "source_url": url, + } resp.encoding = "euc-kr" soup = BeautifulSoup(resp.text, "html.parser") table = soup.find("table", {"class": "type2"}) @@ -88,7 +105,24 @@ def fetch_foreign_institution_flow(session: requests.Session, code: str, pages: rows: list[dict[str, Any]] = [] for page in range(1, pages + 1): url = f"https://finance.naver.com/item/frgn.naver?code={code}&page={page}" - resp = session.get(url, timeout=10) + try: + resp = session.get(url, timeout=10) + if resp.status_code == 403: + return { + "status": "CLOUDFLARE_BLOCKED_403", + "rows": [], + "error": "Cloudflare rejected request (403 Forbidden)", + "source_url": url, + "wbs_ref": "WBS-7.9: Naver 스크래핑 Cloudflare 모니터링", + } + resp.raise_for_status() + except requests.RequestException as e: + return { + "status": "FETCH_ERROR", + "rows": [], + "error": str(e), + "source_url": url, + } resp.encoding = "euc-kr" soup = BeautifulSoup(resp.text, "html.parser") for table in soup.find_all("table", {"class": "type2"}):