first commit

This commit is contained in:
dekun
2026-08-01 10:33:19 +08:00
commit d9a34d4f20
72 changed files with 5499 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
"""领域口径:杠杆、时段桶、波动点数。变更需升版本。"""
from __future__ import annotations
from packages.domain.aggregate import (
aggregate_leverage,
aggregate_move_points,
summarize_values,
)
from packages.domain.buckets import shanghai_bucket, shanghai_day
from packages.domain.expiry import expiry_ms_from_ymd
from packages.domain.leverage import LEVERAGE_FORMULA_VERSION, option_leverage
from packages.domain.move_points import MOVE_POINTS_FORMULA_VERSION, move_points
from packages.domain.range import resolve_range, today_shanghai
__all__ = [
"LEVERAGE_FORMULA_VERSION",
"MOVE_POINTS_FORMULA_VERSION",
"option_leverage",
"move_points",
"expiry_ms_from_ymd",
"shanghai_day",
"shanghai_bucket",
"resolve_range",
"today_shanghai",
"aggregate_leverage",
"aggregate_move_points",
"summarize_values",
]
+342
View File
@@ -0,0 +1,342 @@
"""杠杆按时段桶聚合。"""
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,
}
+41
View File
@@ -0,0 +1,41 @@
"""Asia/Shanghai 日切与时段桶。时间戳一律 UTC ms 入,上海出。"""
from __future__ import annotations
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
_SH = ZoneInfo("Asia/Shanghai")
def _to_shanghai(ts_ms: int) -> datetime:
return datetime.fromtimestamp(int(ts_ms) / 1000.0, tz=_SH)
def shanghai_day(ts_ms: int) -> str:
"""上海自然日 YYYY-MM-DD。"""
return _to_shanghai(ts_ms).strftime("%Y-%m-%d")
def shanghai_bucket(ts_ms: int, bucket_minutes: int = 60) -> int:
"""
日内时段桶起点(分钟,0–1439)。
默认 60 → 0,60,120,...,1380(即小时 023)。
"""
if bucket_minutes <= 0:
raise ValueError("bucket_minutes must be > 0")
dt = _to_shanghai(ts_ms)
minutes = dt.hour * 60 + dt.minute
return (minutes // bucket_minutes) * bucket_minutes
def shanghai_bucket_hour(ts_ms: int) -> int:
"""023 小时桶。"""
return shanghai_bucket(ts_ms, 60) // 60
def rolling_day_start(anchor_ymd: str, days: int) -> str:
"""锚点日(含)往前 days-1 天的起始 YYYY-MM-DD。"""
d = datetime.strptime(anchor_ymd, "%Y-%m-%d").date()
start = d - timedelta(days=max(0, days - 1))
return start.strftime("%Y-%m-%d")
+15
View File
@@ -0,0 +1,15 @@
"""OKX / 欧式期权惯例:到期日当日 08:00 UTC(上海 16:00)。"""
from __future__ import annotations
from datetime import datetime, timezone
def expiry_ms_from_ymd(ymd: str) -> int:
"""YYMMDD → 到期毫秒时间戳(UTC 08:00)。"""
ymd = (ymd or "").strip()
if len(ymd) != 6 or not ymd.isdigit():
raise ValueError(f"invalid expiry ymd: {ymd!r}")
yy, mm, dd = int(ymd[0:2]), int(ymd[2:4]), int(ymd[4:6])
dt = datetime(2000 + yy, mm, dd, 8, 0, 0, tzinfo=timezone.utc)
return int(dt.timestamp() * 1000)
+14
View File
@@ -0,0 +1,14 @@
"""杠杆口径 v1:杠杆 = 标的指数 ÷ 期权卖一(ask)。"""
from __future__ import annotations
LEVERAGE_FORMULA_VERSION = "1.0"
def option_leverage(index_px: float, ask: float | None) -> float | None:
"""index_px / askask 无效时返回 None。"""
if index_px is None or index_px <= 0:
return None
if ask is None or ask <= 0:
return None
return float(index_px) / float(ask)
+10
View File
@@ -0,0 +1,10 @@
"""波动点数口径 v1:到期指数 − 时段代表指数。"""
from __future__ import annotations
MOVE_POINTS_FORMULA_VERSION = "1.0"
def move_points(settle_index_px: float, index_at_t: float) -> float:
"""signed 点数;绝对值由调用方取 abs。"""
return float(settle_index_px) - float(index_at_t)
+77
View File
@@ -0,0 +1,77 @@
"""日/周/月时间范围解析(Asia/Shanghai)。"""
from __future__ import annotations
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
_SH = ZoneInfo("Asia/Shanghai")
def today_shanghai(now: datetime | None = None) -> str:
n = (now or datetime.now(tz=_SH)).astimezone(_SH)
return n.strftime("%Y-%m-%d")
def parse_ymd(ymd: str) -> date:
return datetime.strptime(ymd, "%Y-%m-%d").date()
def day_bounds_ms(ymd: str) -> tuple[int, int]:
"""上海自然日 [start_ms, end_ms)end 为次日 00:00。"""
d = parse_ymd(ymd)
start = datetime(d.year, d.month, d.day, 0, 0, 0, tzinfo=_SH)
end = start + timedelta(days=1)
return int(start.timestamp() * 1000), int(end.timestamp() * 1000)
def resolve_range(
range_name: str,
anchor_ymd: str | None = None,
*,
month_mode: str = "rolling_30",
now: datetime | None = None,
) -> dict:
"""
返回:
anchor, start_ymd, end_ymd (含), start_ms, end_ms (半开区间), days
"""
anchor = anchor_ymd or today_shanghai(now)
parse_ymd(anchor) # validate
name = (range_name or "day").strip().lower()
if name == "day":
start_ymd = end_ymd = anchor
days = 1
elif name == "week":
end_ymd = anchor
start = parse_ymd(anchor) - timedelta(days=6)
start_ymd = start.strftime("%Y-%m-%d")
days = 7
elif name == "month":
end_ymd = anchor
mode = (month_mode or "rolling_30").strip().lower()
if mode in ("calendar", "natural", "natural_month"):
d = parse_ymd(anchor)
start_ymd = d.replace(day=1).strftime("%Y-%m-%d")
days = (parse_ymd(end_ymd) - parse_ymd(start_ymd)).days + 1
else:
# rolling_30
start = parse_ymd(anchor) - timedelta(days=29)
start_ymd = start.strftime("%Y-%m-%d")
days = 30
else:
raise ValueError(f"invalid range: {range_name!r}")
start_ms, _ = day_bounds_ms(start_ymd)
_, end_ms = day_bounds_ms(end_ymd)
return {
"range": name,
"anchor": anchor,
"start_ymd": start_ymd,
"end_ymd": end_ymd,
"start_ms": start_ms,
"end_ms": end_ms,
"days": days,
"month_mode": month_mode if name == "month" else None,
}