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
+60 -19
View File
@@ -56,13 +56,13 @@ def leverage_stats(
info = _range_info(range, date)
repo = Repository(s.db_path)
try:
rows = repo.fetch_option_quotes(
raw = repo.fetch_option_quotes(
start_ms=info["start_ms"],
end_ms=info["end_ms"],
side=side,
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(
rows,
range_info=info,
@@ -71,6 +71,7 @@ def leverage_stats(
side=side,
)
payload["min_hours"] = mh
payload["expiry_filter"] = filt
return payload
finally:
repo.close()
@@ -94,13 +95,13 @@ def move_points_stats(
info = _range_info(range, date)
repo = Repository(s.db_path)
try:
rows = repo.fetch_option_quotes(
raw = repo.fetch_option_quotes(
start_ms=info["start_ms"],
end_ms=info["end_ms"],
side=side,
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")})
# 懒回填:已到期但缺锚点时尽量补齐(本地指数优先,失败则跳过)
try:
@@ -121,6 +122,7 @@ def move_points_stats(
side=side,
)
payload["min_hours"] = mh
payload["expiry_filter"] = filt
return payload
finally:
repo.close()
@@ -134,19 +136,58 @@ def ops_map(
bucket_minutes: int = Query(default=60, ge=15, le=120),
min_hours: float = Query(default=12, ge=8, le=48),
) -> dict:
# 直接算一遍,避免路由函数互相调用时 Query 默认值问题
s = get_settings()
mh = _validate_min_hours(min_hours)
lev = leverage_stats(
range=range, date=date, side=side, bucket_minutes=bucket_minutes, min_hours=mh
)
mov = move_points_stats(
range=range, date=date, side=side, bucket_minutes=bucket_minutes, min_hours=mh
)
return {
"range": range,
"date": lev.get("date"),
"side": side,
"bucket_minutes": bucket_minutes,
"min_hours": mh,
"leverage": lev,
"move_points": mov,
}
info = _range_info(range, date)
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,
)
rows, filt = filter_rows_min_hours_to_expiry(raw, 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 {
"range": range,
"date": lev.get("date"),
"side": side,
"bucket_minutes": bucket_minutes,
"min_hours": mh,
"expiry_filter": filt,
"leverage": lev,
"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
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(
rows: Iterable[dict[str, Any]],
*,
min_hours: float,
) -> list[dict[str, Any]]:
"""保留采样时距到期 ≥ min_hours 的样本。"""
out: list[dict[str, Any]] = []
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
"""保留采样时距到期 ≥ min_hours 的样本;附带过滤前后统计"""
raw = list(rows)
before = hours_stats(raw)
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 ""))
if h is None:
continue
if h >= mh:
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"},
]
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 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 || {};
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 ?? "—";
const titleMap = { day: "日", week: "近7日", month: "近30日" };
document.getElementById("chartTitle").textContent = "上图 · 时段杠杆均值(" + (titleMap[state.range]||"") + "";
+12
View File
@@ -229,9 +229,21 @@ export type MovePointsStats = {
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 = {
leverage: LeverageStats;
move_points: MovePointsStats;
min_hours?: number;
expiry_filter?: ExpiryFilterMeta;
};
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 mov: MovePointsStats | undefined = data?.move_points;
const filt = data?.expiry_filter;
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 (
<div className="layout">
<div className="brand"></div>
@@ -152,7 +169,7 @@ export default function OpsMapPage() {
<div className="value" style={{ fontSize: "1.1rem" }}>
{lev.start_ymd} {lev.end_ymd}
</div>
<div className="hint"> {lev.sample_count}</div>
<div className="hint">{sampleHint}</div>
</div>
<div className="tile">
<div className="label">线</div>