270e114658
Replace paragraph text with day-hit stats (days/days_hit/pct) so leverage and move charts list each hour with counts. Co-authored-by: Cursor <cursoragent@cursor.com>
367 lines
12 KiB
Python
367 lines
12 KiB
Python
"""杠杆按时段桶聚合。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
from collections import defaultdict
|
|
from typing import Any, Iterable, Sequence
|
|
|
|
from packages.domain.buckets import shanghai_bucket, shanghai_day
|
|
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。
|
|
返回按桶排序的聚合列表(含空桶)。
|
|
额外字段:
|
|
days — 该时段有样本的上海自然日数
|
|
days_hit — 当日该时段均值 ≥ min_leverage 的天数
|
|
pct_days_hit — days_hit / days
|
|
"""
|
|
want = (side or "both").upper()
|
|
by_bucket: dict[int, list[float]] = defaultdict(list)
|
|
# bucket -> day_ymd -> leverages
|
|
by_bucket_day: dict[int, dict[str, list[float]]] = defaultdict(lambda: 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
|
|
ts = int(r["ts_ms"])
|
|
b = shanghai_bucket(ts, bucket_minutes)
|
|
day = shanghai_day(ts)
|
|
by_bucket[b].append(lev_f)
|
|
by_bucket_day[b][day].append(lev_f)
|
|
|
|
out: list[dict[str, Any]] = []
|
|
thr = float(min_leverage)
|
|
for b in all_bucket_starts(bucket_minutes):
|
|
stats = summarize_values(by_bucket.get(b, []), min_leverage=min_leverage)
|
|
day_map = by_bucket_day.get(b, {})
|
|
days = len(day_map)
|
|
days_hit = 0
|
|
for vals in day_map.values():
|
|
if vals and (sum(vals) / len(vals)) >= thr:
|
|
days_hit += 1
|
|
pct_days = (days_hit / days) if days else None
|
|
out.append(
|
|
{
|
|
"bucket_start_min": b,
|
|
"bucket_hour": b // 60 if bucket_minutes >= 60 else None,
|
|
"label": bucket_label(b, bucket_minutes),
|
|
**stats,
|
|
"days": days,
|
|
"days_hit": days_hit,
|
|
"pct_days_hit": pct_days,
|
|
}
|
|
)
|
|
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 分布。
|
|
额外:days — 该时段有结算样本的自然日数。
|
|
"""
|
|
by_signed: dict[int, list[float]] = defaultdict(list)
|
|
by_abs: dict[int, list[float]] = defaultdict(list)
|
|
by_bucket_days: dict[int, set[str]] = defaultdict(set)
|
|
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)
|
|
by_bucket_days[b].add(shanghai_day(int(ts)))
|
|
|
|
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"],
|
|
"days": len(by_bucket_days.get(b, set())),
|
|
"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,
|
|
}
|