"""杠杆按时段桶聚合。""" from __future__ import annotations import math from collections import defaultdict from typing import Any, Iterable, Sequence from packages.domain.buckets import shanghai_bucket from packages.domain.leverage import LEVERAGE_FORMULA_VERSION def _percentile(sorted_vals: Sequence[float], p: float) -> float | None: """线性插值百分位;p in [0,100]。""" if not sorted_vals: return None if len(sorted_vals) == 1: return float(sorted_vals[0]) p = max(0.0, min(100.0, float(p))) k = (len(sorted_vals) - 1) * (p / 100.0) f = math.floor(k) c = math.ceil(k) if f == c: return float(sorted_vals[int(k)]) d0 = sorted_vals[f] * (c - k) d1 = sorted_vals[c] * (k - f) return float(d0 + d1) def summarize_values(values: Sequence[float], *, min_leverage: float) -> dict[str, Any]: if not values: return { "n": 0, "mean": None, "median": None, "p25": None, "p75": None, "min": None, "max": None, "pct_ge_min": None, } xs = sorted(float(v) for v in values) n = len(xs) ge = sum(1 for v in xs if v >= float(min_leverage)) return { "n": n, "mean": sum(xs) / n, "median": _percentile(xs, 50), "p25": _percentile(xs, 25), "p75": _percentile(xs, 75), "min": xs[0], "max": xs[-1], "pct_ge_min": ge / n, } def bucket_label(bucket_start_min: int, bucket_minutes: int) -> str: """如 14:00 或 14:00-14:30。""" h, m = divmod(int(bucket_start_min), 60) start = f"{h:02d}:{m:02d}" if bucket_minutes >= 60 and bucket_minutes % 60 == 0 and m == 0: return f"{h:02d}:00" end_min = bucket_start_min + bucket_minutes eh, em = divmod(end_min % (24 * 60), 60) return f"{start}-{eh:02d}:{em:02d}" def all_bucket_starts(bucket_minutes: int) -> list[int]: if bucket_minutes <= 0 or 1440 % bucket_minutes != 0: # 允许非整除:仍按步进生成到 <1440 out = [] t = 0 while t < 1440: out.append(t) t += bucket_minutes return out return list(range(0, 1440, bucket_minutes)) def aggregate_leverage( rows: Iterable[dict[str, Any]], *, bucket_minutes: int = 60, min_leverage: float = 100.0, side: str = "both", ) -> list[dict[str, Any]]: """ rows: 需含 ts_ms, leverage, side。 返回按桶排序的聚合列表(含空桶)。 """ want = (side or "both").upper() by_bucket: dict[int, list[float]] = defaultdict(list) for r in rows: lev = r.get("leverage") if lev is None: continue try: lev_f = float(lev) except (TypeError, ValueError): continue if not math.isfinite(lev_f) or lev_f <= 0: continue s = str(r.get("side") or "").upper() if want in ("C", "P") and s != want: continue if want == "BOTH" and s not in ("C", "P"): continue b = shanghai_bucket(int(r["ts_ms"]), bucket_minutes) by_bucket[b].append(lev_f) out: list[dict[str, Any]] = [] for b in all_bucket_starts(bucket_minutes): stats = summarize_values(by_bucket.get(b, []), min_leverage=min_leverage) out.append( { "bucket_start_min": b, "bucket_hour": b // 60 if bucket_minutes >= 60 else None, "label": bucket_label(b, bucket_minutes), **stats, } ) return out def summarize_distribution(values: Sequence[float]) -> dict[str, Any]: """通用分布摘要(无达标线)。""" if not values: return { "n": 0, "mean": None, "median": None, "p25": None, "p75": None, "min": None, "max": None, } xs = sorted(float(v) for v in values) n = len(xs) return { "n": n, "mean": sum(xs) / n, "median": _percentile(xs, 50), "p25": _percentile(xs, 25), "p75": _percentile(xs, 75), "min": xs[0], "max": xs[-1], } def aggregate_move_points( samples: Iterable[dict[str, Any]], *, bucket_minutes: int = 60, ) -> list[dict[str, Any]]: """ samples: {ts_ms, move_signed, move_abs} 桶内同时给出 signed / abs 分布。 """ by_signed: dict[int, list[float]] = defaultdict(list) by_abs: dict[int, list[float]] = defaultdict(list) for s in samples: ts = s.get("ts_ms") signed = s.get("move_signed") if ts is None or signed is None: continue try: signed_f = float(signed) abs_f = float(s.get("move_abs", abs(signed_f))) except (TypeError, ValueError): continue if not math.isfinite(signed_f): continue b = shanghai_bucket(int(ts), bucket_minutes) by_signed[b].append(signed_f) by_abs[b].append(abs_f) out: list[dict[str, Any]] = [] for b in all_bucket_starts(bucket_minutes): signed_stats = summarize_distribution(by_signed.get(b, [])) abs_stats = summarize_distribution(by_abs.get(b, [])) out.append( { "bucket_start_min": b, "bucket_hour": b // 60 if bucket_minutes >= 60 else None, "label": bucket_label(b, bucket_minutes), "n": signed_stats["n"], "signed": signed_stats, "abs": abs_stats, # 便捷字段(看板默认用 abs 均值) "mean_signed": signed_stats["mean"], "median_signed": signed_stats["median"], "mean_abs": abs_stats["mean"], "median_abs": abs_stats["median"], } ) return out def build_move_samples( rows: Iterable[dict[str, Any]], settlements: dict[str, dict[str, Any]], *, side: str = "both", now_ms: int | None = None, ) -> tuple[list[dict[str, Any]], dict[str, Any]]: """ 对期权样本计算波动点数。 返回 (settled_samples, meta)。 meta: pending_expiry, pending_count, settled_count, pending_ymds, settled_ymds """ import time from packages.domain.move_points import move_points as calc_move now = int(now_ms if now_ms is not None else time.time() * 1000) want = (side or "both").upper() settled: list[dict[str, Any]] = [] pending_ymds: set[str] = set() settled_ymds: set[str] = set() pending_count = 0 settled_count = 0 for r in rows: s = str(r.get("side") or "").upper() if want in ("C", "P") and s != want: continue if want == "BOTH" and s not in ("C", "P"): continue ymd = str(r.get("expiry_ymd") or "") idx = r.get("index_px") ts = r.get("ts_ms") if not ymd or idx is None or ts is None: continue settle = settlements.get(ymd) if settle is None or int(settle.get("settle_ts_ms") or 0) > now: pending_ymds.add(ymd) pending_count += 1 continue try: signed = calc_move(float(settle["settle_index_px"]), float(idx)) except (TypeError, ValueError): pending_ymds.add(ymd) pending_count += 1 continue settled_ymds.add(ymd) settled_count += 1 settled.append( { "ts_ms": int(ts), "expiry_ymd": ymd, "side": s, "index_at_t": float(idx), "settle_index_px": float(settle["settle_index_px"]), "move_signed": signed, "move_abs": abs(signed), } ) meta = { "pending_expiry": pending_count > 0, "pending_count": pending_count, "settled_count": settled_count, "pending_ymds": sorted(pending_ymds), "settled_ymds": sorted(settled_ymds), } return settled, meta def move_points_stats_payload( rows: Iterable[dict[str, Any]], settlements: dict[str, dict[str, Any]], *, range_info: dict[str, Any], bucket_minutes: int, side: str, now_ms: int | None = None, ) -> dict[str, Any]: from packages.domain.move_points import MOVE_POINTS_FORMULA_VERSION samples, meta = build_move_samples( rows, settlements, side=side, now_ms=now_ms ) buckets = aggregate_move_points(samples, bucket_minutes=bucket_minutes) return { "status": "ok", "formula_version": MOVE_POINTS_FORMULA_VERSION, "move_def": "settle_index_px - index_at(t)", "range": range_info["range"], "date": range_info["anchor"], "start_ymd": range_info["start_ymd"], "end_ymd": range_info["end_ymd"], "days": range_info["days"], "month_mode": range_info.get("month_mode"), "side": side, "bucket_minutes": bucket_minutes, "pending_expiry": meta["pending_expiry"], "pending_count": meta["pending_count"], "settled_count": meta["settled_count"], "pending_ymds": meta["pending_ymds"], "settled_ymds": meta["settled_ymds"], "sample_count": meta["settled_count"], "buckets": buckets, "message": ( "部分样本未到期或缺少结算锚点,已排除出分布" if meta["pending_expiry"] else None ), } def leverage_stats_payload( rows: Iterable[dict[str, Any]], *, range_info: dict[str, Any], bucket_minutes: int, min_leverage: float, side: str, ) -> dict[str, Any]: buckets = aggregate_leverage( rows, bucket_minutes=bucket_minutes, min_leverage=min_leverage, side=side, ) total_n = sum(int(b["n"]) for b in buckets) return { "status": "ok", "formula_version": LEVERAGE_FORMULA_VERSION, "leverage_def": "index_px / ask", "range": range_info["range"], "date": range_info["anchor"], "start_ymd": range_info["start_ymd"], "end_ymd": range_info["end_ymd"], "days": range_info["days"], "month_mode": range_info.get("month_mode"), "side": side, "bucket_minutes": bucket_minutes, "min_leverage": min_leverage, "sample_count": total_n, "buckets": buckets, }