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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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 | 买跨对照:可设双边权利金、越过占比与收盘盈亏 |
|
||||
|
||||
+101
-3
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = '<p class="amp-empty">暂无汇总</p>';
|
||||
renderStraddle(null);
|
||||
return;
|
||||
}
|
||||
box.innerHTML =
|
||||
@@ -87,6 +109,35 @@
|
||||
`<div><span class="amp-sum-k">涨/跌窗占比</span><span class="amp-sum-v">${esc(s.up_day_ratio)} / ${esc(s.down_day_ratio)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">价源</span><span class="amp-sum-v">${esc(result && result.price_source)}</span></div>` +
|
||||
`</div>`;
|
||||
renderStraddle(s.straddle);
|
||||
}
|
||||
|
||||
function renderStraddle(st) {
|
||||
const box = el("amp-straddle");
|
||||
if (!box) return;
|
||||
if (!st) {
|
||||
box.innerHTML = '<p class="amp-empty">填写「买跨·双边权利金」后计算,可看越过天数与买跨点数盈亏</p>';
|
||||
return;
|
||||
}
|
||||
const verdict =
|
||||
st.pnl_total == null
|
||||
? "—"
|
||||
: Number(st.pnl_total) > 0
|
||||
? "样本合计盈利"
|
||||
: Number(st.pnl_total) < 0
|
||||
? "样本合计亏损"
|
||||
: "样本合计持平";
|
||||
box.innerHTML =
|
||||
`<div class="amp-sum-grid">` +
|
||||
`<div><span class="amp-sum-k">双边权利金</span><span class="amp-sum-v">${esc(st.premium)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">开→高超过</span><span class="amp-sum-v">${esc(st.up_exceed_days)} 天 · ${esc(pct(st.up_exceed_ratio))}</span></div>` +
|
||||
`<div><span class="amp-sum-k">开→低超过</span><span class="amp-sum-v">${esc(st.down_exceed_days)} 天 · ${esc(pct(st.down_exceed_ratio))}</span></div>` +
|
||||
`<div><span class="amp-sum-k">|涨跌|超过</span><span class="amp-sum-v">${esc(st.abs_change_exceed_days)} 天 · ${esc(pct(st.abs_change_exceed_ratio))}</span></div>` +
|
||||
`<div><span class="amp-sum-k">买跨盈亏合计</span><span class="amp-sum-v ${pnlClass(st.pnl_total)}">${esc(st.pnl_total)} <small>(${esc(verdict)})</small></span></div>` +
|
||||
`<div><span class="amp-sum-k">日均盈亏</span><span class="amp-sum-v ${pnlClass(st.pnl_avg)}">${esc(st.pnl_avg)}</span></div>` +
|
||||
`<div><span class="amp-sum-k">赚钱天数/胜率</span><span class="amp-sum-v">${esc(st.win_days)} · ${esc(pct(st.win_ratio))}</span></div>` +
|
||||
`<div><span class="amp-sum-k">单日最大赚/亏</span><span class="amp-sum-v">${esc(st.pnl_max)} / ${esc(st.pnl_min)}</span></div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
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);
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260723-amp-stats" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260723-amp-straddle" />
|
||||
<link rel="stylesheet" href="/assets/trade_stats_calendar.css?v=4" />
|
||||
<link rel="stylesheet" href="/assets/account_risk_badge.css?v=4" />
|
||||
<script src="/assets/account_risk_badge.js?v=4"></script>
|
||||
@@ -1051,6 +1051,10 @@
|
||||
<span>自定义天数</span>
|
||||
<input id="amp-custom-days" type="number" min="7" max="400" value="60" />
|
||||
</label>
|
||||
<label class="amp-field">
|
||||
<span>买跨·双边权利金(点)</span>
|
||||
<input id="amp-straddle-premium" type="number" min="0" step="any" placeholder="如 30" />
|
||||
</label>
|
||||
<div class="amp-actions">
|
||||
<button type="button" id="amp-btn-compute" class="primary">计算</button>
|
||||
<button type="button" id="amp-btn-save" class="ghost">保存到历史</button>
|
||||
@@ -1058,9 +1062,11 @@
|
||||
</div>
|
||||
</div>
|
||||
<p id="amp-status" class="toolbar-meta amp-status"></p>
|
||||
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低(均为点数).未到 16:00 的当日不入样.</p>
|
||||
<p class="amp-hint">口径:开→高=最高−开盘;开→低=开盘−最低;振幅=最高−最低(均为点数).未到 16:00 的当日不入样.买跨对照:越过用严格 >;盈亏=|收−开|−双边权利金.</p>
|
||||
<h3 class="amp-block-title">汇总</h3>
|
||||
<div id="amp-summary" class="amp-summary"></div>
|
||||
<h3 class="amp-block-title">买跨对照</h3>
|
||||
<div id="amp-straddle" class="amp-summary amp-straddle"></div>
|
||||
<h3 class="amp-block-title">日表明细</h3>
|
||||
<div class="amp-table-wrap">
|
||||
<table class="amp-table">
|
||||
@@ -1490,7 +1496,7 @@
|
||||
<script src="/assets/funds.js?v=20260717-funds-scroll-fix"></script>
|
||||
<script src="/assets/dashboard.js?v=20260720-dash-sl-tp"></script>
|
||||
<script src="/assets/strategy.js?v=9"></script>
|
||||
<script src="/assets/amp_stats.js?v=1"></script>
|
||||
<script src="/assets/amp_stats.js?v=2"></script>
|
||||
<script src="/assets/help.js?v=1"></script>
|
||||
<script src="/assets/logs.js?v=1"></script>
|
||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user