"""실행 품질(슬리피지 bp·체결률) 집계 — 순수 계산 모듈 (settings/DB/네트워크 참조 없음). TradeHistory.slippage 는 (체결가 − 주문가)/주문가×100 [%], side 무관 원시값이다 (trade_executor.slippage_cost_bp_v2 주석). BUY/SELL 을 그대로 평균하면 상쇄되므로 side 보정(불리 = +) bp 로 바꿔 집계한다. 보정식은 slippage_cost_bp_v2 와 동일하며 (trade_executor 는 import 비용이 커서 복제) 동일성은 테스트로 고정한다. """ from __future__ import annotations import math from collections import defaultdict from typing import Any, Iterable, Mapping LOW_SAMPLE_THRESHOLD = 30 # n < 30 이면 low_sample def slippage_cost_bp_v3(order_type: str, slippage_pct) -> float | None: """원시 슬리피지(%) → side 보정 비용(bp, 불리 = +). 해석 불가면 None.""" try: val = float(slippage_pct) except (TypeError, ValueError): return None if not math.isfinite(val): return None side = str(order_type or "").upper() if side == "BUY": return val * 100.0 if side == "SELL": return -val * 100.0 return None def _hour_of(row: Mapping[str, Any]): ts = row.get("executed_at") or row.get("created_at") return getattr(ts, "hour", None) def _bucket_stats(rows: list, min_n: int) -> dict: costs: list[float] = [] for r in rows: c = slippage_cost_bp_v3(r.get("order_type"), r.get("slippage")) if c is not None: costs.append(c) n_all = len(rows) n = len(costs) verified = [r.get("fill_verified") for r in rows if r.get("fill_verified") is not None] filled = sum(1 for r in rows if (r.get("executed_quantity") or 0) > 0) return { "n_orders": n_all, "n_slippage": n, "fill_rate": round(filled / n_all, 4) if n_all else None, "mean_cost_bp": round(sum(costs) / n, 3) if n else None, "mean_abs_bp": round(sum(abs(c) for c in costs) / n, 3) if n else None, "fill_verified_ratio": round(sum(1 for v in verified if v) / len(verified), 4) if verified else None, "low_sample": n < min_n, } def aggregate_execution_quality_v1( rows: Iterable[Mapping[str, Any]], min_n: int = LOW_SAMPLE_THRESHOLD ) -> dict: """행(dict: order_type, slippage, strategy_type, executed_quantity, executed_at, fill_verified)을 전체/전략별/시간대별로 집계한다. slippage NULL 행은 n_orders 에만 포함된다.""" rows = list(rows) by_strategy: dict[str, list] = defaultdict(list) by_hour: dict[int, list] = defaultdict(list) for r in rows: by_strategy[str(r.get("strategy_type") or "unknown")].append(r) h = _hour_of(r) if h is not None: by_hour[int(h)].append(r) return { "overall": _bucket_stats(rows, min_n), "by_strategy": {k: _bucket_stats(v, min_n) for k, v in sorted(by_strategy.items())}, "by_hour": {h: _bucket_stats(v, min_n) for h, v in sorted(by_hour.items())}, "min_n": min_n, }