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:
+60
-19
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+17
-1
@@ -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]||"") + ")";
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user