WBS-7.6: 슬리피지 실측 캡처 스캐폴딩
spec/55_execution_simulator_contract.yaml의 5bps 슬리피지 가정치를 검증할 실측 캡처 경로가 없었다. 주문 실행은 여전히 사람이 HTS에서 직접 한다(governance/rules/06 준수, API로 체결을 가져오지 않음) — 실행 후 사람이 의도가/실제체결가를 수동 기록하면 SQLite에 누적되고, 5건 미만이면 항상 DATA_GATED를 정직하게 반환한다(추정 금지).
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"""WBS-7.6(2026-06-21) — 실거래 슬리피지 실측 캡처 스캐폴딩.
|
||||
|
||||
spec/55_execution_simulator_contract.yaml의 slippage_model(bps=5)은 이론치이며
|
||||
"추후 실측 데이터로 보정 예정"이라는 메모만 있고 실제 캡처 경로가 없었다. 이 모듈은
|
||||
주문은 사람이 HTS에서 직접 실행한다는 governance/rules/06 원칙을 그대로 유지한 채
|
||||
(API로 체결을 가져오지 않는다), 실행 후 사람이 수동으로 기록한 실제 체결가를
|
||||
누적해 가정치(5bps)와 비교할 수 있게 한다. 5건 미만이면 항상 DATA_GATED로 보고한다
|
||||
— 추정 금지 원칙(spec/00_execution_contract.yaml)을 따른다. 표준 라이브러리
|
||||
sqlite3만 사용한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from src.quant_engine.storage_backend_v1 import StoreSpec, default_sqlite_store_path, normalize_store_spec
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS realized_slippage_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL,
|
||||
side TEXT NOT NULL CHECK (side IN ('BUY', 'SELL')),
|
||||
intended_price REAL NOT NULL,
|
||||
actual_fill_price REAL NOT NULL,
|
||||
slippage_bps_actual REAL NOT NULL,
|
||||
recorded_at TEXT NOT NULL,
|
||||
note TEXT,
|
||||
inserted_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
|
||||
ASSUMED_SLIPPAGE_BPS = 5.0
|
||||
MIN_SAMPLE_FOR_COMPARISON = 5
|
||||
|
||||
|
||||
def default_execution_slippage_store_path(root: Path) -> Path:
|
||||
return default_sqlite_store_path(root, "execution_slippage/execution_slippage.db")
|
||||
|
||||
|
||||
def resolve_store_path(spec: StoreSpec, root: Path) -> Path:
|
||||
backend, location = normalize_store_spec(
|
||||
spec, root, default_sqlite_name="execution_slippage/execution_slippage.db"
|
||||
)
|
||||
if backend != "sqlite":
|
||||
raise ValueError("execution_slippage_store_v1 currently executes on sqlite only.")
|
||||
return Path(location)
|
||||
|
||||
|
||||
def init_db(db_path: Path) -> None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.executescript(SCHEMA)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _compute_slippage_bps(intended_price: float, actual_fill_price: float, side: str) -> float:
|
||||
"""체결가가 의도가(지정가)보다 불리한 방향으로 움직인 만큼을 양수 bps로 환산한다.
|
||||
|
||||
BUY: 실제 체결가가 의도가보다 높으면(더 비싸게 샀으면) 양수 슬리피지.
|
||||
SELL: 실제 체결가가 의도가보다 낮으면(더 싸게 팔았으면) 양수 슬리피지.
|
||||
"""
|
||||
if intended_price <= 0:
|
||||
raise ValueError("intended_price must be > 0")
|
||||
direction = 1 if side.upper() == "BUY" else -1
|
||||
return direction * (actual_fill_price - intended_price) / intended_price * 10_000.0
|
||||
|
||||
|
||||
def insert_realized_slippage_sample(
|
||||
db_path: Path,
|
||||
*,
|
||||
ticker: str,
|
||||
side: str,
|
||||
intended_price: float,
|
||||
actual_fill_price: float,
|
||||
recorded_at: str,
|
||||
note: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
init_db(db_path)
|
||||
slippage_bps = _compute_slippage_bps(intended_price, actual_fill_price, side)
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO realized_slippage_samples "
|
||||
"(ticker, side, intended_price, actual_fill_price, slippage_bps_actual, recorded_at, note) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(ticker, side.upper(), intended_price, actual_fill_price, slippage_bps, recorded_at, note),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"side": side.upper(),
|
||||
"intended_price": intended_price,
|
||||
"actual_fill_price": actual_fill_price,
|
||||
"slippage_bps_actual": round(slippage_bps, 4),
|
||||
"recorded_at": recorded_at,
|
||||
}
|
||||
|
||||
|
||||
def fetch_all_samples(db_path: Path) -> list[dict[str, Any]]:
|
||||
if not db_path.exists():
|
||||
return []
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT ticker, side, intended_price, actual_fill_price, slippage_bps_actual, recorded_at, note "
|
||||
"FROM realized_slippage_samples ORDER BY recorded_at ASC"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def build_slippage_comparison_report(db_path: Path) -> dict[str, Any]:
|
||||
"""WBS-7.6 성공 하네스 — 5건 미만이면 DATA_GATED를 정직하게 반환한다(추정 금지)."""
|
||||
samples = fetch_all_samples(db_path)
|
||||
sample_n = len(samples)
|
||||
if sample_n < MIN_SAMPLE_FOR_COMPARISON:
|
||||
return {
|
||||
"status": "DATA_GATED",
|
||||
"sample_n": sample_n,
|
||||
"min_required": MIN_SAMPLE_FOR_COMPARISON,
|
||||
"assumed_slippage_bps": ASSUMED_SLIPPAGE_BPS,
|
||||
"actual_mean_slippage_bps": None,
|
||||
"note": f"실측 표본 {sample_n}/{MIN_SAMPLE_FOR_COMPARISON}건 — 비교 불가, 가정치(5bps) 유지",
|
||||
}
|
||||
actual_mean = sum(s["slippage_bps_actual"] for s in samples) / sample_n
|
||||
gap = abs(actual_mean - ASSUMED_SLIPPAGE_BPS)
|
||||
return {
|
||||
"status": "OK",
|
||||
"sample_n": sample_n,
|
||||
"assumed_slippage_bps": ASSUMED_SLIPPAGE_BPS,
|
||||
"actual_mean_slippage_bps": round(actual_mean, 4),
|
||||
"gap_bps": round(gap, 4),
|
||||
"recommendation": (
|
||||
"가정치(5bps) 유지" if gap <= 3.0 else "spec/55_execution_simulator_contract.yaml의 bps 값을 실측 평균으로 갱신 검토"
|
||||
),
|
||||
}
|
||||
Reference in New Issue
Block a user