fix: show expiry-filter sample stats so ops map filter effect is visible

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 10:48:40 +08:00
parent 3e492eb46c
commit 56f58a12bd
6 changed files with 143 additions and 27 deletions
+49 -8
View File
@@ -56,13 +56,13 @@ def leverage_stats(
info = _range_info(range, date) info = _range_info(range, date)
repo = Repository(s.db_path) repo = Repository(s.db_path)
try: try:
rows = repo.fetch_option_quotes( raw = repo.fetch_option_quotes(
start_ms=info["start_ms"], start_ms=info["start_ms"],
end_ms=info["end_ms"], end_ms=info["end_ms"],
side=side, side=side,
underlying=s.underlying, underlying=s.underlying,
) )
rows = filter_rows_min_hours_to_expiry(rows, min_hours=mh) rows, filt = filter_rows_min_hours_to_expiry(raw, min_hours=mh)
payload = leverage_stats_payload( payload = leverage_stats_payload(
rows, rows,
range_info=info, range_info=info,
@@ -71,6 +71,7 @@ def leverage_stats(
side=side, side=side,
) )
payload["min_hours"] = mh payload["min_hours"] = mh
payload["expiry_filter"] = filt
return payload return payload
finally: finally:
repo.close() repo.close()
@@ -94,13 +95,13 @@ def move_points_stats(
info = _range_info(range, date) info = _range_info(range, date)
repo = Repository(s.db_path) repo = Repository(s.db_path)
try: try:
rows = repo.fetch_option_quotes( raw = repo.fetch_option_quotes(
start_ms=info["start_ms"], start_ms=info["start_ms"],
end_ms=info["end_ms"], end_ms=info["end_ms"],
side=side, side=side,
underlying=s.underlying, underlying=s.underlying,
) )
rows = filter_rows_min_hours_to_expiry(rows, min_hours=mh) rows, filt = filter_rows_min_hours_to_expiry(raw, min_hours=mh)
ymds = sorted({str(r.get("expiry_ymd")) for r in rows if r.get("expiry_ymd")}) ymds = sorted({str(r.get("expiry_ymd")) for r in rows if r.get("expiry_ymd")})
# 懒回填:已到期但缺锚点时尽量补齐(本地指数优先,失败则跳过) # 懒回填:已到期但缺锚点时尽量补齐(本地指数优先,失败则跳过)
try: try:
@@ -121,6 +122,7 @@ def move_points_stats(
side=side, side=side,
) )
payload["min_hours"] = mh payload["min_hours"] = mh
payload["expiry_filter"] = filt
return payload return payload
finally: finally:
repo.close() repo.close()
@@ -134,19 +136,58 @@ def ops_map(
bucket_minutes: int = Query(default=60, ge=15, le=120), bucket_minutes: int = Query(default=60, ge=15, le=120),
min_hours: float = Query(default=12, ge=8, le=48), min_hours: float = Query(default=12, ge=8, le=48),
) -> dict: ) -> dict:
# 直接算一遍,避免路由函数互相调用时 Query 默认值问题
s = get_settings()
mh = _validate_min_hours(min_hours) mh = _validate_min_hours(min_hours)
lev = leverage_stats( info = _range_info(range, date)
range=range, date=date, side=side, bucket_minutes=bucket_minutes, min_hours=mh repo = Repository(s.db_path)
try:
raw = repo.fetch_option_quotes(
start_ms=info["start_ms"],
end_ms=info["end_ms"],
side=side,
underlying=s.underlying,
) )
mov = move_points_stats( rows, filt = filter_rows_min_hours_to_expiry(raw, min_hours=mh)
range=range, date=date, side=side, bucket_minutes=bucket_minutes, min_hours=mh lev = leverage_stats_payload(
rows,
range_info=info,
bucket_minutes=bucket_minutes,
min_leverage=float(s.min_option_leverage),
side=side,
) )
lev["min_hours"] = mh
lev["expiry_filter"] = filt
ymds = sorted({str(r.get("expiry_ymd")) for r in rows if r.get("expiry_ymd")})
try:
ensure_settlements_for_ymds(
repo,
ymds,
underlying=s.underlying,
index_inst_id=s.index_inst_id,
)
except Exception: # noqa: BLE001
pass
settlements = repo.list_settlements(ymds)
mov = move_points_stats_payload(
rows,
settlements,
range_info=info,
bucket_minutes=bucket_minutes,
side=side,
)
mov["min_hours"] = mh
mov["expiry_filter"] = filt
return { return {
"range": range, "range": range,
"date": lev.get("date"), "date": lev.get("date"),
"side": side, "side": side,
"bucket_minutes": bucket_minutes, "bucket_minutes": bucket_minutes,
"min_hours": mh, "min_hours": mh,
"expiry_filter": filt,
"leverage": lev, "leverage": lev,
"move_points": mov, "move_points": mov,
} }
finally:
repo.close()
+33 -5
View File
@@ -15,18 +15,46 @@ def hours_to_expiry_at(ts_ms: int, expiry_ymd: str) -> float | None:
return (ems - int(ts_ms)) / 3_600_000.0 return (ems - int(ts_ms)) / 3_600_000.0
def hours_stats(rows: Iterable[dict[str, Any]]) -> dict[str, Any]:
"""样本在采样时刻的距到期小时分布。"""
vals: list[float] = []
for r in rows:
h = hours_to_expiry_at(int(r.get("ts_ms") or 0), str(r.get("expiry_ymd") or ""))
if h is not None:
vals.append(float(h))
if not vals:
return {"n": 0, "min_hours": None, "max_hours": None}
return {
"n": len(vals),
"min_hours": min(vals),
"max_hours": max(vals),
}
def filter_rows_min_hours_to_expiry( def filter_rows_min_hours_to_expiry(
rows: Iterable[dict[str, Any]], rows: Iterable[dict[str, Any]],
*, *,
min_hours: float, min_hours: float,
) -> list[dict[str, Any]]: ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""保留采样时距到期 ≥ min_hours 的样本。""" """保留采样时距到期 ≥ min_hours 的样本;附带过滤前后统计"""
out: list[dict[str, Any]] = [] raw = list(rows)
before = hours_stats(raw)
mh = float(min_hours) mh = float(min_hours)
for r in rows: out: list[dict[str, Any]] = []
for r in raw:
h = hours_to_expiry_at(int(r.get("ts_ms") or 0), str(r.get("expiry_ymd") or "")) h = hours_to_expiry_at(int(r.get("ts_ms") or 0), str(r.get("expiry_ymd") or ""))
if h is None: if h is None:
continue continue
if h >= mh: if h >= mh:
out.append(r) out.append(r)
return out after = hours_stats(out)
meta = {
"min_hours_filter": mh,
"raw_sample_count": before["n"],
"filtered_sample_count": after["n"],
"raw_hours_min": before["min_hours"],
"raw_hours_max": before["max_hours"],
"filtered_hours_min": after["min_hours"],
"filtered_hours_max": after["max_hours"],
}
return out, meta
+3 -1
View File
@@ -14,6 +14,8 @@ def test_filter_min_hours():
{"ts_ms": ts_10h, "expiry_ymd": ymd, "side": "P"}, {"ts_ms": ts_10h, "expiry_ymd": ymd, "side": "P"},
] ]
assert hours_to_expiry_at(ts_20h, ymd) == 20 assert hours_to_expiry_at(ts_20h, ymd) == 20
kept = filter_rows_min_hours_to_expiry(rows, min_hours=12) kept, meta = filter_rows_min_hours_to_expiry(rows, min_hours=12)
assert len(kept) == 1 assert len(kept) == 1
assert kept[0]["side"] == "C" assert kept[0]["side"] == "C"
assert meta["raw_sample_count"] == 2
assert meta["filtered_sample_count"] == 1
+17 -1
View File
@@ -712,7 +712,23 @@
}); });
const lev = d.leverage || {}; const lev = d.leverage || {};
document.getElementById("opsRange").textContent = (lev.start_ymd || "—") + " → " + (lev.end_ymd || "—"); document.getElementById("opsRange").textContent = (lev.start_ymd || "—") + " → " + (lev.end_ymd || "—");
document.getElementById("opsSamples").textContent = "杠杆样本 " + (lev.sample_count ?? 0); const filt = d.expiry_filter || lev.expiry_filter || {};
const rawN = filt.raw_sample_count;
const keptN = filt.filtered_sample_count != null ? filt.filtered_sample_count : (lev.sample_count ?? 0);
let sampleHint = "杠杆样本 " + keptN;
if (rawN != null) {
const hMin = filt.raw_hours_min;
const hMax = filt.raw_hours_max;
const span = (hMin != null && hMax != null)
? (" · 样本距到期约 " + Math.floor(hMin) + "" + Math.ceil(hMax) + "h")
: "";
if (rawN === keptN) {
sampleHint = "杠杆样本 " + keptN + "(≥" + (state.minHours || 12) + "h 未减少)" + span;
} else {
sampleHint = "杠杆样本 " + keptN + "/" + rawN + "(≥" + (state.minHours || 12) + "h" + span;
}
}
document.getElementById("opsSamples").textContent = sampleHint;
document.getElementById("opsMin").textContent = lev.min_leverage ?? "—"; document.getElementById("opsMin").textContent = lev.min_leverage ?? "—";
const titleMap = { day: "日", week: "近7日", month: "近30日" }; const titleMap = { day: "日", week: "近7日", month: "近30日" };
document.getElementById("chartTitle").textContent = "上图 · 时段杠杆均值(" + (titleMap[state.range]||"") + ""; document.getElementById("chartTitle").textContent = "上图 · 时段杠杆均值(" + (titleMap[state.range]||"") + "";
+12
View File
@@ -229,9 +229,21 @@ export type MovePointsStats = {
buckets: MoveBucket[]; buckets: MoveBucket[];
}; };
export type ExpiryFilterMeta = {
min_hours_filter: number;
raw_sample_count: number;
filtered_sample_count: number;
raw_hours_min: number | null;
raw_hours_max: number | null;
filtered_hours_min?: number | null;
filtered_hours_max?: number | null;
};
export type OpsMap = { export type OpsMap = {
leverage: LeverageStats; leverage: LeverageStats;
move_points: MovePointsStats; move_points: MovePointsStats;
min_hours?: number;
expiry_filter?: ExpiryFilterMeta;
}; };
export async function fetchHealth(): Promise<Health> { export async function fetchHealth(): Promise<Health> {
+18 -1
View File
@@ -88,8 +88,25 @@ export default function OpsMapPage() {
const lev: LeverageStats | undefined = data?.leverage; const lev: LeverageStats | undefined = data?.leverage;
const mov: MovePointsStats | undefined = data?.move_points; const mov: MovePointsStats | undefined = data?.move_points;
const filt = data?.expiry_filter;
const rangeLabel = range === "day" ? "日" : range === "week" ? "近7日" : "近30日"; const rangeLabel = range === "day" ? "日" : range === "week" ? "近7日" : "近30日";
const sampleHint = (() => {
if (!filt) return `杠杆样本 ${lev?.sample_count ?? 0}`;
const raw = filt.raw_sample_count;
const kept = filt.filtered_sample_count;
const hMin = filt.raw_hours_min;
const hMax = filt.raw_hours_max;
const span =
hMin != null && hMax != null
? ` · 样本距到期约 ${Math.floor(hMin)}${Math.ceil(hMax)}h`
: "";
if (raw === kept) {
return `杠杆样本 ${kept}(≥${minHours}h 未减少)${span}`;
}
return `杠杆样本 ${kept}/${raw}(≥${minHours}h${span}`;
})();
return ( return (
<div className="layout"> <div className="layout">
<div className="brand"></div> <div className="brand"></div>
@@ -152,7 +169,7 @@ export default function OpsMapPage() {
<div className="value" style={{ fontSize: "1.1rem" }}> <div className="value" style={{ fontSize: "1.1rem" }}>
{lev.start_ymd} {lev.end_ymd} {lev.start_ymd} {lev.end_ymd}
</div> </div>
<div className="hint"> {lev.sample_count}</div> <div className="hint">{sampleHint}</div>
</div> </div>
<div className="tile"> <div className="tile">
<div className="label">线</div> <div className="label">线</div>