From 789ab43dbee9a2e18508e544f6e7f7625dab22c7 Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 23 Jul 2026 02:33:23 +0800 Subject: [PATCH] Add long-straddle premium overlay to hub amp stats. Configurable bilateral premium with exceed counts/ratios and settlement PnL for buying volatility. Co-authored-by: Cursor --- docs/振幅统计说明.md | 19 +++++ lib/hub/amp_stats_lib.py | 104 +++++++++++++++++++++++- manual_trading_hub/amp_stats_routes.py | 39 ++++++++- manual_trading_hub/static/amp_stats.js | 103 +++++++++++++++++++++++- manual_trading_hub/static/app.css | 105 +++++++++++++------------ manual_trading_hub/static/index.html | 12 ++- tests/test_amp_stats_lib.py | 26 ++++++ 7 files changed, 347 insertions(+), 61 deletions(-) diff --git a/docs/振幅统计说明.md b/docs/振幅统计说明.md index 20620cf..355283f 100644 --- a/docs/振幅统计说明.md +++ b/docs/振幅统计说明.md @@ -55,6 +55,24 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / --- +## 买跨对照(赌波动) + +表单可填 **双边权利金(点)**,例如 `30`: + +| 汇总项 | 口径 | +|--------|------| +| 开→高超过 | `H−O > 权利金` 的天数与占比 | +| 开→低超过 | `O−L > 权利金` 的天数与占比 | +| \|涨跌\|超过 | `\|C−O\| > 权利金` 的天数与占比 | +| 买跨盈亏 | 单日 `\|C−O\| − 权利金`,再看合计 / 日均 / 胜率 | + +- 方向:**买跨**(不是卖跨) +- 比较:严格 **`>`**(刚好等于权利金不算越过) +- 已算出日表后,改权利金会**本地重算对照**(不重拉 K 线) +- 空着或 ≤0:不显示买跨块 + +--- + ## 历史 Tab - 仅 **保存到历史** 后出现(不会一算就自动入库) @@ -80,3 +98,4 @@ K 线粒度:**1H**(与整点对齐);价源优先 OKX 指数(ETH-USD / | 日期 | 说明 | |------|------| | 2026-07-23 | 首版上线说明 | +| 2026-07-23 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 | diff --git a/lib/hub/amp_stats_lib.py b/lib/hub/amp_stats_lib.py index 4591700..ef9e61c 100644 --- a/lib/hub/amp_stats_lib.py +++ b/lib/hub/amp_stats_lib.py @@ -165,9 +165,74 @@ def compute_day_row( } -def summarize_rows(rows: list[dict[str, Any]]) -> dict[str, Any]: +def normalize_straddle_premium(raw: Any) -> Optional[float]: + """双边权利金(点数).空/≤0 表示不做跨式对照.""" + if raw is None or raw == "": + return None + try: + v = float(raw) + except (TypeError, ValueError): + raise ValueError("双边权利金须为数字") from None + if v <= 0: + return None + return v + + +def straddle_long_stats(rows: list[dict[str, Any]], premium: float) -> dict[str, Any]: + """买跨(赌波动):盈亏按收盘 |C−O| − 双边权利金;越过阈值用严格 >.""" + prem = float(premium) + if prem <= 0: + raise ValueError("双边权利金须 > 0") if not rows: return { + "side": "long_straddle", + "premium": prem, + "sample_count": 0, + "up_exceed_days": 0, + "up_exceed_ratio": None, + "down_exceed_days": 0, + "down_exceed_ratio": None, + "abs_change_exceed_days": 0, + "abs_change_exceed_ratio": None, + "pnl_total": None, + "pnl_avg": None, + "win_days": 0, + "win_ratio": None, + "pnl_max": None, + "pnl_min": None, + } + n = len(rows) + up_ex = sum(1 for r in rows if float(r["up_points"]) > prem) + down_ex = sum(1 for r in rows if float(r["down_points"]) > prem) + abs_ex = sum(1 for r in rows if abs(float(r["change"])) > prem) + pnls = [abs(float(r["change"])) - prem for r in rows] + win = sum(1 for p in pnls if p > 0) + return { + "side": "long_straddle", + "premium": round(prem, 4), + "sample_count": n, + "up_exceed_days": up_ex, + "up_exceed_ratio": round(up_ex / n, 4), + "down_exceed_days": down_ex, + "down_exceed_ratio": round(down_ex / n, 4), + "abs_change_exceed_days": abs_ex, + "abs_change_exceed_ratio": round(abs_ex / n, 4), + "pnl_total": round(sum(pnls), 4), + "pnl_avg": round(statistics.fmean(pnls), 4), + "win_days": win, + "win_ratio": round(win / n, 4), + "pnl_max": round(max(pnls), 4), + "pnl_min": round(min(pnls), 4), + } + + +def summarize_rows( + rows: list[dict[str, Any]], + *, + straddle_premium: Any = None, +) -> dict[str, Any]: + if not rows: + out = { "sample_count": 0, "max_amplitude": None, "max_amplitude_day": None, @@ -179,7 +244,12 @@ def summarize_rows(rows: list[dict[str, Any]]) -> dict[str, Any]: "avg_down_points": None, "up_day_ratio": None, "down_day_ratio": None, + "straddle": None, } + prem = normalize_straddle_premium(straddle_premium) + if prem is not None: + out["straddle"] = straddle_long_stats([], prem) + return out amps = [float(r["amplitude"]) for r in rows] ups = [float(r["up_points"]) for r in rows] downs = [float(r["down_points"]) for r in rows] @@ -188,7 +258,7 @@ def summarize_rows(rows: list[dict[str, Any]]) -> dict[str, Any]: up_days = sum(1 for r in rows if float(r["change"]) > 0) down_days = sum(1 for r in rows if float(r["change"]) < 0) n = len(rows) - return { + out: dict[str, Any] = { "sample_count": n, "max_amplitude": round(max_amp, 4), "max_amplitude_day": max_amp_day, @@ -200,7 +270,12 @@ def summarize_rows(rows: list[dict[str, Any]]) -> dict[str, Any]: "avg_down_points": round(statistics.fmean(downs), 4), "up_day_ratio": round(up_days / n, 4), "down_day_ratio": round(down_days / n, 4), + "straddle": None, } + prem = normalize_straddle_premium(straddle_premium) + if prem is not None: + out["straddle"] = straddle_long_stats(rows, prem) + return out def _parse_okx_candle_row(row: list) -> Optional[dict[str, Any]]: @@ -312,6 +387,7 @@ def compute_amp_stats( start_hour: int = 16, period: str = "2m", custom_days: Any = None, + straddle_premium: Any = None, now: Optional[datetime] = None, fetch_fn: Optional[Callable[..., list[dict[str, Any]]]] = None, ) -> dict[str, Any]: @@ -319,6 +395,7 @@ def compute_amp_stats( sh = int(start_hour) if sh < 0 or sh > 23: raise ValueError("起点须为 0-23 整点") + prem = normalize_straddle_premium(straddle_premium) sample_days = resolve_sample_days(period, custom_days) settlements = list_settlement_dates(sample_days=sample_days, now=now) if not settlements: @@ -342,7 +419,7 @@ def compute_amp_stats( missing.append(d.isoformat()) continue rows.append(row) - summary = summarize_rows(rows) + summary = summarize_rows(rows, straddle_premium=prem) period_label = period if period != "custom" else f"custom:{sample_days}" return { "ok": True, @@ -353,6 +430,7 @@ def compute_amp_stats( "end_hour": END_HOUR, "period": period_label, "sample_days_requested": sample_days, + "straddle_premium": prem, "timeframe": TIMEFRAME, "price_source": price_source, "inst_id": inst_id, @@ -398,6 +476,26 @@ def build_export_csv(payload: dict[str, Any]) -> str: w.writerow(["开→高最大", s.get("max_up_points"), "均值", s.get("avg_up_points")]) w.writerow(["开→低最大", s.get("max_down_points"), "均值", s.get("avg_down_points")]) w.writerow(["上涨窗占比", s.get("up_day_ratio"), "下跌窗占比", s.get("down_day_ratio")]) + st = s.get("straddle") or {} + if st: + w.writerow([]) + w.writerow(["【买跨对照·双边权利金】", st.get("premium")]) + w.writerow(["开→高超过", st.get("up_exceed_days"), "占比", st.get("up_exceed_ratio")]) + w.writerow(["开→低超过", st.get("down_exceed_days"), "占比", st.get("down_exceed_ratio")]) + w.writerow(["|涨跌|超过", st.get("abs_change_exceed_days"), "占比", st.get("abs_change_exceed_ratio")]) + w.writerow( + [ + "买跨点数盈亏合计", + st.get("pnl_total"), + "日均", + st.get("pnl_avg"), + "赚钱天数", + st.get("win_days"), + "胜率", + st.get("win_ratio"), + ] + ) + w.writerow(["单日最大赚", st.get("pnl_max"), "单日最大亏", st.get("pnl_min")]) w.writerow([]) w.writerow(["【日表明细】"]) w.writerow( diff --git a/manual_trading_hub/amp_stats_routes.py b/manual_trading_hub/amp_stats_routes.py index 43f5d2f..25ab4aa 100644 --- a/manual_trading_hub/amp_stats_routes.py +++ b/manual_trading_hub/amp_stats_routes.py @@ -12,7 +12,10 @@ from lib.hub.amp_stats_lib import ( build_export_csv, compute_amp_stats, export_filename, + normalize_straddle_premium, rows_page, + straddle_long_stats, + summarize_rows, ) @@ -21,6 +24,7 @@ class ComputeBody(BaseModel): start_hour: int = 16 period: str = "2m" custom_days: Optional[int] = None + straddle_premium: Optional[float] = None page: int = 1 page_size: int = 20 @@ -29,6 +33,13 @@ class SaveBody(BaseModel): result: dict[str, Any] = Field(default_factory=dict) +class StraddleBody(BaseModel): + """已有日表上按权利金重算买跨对照(不拉 K 线).""" + + rows: list[dict[str, Any]] = Field(default_factory=list) + straddle_premium: Optional[float] = None + + def create_amp_stats_router() -> APIRouter: router = APIRouter(prefix="/api/amp-stats", tags=["amp-stats"]) @@ -54,6 +65,7 @@ def create_amp_stats_router() -> APIRouter: "default_period": "2m", "timeframe": "1H", "metric_note": "振幅与距离均为点数:振幅=最高-最低=(开→高)+(开→低)", + "straddle_note": "买跨对照:双边权利金可设;越过用严格>;盈亏=|收-开|-权利金", } @router.post("/compute") @@ -64,6 +76,7 @@ def create_amp_stats_router() -> APIRouter: start_hour=body.start_hour, period=body.period, custom_days=body.custom_days, + straddle_premium=body.straddle_premium, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -76,6 +89,20 @@ def create_amp_stats_router() -> APIRouter: "page": page, } + @router.post("/straddle") + def api_straddle(body: StraddleBody): + try: + prem = normalize_straddle_premium(body.straddle_premium) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if prem is None: + return {"ok": True, "straddle": None} + try: + st = straddle_long_stats(body.rows or [], prem) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return {"ok": True, "straddle": st} + @router.get("/history") def api_history(symbol: str = "", limit: int = 50): return {"ok": True, "items": list_history(symbol=symbol, limit=limit)} @@ -108,12 +135,21 @@ def create_amp_stats_router() -> APIRouter: start_hour: int = Query(default=16), period: str = Query(default="2m"), custom_days: Optional[int] = Query(default=None), + straddle_premium: Optional[float] = Query(default=None), ): if (history_id or "").strip(): item = get_history(history_id.strip()) if not item: raise HTTPException(status_code=404, detail="历史不存在") - payload = item + payload = dict(item) + try: + prem = normalize_straddle_premium(straddle_premium) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if prem is not None: + summary = summarize_rows(payload.get("rows") or [], straddle_premium=prem) + payload["summary"] = summary + payload["straddle_premium"] = prem else: try: payload = compute_amp_stats( @@ -121,6 +157,7 @@ def create_amp_stats_router() -> APIRouter: start_hour=start_hour, period=period, custom_days=custom_days, + straddle_premium=straddle_premium, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/manual_trading_hub/static/amp_stats.js b/manual_trading_hub/static/amp_stats.js index 2c5a171..d579617 100644 --- a/manual_trading_hub/static/amp_stats.js +++ b/manual_trading_hub/static/amp_stats.js @@ -1,14 +1,14 @@ /** - * 中控振幅统计:OKX ETH/BTC 时段点数振幅. + * 中控振幅统计:OKX ETH/BTC 时段点数振幅 + 买跨对照. */ (function () { const page = document.getElementById("page-amp-stats"); if (!page) return; - let meta = null; let lastResult = null; let pageNo = 1; let bound = false; + let straddleTimer = null; const el = (id) => document.getElementById(id); @@ -32,6 +32,21 @@ .replace(/"/g, """); } + function pct(ratio) { + if (ratio == null || ratio === "") return "—"; + const n = Number(ratio); + if (!Number.isFinite(n)) return "—"; + return (n * 100).toFixed(1) + "%"; + } + + function readPremium() { + const raw = (el("amp-straddle-premium")?.value || "").trim(); + if (!raw) return null; + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) return null; + return n; + } + function setStatus(msg) { const s = el("amp-status"); if (s) s.textContent = msg || ""; @@ -68,12 +83,19 @@ } } + function pnlClass(v) { + const n = Number(v); + if (!Number.isFinite(n) || n === 0) return ""; + return n > 0 ? "is-pos" : "is-neg"; + } + function renderSummary(summary, result) { const box = el("amp-summary"); if (!box) return; const s = summary || {}; if (!s.sample_count) { box.innerHTML = '

