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