feat: add min hours-to-expiry filter on ops map (8-48h step 2)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 10:42:55 +08:00
parent f798c11cf9
commit 3e492eb46c
7 changed files with 143 additions and 10 deletions
+43 -4
View File
@@ -9,6 +9,7 @@ from apps.worker.settle import ensure_settlements_for_ymds
from packages.config import get_settings
from packages.db import Repository
from packages.domain.aggregate import leverage_stats_payload, move_points_stats_payload
from packages.domain.expiry_filter import filter_rows_min_hours_to_expiry
from packages.domain.range import resolve_range
router = APIRouter(prefix="/stats", tags=["stats"], dependencies=[Depends(require_auth)])
@@ -26,14 +27,32 @@ def _range_info(range_name: str, date: str | None) -> dict:
raise HTTPException(status_code=400, detail=str(e)) from e
def _validate_min_hours(min_hours: float) -> float:
# 848,步进 2(与前端一致)
h = float(min_hours)
if h < 8 or h > 48 or abs(h - round(h)) > 1e-9 or int(round(h)) % 2 != 0:
raise HTTPException(
status_code=400,
detail="min_hours must be even integer in [8, 48]",
)
return float(int(round(h)))
@router.get("/leverage")
def leverage_stats(
range: str = Query(default="day", pattern="^(day|week|month)$"),
date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD(上海)"),
side: str = Query(default="both", pattern="^(C|P|both)$"),
bucket_minutes: int = Query(default=60, ge=15, le=120),
min_hours: float = Query(
default=12,
ge=8,
le=48,
description="采样时距离到期 ≥ 该小时数才计入",
),
) -> dict:
s = get_settings()
mh = _validate_min_hours(min_hours)
info = _range_info(range, date)
repo = Repository(s.db_path)
try:
@@ -43,13 +62,16 @@ def leverage_stats(
side=side,
underlying=s.underlying,
)
return leverage_stats_payload(
rows = filter_rows_min_hours_to_expiry(rows, min_hours=mh)
payload = leverage_stats_payload(
rows,
range_info=info,
bucket_minutes=bucket_minutes,
min_leverage=float(s.min_option_leverage),
side=side,
)
payload["min_hours"] = mh
return payload
finally:
repo.close()
@@ -60,8 +82,15 @@ def move_points_stats(
date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD"),
side: str = Query(default="both", pattern="^(C|P|both)$"),
bucket_minutes: int = Query(default=60, ge=15, le=120),
min_hours: float = Query(
default=12,
ge=8,
le=48,
description="采样时距离到期 ≥ 该小时数才计入",
),
) -> dict:
s = get_settings()
mh = _validate_min_hours(min_hours)
info = _range_info(range, date)
repo = Repository(s.db_path)
try:
@@ -71,6 +100,7 @@ def move_points_stats(
side=side,
underlying=s.underlying,
)
rows = filter_rows_min_hours_to_expiry(rows, min_hours=mh)
ymds = sorted({str(r.get("expiry_ymd")) for r in rows if r.get("expiry_ymd")})
# 懒回填:已到期但缺锚点时尽量补齐(本地指数优先,失败则跳过)
try:
@@ -83,13 +113,15 @@ def move_points_stats(
except Exception: # noqa: BLE001 — 回填失败不阻断统计
pass
settlements = repo.list_settlements(ymds)
return move_points_stats_payload(
payload = move_points_stats_payload(
rows,
settlements,
range_info=info,
bucket_minutes=bucket_minutes,
side=side,
)
payload["min_hours"] = mh
return payload
finally:
repo.close()
@@ -100,14 +132,21 @@ def ops_map(
date: str | None = Query(default=None),
side: str = Query(default="both", pattern="^(C|P|both)$"),
bucket_minutes: int = Query(default=60, ge=15, le=120),
min_hours: float = Query(default=12, ge=8, le=48),
) -> dict:
lev = leverage_stats(range=range, date=date, side=side, bucket_minutes=bucket_minutes)
mov = move_points_stats(range=range, date=date, side=side, bucket_minutes=bucket_minutes)
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,
}
+32
View File
@@ -0,0 +1,32 @@
"""按采样时刻距离到期时长过滤行情样本。"""
from __future__ import annotations
from typing import Any, Iterable
from packages.domain.expiry import expiry_ms_from_ymd
def hours_to_expiry_at(ts_ms: int, expiry_ymd: str) -> float | None:
try:
ems = expiry_ms_from_ymd(str(expiry_ymd))
except ValueError:
return None
return (ems - int(ts_ms)) / 3_600_000.0
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]] = []
mh = float(min_hours)
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 None:
continue
if h >= mh:
out.append(r)
return out
+19
View File
@@ -0,0 +1,19 @@
from packages.domain.expiry import expiry_ms_from_ymd
from packages.domain.expiry_filter import filter_rows_min_hours_to_expiry, hours_to_expiry_at
def test_filter_min_hours():
# 到期 260803 → 2026-08-03 08:00 UTC
ymd = "260803"
ems = expiry_ms_from_ymd(ymd)
# 距到期 20h / 10h 的采样点
ts_20h = ems - 20 * 3_600_000
ts_10h = ems - 10 * 3_600_000
rows = [
{"ts_ms": ts_20h, "expiry_ymd": ymd, "side": "C"},
{"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)
assert len(kept) == 1
assert kept[0]["side"] == "C"
+26 -3
View File
@@ -34,7 +34,7 @@
.seg button { appearance: none; border: 0; background: transparent; color: var(--muted); padding: 0.45rem 0.85rem; cursor: pointer; }
.seg button.active { background: #1e2a3a; color: var(--text); }
.date-field { color: var(--muted); font-size: 0.85rem; display: inline-flex; gap: 0.5rem; align-items: center; }
.date-field input { background: var(--panel); border: 1px solid #243041; color: var(--text); border-radius: 6px; padding: 0.35rem 0.5rem; }
.date-field input, .date-field select { background: var(--panel); border: 1px solid #243041; color: var(--text); border-radius: 6px; padding: 0.35rem 0.5rem; }
.chart-wrap { background: var(--panel); border: 1px solid #243041; border-radius: 10px; padding: 0.75rem 0.5rem; margin-top: 0.5rem; }
.chart-title { padding: 0 0.75rem 0.25rem; color: var(--muted); font-size: 0.85rem; }
.chart-svg { width: 100%; height: auto; display: block; }
@@ -123,6 +123,9 @@
<button type="button" data-side="P">Put</button>
</div>
<label class="date-field">锚点日 <input type="date" id="anchorDate" /></label>
<label class="date-field">距离到期 ≥
<select id="minHours" aria-label="距离到期最小小时数"></select>
</label>
</div>
<div class="row">
<div class="tile"><div class="label">范围</div><div class="value" id="opsRange" style="font-size:1.05rem"></div><div class="hint" id="opsSamples"></div></div>
@@ -211,7 +214,7 @@
</div>
<script>
const TOKEN_KEY = "mi_token";
const state = { page: "dash", range: "day", side: "both", moveMode: "abs", lastMov: null, authRequired: false, loadedUsername: "admin" };
const state = { page: "dash", range: "day", side: "both", moveMode: "abs", minHours: 12, lastMov: null, authRequired: false, loadedUsername: "admin" };
function getToken() { try { return localStorage.getItem(TOKEN_KEY); } catch { return null; } }
function setToken(t) { try { if (t) localStorage.setItem(TOKEN_KEY, t); else localStorage.removeItem(TOKEN_KEY); } catch {} }
function authHeaders() {
@@ -233,6 +236,16 @@
return d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
}
document.getElementById("anchorDate").value = todayYmd();
(function initMinHours() {
const sel = document.getElementById("minHours");
for (let h = 8; h <= 48; h += 2) {
const opt = document.createElement("option");
opt.value = String(h);
opt.textContent = h + "h";
if (h === state.minHours) opt.selected = true;
sel.appendChild(opt);
}
})();
function showLogin() {
document.getElementById("page-login").classList.remove("hidden");
@@ -540,6 +553,10 @@
}
}));
document.getElementById("anchorDate").addEventListener("change", loadOps);
document.getElementById("minHours").addEventListener("change", () => {
state.minHours = Number(document.getElementById("minHours").value) || 12;
loadOps();
});
function drawChart(buckets, minLev) {
const svg = document.getElementById("levChart");
@@ -681,7 +698,13 @@
async function loadOps() {
const date = document.getElementById("anchorDate").value;
const q = new URLSearchParams({ range: state.range, side: state.side, bucket_minutes: "60", date });
const q = new URLSearchParams({
range: state.range,
side: state.side,
bucket_minutes: "60",
date,
min_hours: String(state.minHours || 12),
});
try {
const d = await apiFetch("/api/stats/ops-map?" + q).then(r => {
if (!r.ok) throw new Error("ops-map " + r.status);
+2
View File
@@ -251,12 +251,14 @@ export async function fetchOpsMap(params: {
date?: string;
side?: string;
bucket_minutes?: number;
min_hours?: number;
}): Promise<OpsMap> {
const q = new URLSearchParams();
q.set("range", params.range);
if (params.date) q.set("date", params.date);
if (params.side) q.set("side", params.side);
if (params.bucket_minutes) q.set("bucket_minutes", String(params.bucket_minutes));
if (params.min_hours != null) q.set("min_hours", String(params.min_hours));
const r = await apiFetch(`/api/stats/ops-map?${q}`);
if (!r.ok) throw new Error(r.status === 401 ? "unauthorized" : "ops-map failed");
return r.json();
+19 -2
View File
@@ -15,6 +15,8 @@ import { leverageSummary, moveSummary } from "../lib/opsSummary";
type RangeKey = "day" | "week" | "month";
type SideKey = "both" | "C" | "P";
const MIN_HOURS_OPTIONS = Array.from({ length: (48 - 8) / 2 + 1 }, (_, i) => 8 + i * 2);
function todayYmd() {
const d = new Date();
const y = d.getFullYear();
@@ -29,6 +31,7 @@ export default function OpsMapPage() {
const [range, setRange] = useState<RangeKey>("day");
const [side, setSide] = useState<SideKey>("both");
const [date, setDate] = useState(todayYmd());
const [minHours, setMinHours] = useState(12);
const [moveMode, setMoveMode] = useState<"abs" | "signed">("abs");
const [data, setData] = useState<OpsMap | null>(null);
const [err, setErr] = useState<string | null>(null);
@@ -52,7 +55,7 @@ export default function OpsMapPage() {
if (!ready || needLogin) return;
let alive = true;
setLoading(true);
fetchOpsMap({ range, date, side, bucket_minutes: 60 })
fetchOpsMap({ range, date, side, bucket_minutes: 60, min_hours: minHours })
.then((d) => {
if (!alive) return;
setData(d);
@@ -70,7 +73,7 @@ export default function OpsMapPage() {
return () => {
alive = false;
};
}, [range, side, date, ready, needLogin]);
}, [range, side, date, minHours, ready, needLogin]);
if (!ready) {
return (
@@ -122,6 +125,20 @@ export default function OpsMapPage() {
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</label>
<label className="date-field">
<select
value={minHours}
onChange={(e) => setMinHours(Number(e.target.value))}
aria-label="距离到期最小小时数"
>
{MIN_HOURS_OPTIONS.map((h) => (
<option key={h} value={h}>
{h}h
</option>
))}
</select>
</label>
</div>
{err && <p style={{ color: "#e85d5d" }}>{err}</p>}
+2 -1
View File
@@ -159,7 +159,8 @@ select.field-input { cursor: pointer; }
color: var(--muted);
font-size: 0.85rem;
}
.date-field input {
.date-field input,
.date-field select {
background: var(--panel);
border: 1px solid #243041;
color: var(--text);