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:
@@ -9,6 +9,7 @@ from apps.worker.settle import ensure_settlements_for_ymds
|
|||||||
from packages.config import get_settings
|
from packages.config import get_settings
|
||||||
from packages.db import Repository
|
from packages.db import Repository
|
||||||
from packages.domain.aggregate import leverage_stats_payload, move_points_stats_payload
|
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
|
from packages.domain.range import resolve_range
|
||||||
|
|
||||||
router = APIRouter(prefix="/stats", tags=["stats"], dependencies=[Depends(require_auth)])
|
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
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_min_hours(min_hours: float) -> float:
|
||||||
|
# 8–48,步进 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")
|
@router.get("/leverage")
|
||||||
def leverage_stats(
|
def leverage_stats(
|
||||||
range: str = Query(default="day", pattern="^(day|week|month)$"),
|
range: str = Query(default="day", pattern="^(day|week|month)$"),
|
||||||
date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD(上海)"),
|
date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD(上海)"),
|
||||||
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
||||||
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,
|
||||||
|
description="采样时距离到期 ≥ 该小时数才计入",
|
||||||
|
),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
|
mh = _validate_min_hours(min_hours)
|
||||||
info = _range_info(range, date)
|
info = _range_info(range, date)
|
||||||
repo = Repository(s.db_path)
|
repo = Repository(s.db_path)
|
||||||
try:
|
try:
|
||||||
@@ -43,13 +62,16 @@ def leverage_stats(
|
|||||||
side=side,
|
side=side,
|
||||||
underlying=s.underlying,
|
underlying=s.underlying,
|
||||||
)
|
)
|
||||||
return leverage_stats_payload(
|
rows = filter_rows_min_hours_to_expiry(rows, min_hours=mh)
|
||||||
|
payload = leverage_stats_payload(
|
||||||
rows,
|
rows,
|
||||||
range_info=info,
|
range_info=info,
|
||||||
bucket_minutes=bucket_minutes,
|
bucket_minutes=bucket_minutes,
|
||||||
min_leverage=float(s.min_option_leverage),
|
min_leverage=float(s.min_option_leverage),
|
||||||
side=side,
|
side=side,
|
||||||
)
|
)
|
||||||
|
payload["min_hours"] = mh
|
||||||
|
return payload
|
||||||
finally:
|
finally:
|
||||||
repo.close()
|
repo.close()
|
||||||
|
|
||||||
@@ -60,8 +82,15 @@ def move_points_stats(
|
|||||||
date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD"),
|
date: str | None = Query(default=None, description="锚点日 YYYY-MM-DD"),
|
||||||
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
||||||
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,
|
||||||
|
description="采样时距离到期 ≥ 该小时数才计入",
|
||||||
|
),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
s = get_settings()
|
s = get_settings()
|
||||||
|
mh = _validate_min_hours(min_hours)
|
||||||
info = _range_info(range, date)
|
info = _range_info(range, date)
|
||||||
repo = Repository(s.db_path)
|
repo = Repository(s.db_path)
|
||||||
try:
|
try:
|
||||||
@@ -71,6 +100,7 @@ def move_points_stats(
|
|||||||
side=side,
|
side=side,
|
||||||
underlying=s.underlying,
|
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")})
|
ymds = sorted({str(r.get("expiry_ymd")) for r in rows if r.get("expiry_ymd")})
|
||||||
# 懒回填:已到期但缺锚点时尽量补齐(本地指数优先,失败则跳过)
|
# 懒回填:已到期但缺锚点时尽量补齐(本地指数优先,失败则跳过)
|
||||||
try:
|
try:
|
||||||
@@ -83,13 +113,15 @@ def move_points_stats(
|
|||||||
except Exception: # noqa: BLE001 — 回填失败不阻断统计
|
except Exception: # noqa: BLE001 — 回填失败不阻断统计
|
||||||
pass
|
pass
|
||||||
settlements = repo.list_settlements(ymds)
|
settlements = repo.list_settlements(ymds)
|
||||||
return move_points_stats_payload(
|
payload = move_points_stats_payload(
|
||||||
rows,
|
rows,
|
||||||
settlements,
|
settlements,
|
||||||
range_info=info,
|
range_info=info,
|
||||||
bucket_minutes=bucket_minutes,
|
bucket_minutes=bucket_minutes,
|
||||||
side=side,
|
side=side,
|
||||||
)
|
)
|
||||||
|
payload["min_hours"] = mh
|
||||||
|
return payload
|
||||||
finally:
|
finally:
|
||||||
repo.close()
|
repo.close()
|
||||||
|
|
||||||
@@ -100,14 +132,21 @@ def ops_map(
|
|||||||
date: str | None = Query(default=None),
|
date: str | None = Query(default=None),
|
||||||
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
side: str = Query(default="both", pattern="^(C|P|both)$"),
|
||||||
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),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
lev = leverage_stats(range=range, date=date, side=side, bucket_minutes=bucket_minutes)
|
mh = _validate_min_hours(min_hours)
|
||||||
mov = move_points_stats(range=range, date=date, side=side, bucket_minutes=bucket_minutes)
|
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 {
|
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,
|
||||||
"leverage": lev,
|
"leverage": lev,
|
||||||
"move_points": mov,
|
"move_points": mov,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"
|
||||||
Vendored
+26
-3
@@ -34,7 +34,7 @@
|
|||||||
.seg button { appearance: none; border: 0; background: transparent; color: var(--muted); padding: 0.45rem 0.85rem; cursor: pointer; }
|
.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); }
|
.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 { 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-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-title { padding: 0 0.75rem 0.25rem; color: var(--muted); font-size: 0.85rem; }
|
||||||
.chart-svg { width: 100%; height: auto; display: block; }
|
.chart-svg { width: 100%; height: auto; display: block; }
|
||||||
@@ -123,6 +123,9 @@
|
|||||||
<button type="button" data-side="P">Put</button>
|
<button type="button" data-side="P">Put</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="date-field">锚点日 <input type="date" id="anchorDate" /></label>
|
<label class="date-field">锚点日 <input type="date" id="anchorDate" /></label>
|
||||||
|
<label class="date-field">距离到期 ≥
|
||||||
|
<select id="minHours" aria-label="距离到期最小小时数"></select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<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>
|
<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>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const TOKEN_KEY = "mi_token";
|
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 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 setToken(t) { try { if (t) localStorage.setItem(TOKEN_KEY, t); else localStorage.removeItem(TOKEN_KEY); } catch {} }
|
||||||
function authHeaders() {
|
function authHeaders() {
|
||||||
@@ -233,6 +236,16 @@
|
|||||||
return d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
|
return d.getFullYear() + "-" + String(d.getMonth()+1).padStart(2,"0") + "-" + String(d.getDate()).padStart(2,"0");
|
||||||
}
|
}
|
||||||
document.getElementById("anchorDate").value = todayYmd();
|
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() {
|
function showLogin() {
|
||||||
document.getElementById("page-login").classList.remove("hidden");
|
document.getElementById("page-login").classList.remove("hidden");
|
||||||
@@ -540,6 +553,10 @@
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
document.getElementById("anchorDate").addEventListener("change", loadOps);
|
document.getElementById("anchorDate").addEventListener("change", loadOps);
|
||||||
|
document.getElementById("minHours").addEventListener("change", () => {
|
||||||
|
state.minHours = Number(document.getElementById("minHours").value) || 12;
|
||||||
|
loadOps();
|
||||||
|
});
|
||||||
|
|
||||||
function drawChart(buckets, minLev) {
|
function drawChart(buckets, minLev) {
|
||||||
const svg = document.getElementById("levChart");
|
const svg = document.getElementById("levChart");
|
||||||
@@ -681,7 +698,13 @@
|
|||||||
|
|
||||||
async function loadOps() {
|
async function loadOps() {
|
||||||
const date = document.getElementById("anchorDate").value;
|
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 {
|
try {
|
||||||
const d = await apiFetch("/api/stats/ops-map?" + q).then(r => {
|
const d = await apiFetch("/api/stats/ops-map?" + q).then(r => {
|
||||||
if (!r.ok) throw new Error("ops-map " + r.status);
|
if (!r.ok) throw new Error("ops-map " + r.status);
|
||||||
|
|||||||
@@ -251,12 +251,14 @@ export async function fetchOpsMap(params: {
|
|||||||
date?: string;
|
date?: string;
|
||||||
side?: string;
|
side?: string;
|
||||||
bucket_minutes?: number;
|
bucket_minutes?: number;
|
||||||
|
min_hours?: number;
|
||||||
}): Promise<OpsMap> {
|
}): Promise<OpsMap> {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
q.set("range", params.range);
|
q.set("range", params.range);
|
||||||
if (params.date) q.set("date", params.date);
|
if (params.date) q.set("date", params.date);
|
||||||
if (params.side) q.set("side", params.side);
|
if (params.side) q.set("side", params.side);
|
||||||
if (params.bucket_minutes) q.set("bucket_minutes", String(params.bucket_minutes));
|
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}`);
|
const r = await apiFetch(`/api/stats/ops-map?${q}`);
|
||||||
if (!r.ok) throw new Error(r.status === 401 ? "unauthorized" : "ops-map failed");
|
if (!r.ok) throw new Error(r.status === 401 ? "unauthorized" : "ops-map failed");
|
||||||
return r.json();
|
return r.json();
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { leverageSummary, moveSummary } from "../lib/opsSummary";
|
|||||||
type RangeKey = "day" | "week" | "month";
|
type RangeKey = "day" | "week" | "month";
|
||||||
type SideKey = "both" | "C" | "P";
|
type SideKey = "both" | "C" | "P";
|
||||||
|
|
||||||
|
const MIN_HOURS_OPTIONS = Array.from({ length: (48 - 8) / 2 + 1 }, (_, i) => 8 + i * 2);
|
||||||
|
|
||||||
function todayYmd() {
|
function todayYmd() {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
const y = d.getFullYear();
|
const y = d.getFullYear();
|
||||||
@@ -29,6 +31,7 @@ export default function OpsMapPage() {
|
|||||||
const [range, setRange] = useState<RangeKey>("day");
|
const [range, setRange] = useState<RangeKey>("day");
|
||||||
const [side, setSide] = useState<SideKey>("both");
|
const [side, setSide] = useState<SideKey>("both");
|
||||||
const [date, setDate] = useState(todayYmd());
|
const [date, setDate] = useState(todayYmd());
|
||||||
|
const [minHours, setMinHours] = useState(12);
|
||||||
const [moveMode, setMoveMode] = useState<"abs" | "signed">("abs");
|
const [moveMode, setMoveMode] = useState<"abs" | "signed">("abs");
|
||||||
const [data, setData] = useState<OpsMap | null>(null);
|
const [data, setData] = useState<OpsMap | null>(null);
|
||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
@@ -52,7 +55,7 @@ export default function OpsMapPage() {
|
|||||||
if (!ready || needLogin) return;
|
if (!ready || needLogin) return;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchOpsMap({ range, date, side, bucket_minutes: 60 })
|
fetchOpsMap({ range, date, side, bucket_minutes: 60, min_hours: minHours })
|
||||||
.then((d) => {
|
.then((d) => {
|
||||||
if (!alive) return;
|
if (!alive) return;
|
||||||
setData(d);
|
setData(d);
|
||||||
@@ -70,7 +73,7 @@ export default function OpsMapPage() {
|
|||||||
return () => {
|
return () => {
|
||||||
alive = false;
|
alive = false;
|
||||||
};
|
};
|
||||||
}, [range, side, date, ready, needLogin]);
|
}, [range, side, date, minHours, ready, needLogin]);
|
||||||
|
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return (
|
return (
|
||||||
@@ -122,6 +125,20 @@ export default function OpsMapPage() {
|
|||||||
锚点日
|
锚点日
|
||||||
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
<input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||||
</label>
|
</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>
|
</div>
|
||||||
|
|
||||||
{err && <p style={{ color: "#e85d5d" }}>{err}</p>}
|
{err && <p style={{ color: "#e85d5d" }}>{err}</p>}
|
||||||
|
|||||||
+2
-1
@@ -159,7 +159,8 @@ select.field-input { cursor: pointer; }
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.date-field input {
|
.date-field input,
|
||||||
|
.date-field select {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border: 1px solid #243041;
|
border: 1px solid #243041;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
|
|||||||
Reference in New Issue
Block a user