暂无汇总

'; + renderStraddle(null); return; } box.innerHTML = @@ -87,6 +109,35 @@ `
涨/跌窗占比${esc(s.up_day_ratio)} / ${esc(s.down_day_ratio)}
` + `
价源${esc(result && result.price_source)}
` + ``; + renderStraddle(s.straddle); + } + + function renderStraddle(st) { + const box = el("amp-straddle"); + if (!box) return; + if (!st) { + box.innerHTML = '

填写「买跨·双边权利金」后计算,可看越过天数与买跨点数盈亏

'; + return; + } + const verdict = + st.pnl_total == null + ? "—" + : Number(st.pnl_total) > 0 + ? "样本合计盈利" + : Number(st.pnl_total) < 0 + ? "样本合计亏损" + : "样本合计持平"; + box.innerHTML = + `
` + + `
双边权利金${esc(st.premium)}
` + + `
开→高超过${esc(st.up_exceed_days)} 天 · ${esc(pct(st.up_exceed_ratio))}
` + + `
开→低超过${esc(st.down_exceed_days)} 天 · ${esc(pct(st.down_exceed_ratio))}
` + + `
|涨跌|超过${esc(st.abs_change_exceed_days)} 天 · ${esc(pct(st.abs_change_exceed_ratio))}
` + + `
买跨盈亏合计${esc(st.pnl_total)} (${esc(verdict)})
` + + `
日均盈亏${esc(st.pnl_avg)}
` + + `
赚钱天数/胜率${esc(st.win_days)} · ${esc(pct(st.win_ratio))}
` + + `
单日最大赚/亏${esc(st.pnl_max)} / ${esc(st.pnl_min)}
` + + `
`; } function renderTable(pagePayload) { @@ -135,12 +186,46 @@ } } + async function applyStraddleOnly() { + if (!lastResult || !Array.isArray(lastResult.rows)) { + renderStraddle(null); + return; + } + const prem = readPremium(); + if (prem == null) { + if (lastResult.summary) lastResult.summary.straddle = null; + lastResult.straddle_premium = null; + renderStraddle(null); + return; + } + try { + const data = await apiFetch("/api/amp-stats/straddle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rows: lastResult.rows, straddle_premium: prem }), + }); + const st = data.straddle || null; + if (!lastResult.summary) lastResult.summary = {}; + lastResult.summary.straddle = st; + lastResult.straddle_premium = prem; + renderStraddle(st); + } catch (e) { + setStatus(String(e && e.message ? e.message : e)); + } + } + + function scheduleStraddleRefresh() { + if (straddleTimer) clearTimeout(straddleTimer); + straddleTimer = setTimeout(() => void applyStraddleOnly(), 280); + } + async function compute(resetPage) { if (resetPage) pageNo = 1; const symbol = el("amp-symbol")?.value || "eth"; const startHour = Number(el("amp-start-hour")?.value || 16); const period = el("amp-period")?.value || "2m"; const customDays = Number(el("amp-custom-days")?.value || 60); + const straddlePremium = readPremium(); setStatus("计算中…(首次拉取 OKX K 线可能需数十秒)"); try { const data = await apiFetch("/api/amp-stats/compute", { @@ -151,6 +236,7 @@ start_hour: startHour, period, custom_days: period === "custom" ? customDays : null, + straddle_premium: straddlePremium, page: pageNo, page_size: 20, }), @@ -195,12 +281,14 @@ const startHour = Number(el("amp-start-hour")?.value || 16); const period = el("amp-period")?.value || "2m"; const customDays = Number(el("amp-custom-days")?.value || 60); + const prem = readPremium(); const q = new URLSearchParams({ symbol, start_hour: String(startHour), period, }); if (period === "custom") q.set("custom_days", String(customDays)); + if (prem != null) q.set("straddle_premium", String(prem)); window.location.href = "/api/amp-stats/export?" + q.toString(); } @@ -234,7 +322,10 @@ const id = card.getAttribute("data-id"); card.querySelector(".amp-hist-view")?.addEventListener("click", () => void openHistory(id)); card.querySelector(".amp-hist-dl")?.addEventListener("click", () => { - window.location.href = "/api/amp-stats/export?history_id=" + encodeURIComponent(id); + const prem = readPremium(); + let url = "/api/amp-stats/export?history_id=" + encodeURIComponent(id); + if (prem != null) url += "&straddle_premium=" + encodeURIComponent(String(prem)); + window.location.href = url; }); card.querySelector(".amp-hist-del")?.addEventListener("click", async () => { if (!confirm("删除该历史记录?")) return; @@ -255,6 +346,9 @@ if (lastResult) { if (el("amp-symbol")) el("amp-symbol").value = lastResult.symbol || "eth"; if (el("amp-start-hour")) el("amp-start-hour").value = String(lastResult.start_hour ?? 16); + if (lastResult.straddle_premium != null && el("amp-straddle-premium")) { + el("amp-straddle-premium").value = String(lastResult.straddle_premium); + } renderSummary(lastResult.summary, lastResult); const pagePayload = { page: 1, @@ -266,6 +360,7 @@ pageNo = 1; renderTable(pagePayload); setStatus("已载入历史 " + id); + void applyStraddleOnly(); } } catch (e) { setStatus(String(e && e.message ? e.message : e)); @@ -283,6 +378,7 @@ el("amp-btn-compute")?.addEventListener("click", () => void compute(true)); el("amp-btn-save")?.addEventListener("click", () => void saveHistory()); el("amp-btn-download")?.addEventListener("click", downloadCurrent); + el("amp-straddle-premium")?.addEventListener("input", scheduleStraddleRefresh); syncCustomDays(); } @@ -291,6 +387,7 @@ bind(); setView("stats"); setStatus(""); + renderStraddle(null); }, }; })(); diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 08b0048..ebdcd10 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -10939,55 +10939,58 @@ html[data-theme="light"] .hub-logs-card-hint { min-height: 360px; } } - -/* —— 振幅统计 —— */ -.amp-view-tabs { display: flex; gap: 8px; margin: 0 0 12px; } -.amp-view-tab { - min-height: 34px; padding: 6px 14px; border: 1px solid var(--border-soft); - border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; -} -.amp-view-tab.is-active { color: var(--text); border-color: var(--accent); background: rgba(0, 212, 255, 0.08); } -.amp-panel { padding: 14px 16px 18px; } -.amp-form { - display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 10px 12px; align-items: end; margin-bottom: 8px; -} -.amp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); } -.amp-field select, .amp-field input { - min-height: 34px; padding: 6px 8px; border-radius: 8px; - border: 1px solid var(--border-soft); background: var(--panel-solid); color: var(--text); -} -.amp-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } -.amp-hint { font-size: 12px; color: var(--muted); margin: 4px 0 12px; } -.amp-status { margin: 0 0 8px; } -.amp-block-title { font-size: 14px; margin: 14px 0 8px; } -.amp-sum-grid { - display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; -} -.amp-sum-grid > div { - border: 1px solid var(--border-soft); border-radius: 8px; padding: 8px 10px; - display: flex; flex-direction: column; gap: 2px; -} -.amp-sum-k { font-size: 11px; color: var(--muted); } -.amp-sum-v { font-size: 14px; font-weight: 600; color: var(--text); } -.amp-table-wrap { overflow-x: auto; } -.amp-table { width: 100%; border-collapse: collapse; font-size: 12px; } -.amp-table th, .amp-table td { - border-bottom: 1px solid var(--border-soft); padding: 7px 8px; text-align: right; white-space: nowrap; -} -.amp-table th:first-child, .amp-table td:first-child, -.amp-table th:nth-child(2), .amp-table td:nth-child(2) { text-align: left; } -.amp-pager { display: flex; align-items: center; gap: 10px; margin-top: 10px; } -.amp-pager-meta { font-size: 12px; color: var(--muted); } -.amp-empty { color: var(--muted); text-align: center; padding: 16px; } -.amp-history-list { display: flex; flex-direction: column; gap: 10px; } -.amp-hist-card { - display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; - border: 1px solid var(--border-soft); border-radius: 10px; padding: 10px 12px; -} -.amp-hist-sub { font-size: 12px; color: var(--muted); margin-top: 4px; } -.amp-hist-actions { display: flex; gap: 6px; align-items: center; } -@media (max-width: 720px) { - .amp-form { grid-template-columns: 1fr 1fr; } - .amp-actions { grid-column: 1 / -1; } + +/* —— 振幅统计 —— */ +.amp-view-tabs { display: flex; gap: 8px; margin: 0 0 12px; } +.amp-view-tab { + min-height: 34px; padding: 6px 14px; border: 1px solid var(--border-soft); + border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; +} +.amp-view-tab.is-active { color: var(--text); border-color: var(--accent); background: rgba(0, 212, 255, 0.08); } +.amp-panel { padding: 14px 16px 18px; } +.amp-form { + display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 10px 12px; align-items: end; margin-bottom: 8px; +} +.amp-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); } +.amp-field select, .amp-field input { + min-height: 34px; padding: 6px 8px; border-radius: 8px; + border: 1px solid var(--border-soft); background: var(--panel-solid); color: var(--text); +} +.amp-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } +.amp-hint { font-size: 12px; color: var(--muted); margin: 4px 0 12px; } +.amp-status { margin: 0 0 8px; } +.amp-block-title { font-size: 14px; margin: 14px 0 8px; } +.amp-sum-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; +} +.amp-sum-grid > div { + border: 1px solid var(--border-soft); border-radius: 8px; padding: 8px 10px; + display: flex; flex-direction: column; gap: 2px; +} +.amp-sum-k { font-size: 11px; color: var(--muted); } +.amp-sum-v { font-size: 14px; font-weight: 600; color: var(--text); } +.amp-sum-v.is-pos { color: var(--green); } +.amp-sum-v.is-neg { color: var(--red); } +.amp-straddle { margin-bottom: 4px; } +.amp-table-wrap { overflow-x: auto; } +.amp-table { width: 100%; border-collapse: collapse; font-size: 12px; } +.amp-table th, .amp-table td { + border-bottom: 1px solid var(--border-soft); padding: 7px 8px; text-align: right; white-space: nowrap; +} +.amp-table th:first-child, .amp-table td:first-child, +.amp-table th:nth-child(2), .amp-table td:nth-child(2) { text-align: left; } +.amp-pager { display: flex; align-items: center; gap: 10px; margin-top: 10px; } +.amp-pager-meta { font-size: 12px; color: var(--muted); } +.amp-empty { color: var(--muted); text-align: center; padding: 16px; } +.amp-history-list { display: flex; flex-direction: column; gap: 10px; } +.amp-hist-card { + display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; + border: 1px solid var(--border-soft); border-radius: 10px; padding: 10px 12px; +} +.amp-hist-sub { font-size: 12px; color: var(--muted); margin-top: 4px; } +.amp-hist-actions { display: flex; gap: 6px; align-items: center; } +@media (max-width: 720px) { + .amp-form { grid-template-columns: 1fr 1fr; } + .amp-actions { grid-column: 1 / -1; } } diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index b2c9001..7ed831d 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -16,7 +16,7 @@ - + @@ -1051,6 +1051,10 @@ 自定义天数 +
@@ -1058,9 +1062,11 @@

-

口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低(均为点数).未到 16:00 的当日不入样.

+

口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低(均为点数).未到 16:00 的当日不入样.买跨对照:越过用严格 >;盈亏=|收−开|−双边权利金.

汇总

+

买跨对照

+

日表明细

@@ -1490,7 +1496,7 @@ - + diff --git a/tests/test_amp_stats_lib.py b/tests/test_amp_stats_lib.py index ab81b5b..a800078 100644 --- a/tests/test_amp_stats_lib.py +++ b/tests/test_amp_stats_lib.py @@ -87,6 +87,32 @@ class AmpStatsLibTests(unittest.TestCase): self.assertEqual(s["max_amplitude_day"], "2026-07-02") self.assertEqual(s["max_up_points"], 500) self.assertEqual(s["max_down_points"], 200) + self.assertIsNone(s["straddle"]) + + def test_long_straddle_stats(self): + rows = [ + # |chg|=40>30 win+10; up=40>30; down=10 + {"up_points": 40, "down_points": 10, "change": 40, "amplitude": 50, "settlement_day": "2026-07-01"}, + # |chg|=10 lose-20; up=5; down=35>30 + {"up_points": 5, "down_points": 35, "change": -10, "amplitude": 40, "settlement_day": "2026-07-02"}, + # |chg|=30 not >30 lose-30; boundary + {"up_points": 30, "down_points": 30, "change": 30, "amplitude": 60, "settlement_day": "2026-07-03"}, + ] + s = summarize_rows(rows, straddle_premium=30) + st = s["straddle"] + self.assertEqual(st["side"], "long_straddle") + self.assertEqual(st["premium"], 30) + self.assertEqual(st["up_exceed_days"], 1) # only 40 + self.assertEqual(st["down_exceed_days"], 1) # only 35 + self.assertEqual(st["abs_change_exceed_days"], 1) # only 40 + self.assertAlmostEqual(st["pnl_total"], 40 - 30 + 10 - 30 + 30 - 30) + self.assertEqual(st["win_days"], 1) + self.assertEqual(st["win_ratio"], round(1 / 3, 4)) + csv_text = build_export_csv( + {"exchange": "okx", "symbol_label": "ETH", "summary": s, "rows": rows, "start_hour": 22, "end_hour": 16} + ) + self.assertIn("买跨对照", csv_text) + self.assertIn("买跨点数盈亏合计", csv_text) def test_compute_with_mock_fetch(self): now = datetime(2026, 7, 22, 18, 0, tzinfo=TZ)