新增实例数据统计页:复盘 KPI 上、资金曲线下,系统设置可关。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,345 @@
|
||||
/**
|
||||
* 实例数据统计:复盘 KPI + 资金曲线.
|
||||
*/
|
||||
(function (global) {
|
||||
"use strict";
|
||||
|
||||
var currentRange = "7d";
|
||||
var booted = false;
|
||||
|
||||
function $(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function root() {
|
||||
return $("options-data-stats");
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? "" : s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fmtPnl(v) {
|
||||
if (v == null || v === "") return "—";
|
||||
var n = Number(v);
|
||||
if (Number.isNaN(n)) return "—";
|
||||
return (n >= 0 ? "+" : "") + n.toFixed(2) + "U";
|
||||
}
|
||||
|
||||
function fmtHold(sec) {
|
||||
if (sec == null) return "—";
|
||||
var s = Math.max(0, Number(sec) || 0);
|
||||
if (s < 3600) return Math.round(s / 60) + "m";
|
||||
if (s < 86400) return (s / 3600).toFixed(1) + "h";
|
||||
return (s / 86400).toFixed(1) + "d";
|
||||
}
|
||||
|
||||
function pnlClass(v) {
|
||||
var n = Number(v);
|
||||
if (Number.isNaN(n) || n === 0) return "";
|
||||
return n > 0 ? "ods-pnl-pos" : "ods-pnl-neg";
|
||||
}
|
||||
|
||||
function closeReasonLabel(r) {
|
||||
var map = {
|
||||
perp_tp: "永续止盈",
|
||||
perp_sl: "永续止损",
|
||||
oo_expiry_loss: "期期到期亏损",
|
||||
oo_expiry_win: "期期到期盈利",
|
||||
target_win_leg: "期期平盈利腿",
|
||||
profit_rr_win_leg: "期期盈亏比达标·平盈利腿",
|
||||
};
|
||||
return map[r] || String(r || "未填");
|
||||
}
|
||||
|
||||
function sourceType() {
|
||||
var el = root();
|
||||
return (el && el.getAttribute("data-source-type")) || "option_spot";
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
var el = $("ods-status");
|
||||
if (el) el.textContent = text || "";
|
||||
}
|
||||
|
||||
function renderGroup(title, items) {
|
||||
if (!items || !items.length) return "";
|
||||
var lines = items
|
||||
.slice(0, 8)
|
||||
.map(function (g) {
|
||||
var keyLabel =
|
||||
title === "对冲结束原因" ? closeReasonLabel(g.key) : String(g.key || "");
|
||||
return (
|
||||
'<div class="ods-stat-row">' +
|
||||
'<span class="ods-stat-key">' +
|
||||
escapeHtml(keyLabel) +
|
||||
" · " +
|
||||
g.count +
|
||||
"笔</span>" +
|
||||
'<span class="ods-stat-val">' +
|
||||
fmtPnl(g.pnl_sum) +
|
||||
" / 胜" +
|
||||
(g.win_rate || 0) +
|
||||
"%</span>" +
|
||||
"</div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
return (
|
||||
'<div class="ods-stat-card"><div class="ods-stat-card-title">' +
|
||||
title +
|
||||
"</div>" +
|
||||
lines +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
|
||||
function loadReviewStats() {
|
||||
var kpi = $("ods-kpi");
|
||||
var groups = $("ods-stats-groups");
|
||||
if (!kpi || !groups) return Promise.resolve();
|
||||
var qs = new URLSearchParams();
|
||||
qs.set("source_type", sourceType());
|
||||
qs.set("sync", "0");
|
||||
return fetch("/api/options/review/stats?" + qs.toString(), {
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || !data.ok) {
|
||||
kpi.innerHTML = '<div class="muted" style="font-size:.76rem">统计加载失败</div>';
|
||||
groups.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
var k = data.kpi || {};
|
||||
kpi.innerHTML = [
|
||||
["笔数", k.total],
|
||||
["已复盘率", (k.review_rate || 0) + "%"],
|
||||
["胜率", (k.win_rate || 0) + "%"],
|
||||
["累计盈亏", fmtPnl(k.pnl_sum)],
|
||||
["平均盈亏", fmtPnl(k.avg_pnl)],
|
||||
["平均持有", fmtHold(k.avg_hold_sec)],
|
||||
]
|
||||
.map(function (pair) {
|
||||
var cls = "";
|
||||
if (pair[0] === "累计盈亏") cls = pnlClass(k.pnl_sum);
|
||||
if (pair[0] === "平均盈亏") cls = pnlClass(k.avg_pnl);
|
||||
return (
|
||||
'<div class="ods-kpi-tile"><div class="ods-kpi-label">' +
|
||||
pair[0] +
|
||||
'</div><div class="ods-kpi-value' +
|
||||
(cls ? " " + cls : "") +
|
||||
'">' +
|
||||
pair[1] +
|
||||
"</div></div>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
var html = [
|
||||
renderGroup("按类型", data.by_source_type),
|
||||
renderGroup("按标的", data.by_underlying),
|
||||
renderGroup("按策略", data.by_strategy),
|
||||
renderGroup("对冲结束原因", data.by_close_reason),
|
||||
renderGroup("持有周期", data.by_hold_bucket),
|
||||
renderGroup("Call/Put", data.by_opt_type),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("");
|
||||
groups.innerHTML =
|
||||
html || '<div class="muted" style="font-size:.76rem">暂无分组数据</div>';
|
||||
})
|
||||
.catch(function () {
|
||||
kpi.innerHTML = '<div class="muted" style="font-size:.76rem">统计加载失败</div>';
|
||||
groups.innerHTML = "";
|
||||
});
|
||||
}
|
||||
|
||||
function fmtCash(v) {
|
||||
if (v == null || Number.isNaN(Number(v))) return "—";
|
||||
return Number(v).toFixed(2) + "U";
|
||||
}
|
||||
|
||||
function drawCurve(points) {
|
||||
var svg = $("ods-curve-svg");
|
||||
if (!svg) return;
|
||||
var W = 640;
|
||||
var H = 240;
|
||||
var padL = 48;
|
||||
var padR = 12;
|
||||
var padT = 16;
|
||||
var padB = 28;
|
||||
var stroke = getComputedStyle(document.body).getPropertyValue("--accent") || "#5aa0ff";
|
||||
stroke = (stroke || "").trim() || "#5aa0ff";
|
||||
var grid = "rgba(127,127,127,.22)";
|
||||
var muted = "rgba(160,160,180,.75)";
|
||||
|
||||
if (!points || !points.length) {
|
||||
svg.innerHTML =
|
||||
'<text x="320" y="120" text-anchor="middle" fill="' +
|
||||
muted +
|
||||
'" font-size="13">暂无采样数据,请稍后再看</text>';
|
||||
return;
|
||||
}
|
||||
|
||||
var vals = points.map(function (p) {
|
||||
return Number(p.v);
|
||||
});
|
||||
var ts = points.map(function (p) {
|
||||
return Number(p.t);
|
||||
});
|
||||
var minV = Math.min.apply(null, vals);
|
||||
var maxV = Math.max.apply(null, vals);
|
||||
if (maxV - minV < 1e-6) {
|
||||
minV -= 1;
|
||||
maxV += 1;
|
||||
}
|
||||
var minT = ts[0];
|
||||
var maxT = ts[ts.length - 1];
|
||||
if (maxT <= minT) maxT = minT + 1;
|
||||
|
||||
function xOf(t) {
|
||||
return padL + ((t - minT) / (maxT - minT)) * (W - padL - padR);
|
||||
}
|
||||
function yOf(v) {
|
||||
return padT + (1 - (v - minV) / (maxV - minV)) * (H - padT - padB);
|
||||
}
|
||||
|
||||
var path = points
|
||||
.map(function (p, i) {
|
||||
var x = xOf(p.t);
|
||||
var y = yOf(p.v);
|
||||
return (i ? "L" : "M") + x.toFixed(1) + "," + y.toFixed(1);
|
||||
})
|
||||
.join(" ");
|
||||
|
||||
var yTicks = [minV, (minV + maxV) / 2, maxV];
|
||||
var gridHtml = yTicks
|
||||
.map(function (v) {
|
||||
var y = yOf(v);
|
||||
return (
|
||||
'<line x1="' +
|
||||
padL +
|
||||
'" y1="' +
|
||||
y.toFixed(1) +
|
||||
'" x2="' +
|
||||
(W - padR) +
|
||||
'" y2="' +
|
||||
y.toFixed(1) +
|
||||
'" stroke="' +
|
||||
grid +
|
||||
'" stroke-width="1"/>' +
|
||||
'<text x="' +
|
||||
(padL - 6) +
|
||||
'" y="' +
|
||||
(y + 4).toFixed(1) +
|
||||
'" text-anchor="end" fill="' +
|
||||
muted +
|
||||
'" font-size="10">' +
|
||||
Number(v).toFixed(0) +
|
||||
"</text>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
var last = points[points.length - 1];
|
||||
svg.innerHTML =
|
||||
gridHtml +
|
||||
'<path d="' +
|
||||
path +
|
||||
'" fill="none" stroke="' +
|
||||
stroke +
|
||||
'" stroke-width="2" vector-effect="non-scaling-stroke"/>' +
|
||||
'<circle cx="' +
|
||||
xOf(last.t).toFixed(1) +
|
||||
'" cy="' +
|
||||
yOf(last.v).toFixed(1) +
|
||||
'" r="3.5" fill="' +
|
||||
stroke +
|
||||
'"/>';
|
||||
}
|
||||
|
||||
function loadCashCurve() {
|
||||
var meta = $("ods-curve-meta");
|
||||
return fetch("/api/instance/cash_curve?range=" + encodeURIComponent(currentRange), {
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then(function (r) {
|
||||
return r.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
if (!data || !data.ok) {
|
||||
if (meta) meta.textContent = "曲线加载失败";
|
||||
drawCurve([]);
|
||||
return;
|
||||
}
|
||||
var pts = data.points || [];
|
||||
drawCurve(pts);
|
||||
if (meta) {
|
||||
meta.textContent =
|
||||
"采样 " +
|
||||
pts.length +
|
||||
" 点 · 最新 " +
|
||||
fmtCash(data.latest != null ? data.latest : pts.length ? pts[pts.length - 1].v : null);
|
||||
}
|
||||
})
|
||||
.catch(function () {
|
||||
if (meta) meta.textContent = "曲线加载失败";
|
||||
drawCurve([]);
|
||||
});
|
||||
}
|
||||
|
||||
function reloadAll() {
|
||||
setStatus("加载中…");
|
||||
return Promise.all([loadReviewStats(), loadCashCurve()]).then(function () {
|
||||
setStatus("");
|
||||
});
|
||||
}
|
||||
|
||||
function bindOnce() {
|
||||
if (booted || !root()) return;
|
||||
booted = true;
|
||||
var btn = $("ods-reload-btn");
|
||||
if (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
reloadAll();
|
||||
});
|
||||
}
|
||||
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (b) {
|
||||
b.addEventListener("click", function () {
|
||||
currentRange = b.getAttribute("data-range") || "7d";
|
||||
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (x) {
|
||||
x.classList.toggle("active", x === b);
|
||||
});
|
||||
loadCashCurve();
|
||||
});
|
||||
});
|
||||
// 默认 7 日更有曲线感;若用户点今日再切
|
||||
var def7 = document.querySelector('#options-data-stats .ods-range-btn[data-range="7d"]');
|
||||
if (def7) {
|
||||
document.querySelectorAll("#options-data-stats .ods-range-btn").forEach(function (x) {
|
||||
x.classList.toggle("active", x === def7);
|
||||
});
|
||||
currentRange = "7d";
|
||||
}
|
||||
reloadAll();
|
||||
}
|
||||
|
||||
function bootIfPresent() {
|
||||
if (root()) bindOnce();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", bootIfPresent);
|
||||
} else {
|
||||
bootIfPresent();
|
||||
}
|
||||
|
||||
// embed 软切 tab 时脚本可能再次插入
|
||||
global.__OPTIONS_DATA_STATS_BOOT__ = bootIfPresent;
|
||||
})(typeof window !== "undefined" ? window : this);
|
||||
@@ -0,0 +1,158 @@
|
||||
"""实例资金曲线采样:现金余额(不含权利金/浮盈),供数据统计页使用."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
_lock = threading.Lock()
|
||||
_last_sample_at = 0.0
|
||||
_thread: threading.Thread | None = None
|
||||
_stop = threading.Event()
|
||||
|
||||
|
||||
def cash_sample_interval_sec() -> float:
|
||||
try:
|
||||
return max(30.0, float(os.getenv("INSTANCE_CASH_SAMPLE_SEC") or 60))
|
||||
except (TypeError, ValueError):
|
||||
return 60.0
|
||||
|
||||
|
||||
def cash_sample_keep_days() -> int:
|
||||
try:
|
||||
return max(7, int(float(os.getenv("INSTANCE_CASH_SAMPLE_KEEP_DAYS") or 90)))
|
||||
except (TypeError, ValueError):
|
||||
return 90
|
||||
|
||||
|
||||
def init_cash_sample_table(conn) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS instance_cash_samples (
|
||||
ts_ms INTEGER PRIMARY KEY,
|
||||
cash_usdt REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _safe_float(v: Any) -> Optional[float]:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def cash_usdt_from_balances(bal: dict[str, Any] | None) -> Optional[float]:
|
||||
"""现金 = 交易账户 + 资金账户(优先 USDT,否则 USDC);不含权利金/浮盈."""
|
||||
if not isinstance(bal, dict):
|
||||
return None
|
||||
parts = [_safe_float(bal.get("trading_usdt")), _safe_float(bal.get("funding_usdt"))]
|
||||
if all(x is None for x in parts):
|
||||
parts = [_safe_float(bal.get("trading_usdc")), _safe_float(bal.get("funding_usdc"))]
|
||||
known = [x for x in parts if x is not None]
|
||||
if not known:
|
||||
return None
|
||||
return round(sum(known), 4)
|
||||
|
||||
|
||||
def record_cash_sample(conn, cash_usdt: float, *, ts_ms: int | None = None) -> None:
|
||||
init_cash_sample_table(conn)
|
||||
t = int(ts_ms if ts_ms is not None else time.time() * 1000)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO instance_cash_samples(ts_ms, cash_usdt)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(ts_ms) DO UPDATE SET cash_usdt=excluded.cash_usdt
|
||||
""",
|
||||
(t, float(cash_usdt)),
|
||||
)
|
||||
keep_ms = cash_sample_keep_days() * 86400 * 1000
|
||||
cutoff = t - keep_ms
|
||||
conn.execute("DELETE FROM instance_cash_samples WHERE ts_ms < ?", (cutoff,))
|
||||
|
||||
|
||||
def _range_start_ms(range_key: str) -> tuple[str, int]:
|
||||
rk = (range_key or "7d").strip().lower()
|
||||
now_ms = int(time.time() * 1000)
|
||||
if rk in ("today", "1d"):
|
||||
start = dt.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return "today", int(start.timestamp() * 1000)
|
||||
if rk in ("30d", "30"):
|
||||
return "30d", now_ms - 30 * 86400 * 1000
|
||||
if rk in ("all", "0"):
|
||||
return "all", 0
|
||||
return "7d", now_ms - 7 * 86400 * 1000
|
||||
|
||||
|
||||
def query_cash_series_with_db(conn, *, range_key: str = "7d") -> dict[str, Any]:
|
||||
rk, start_ms = _range_start_ms(range_key)
|
||||
now_ms = int(time.time() * 1000)
|
||||
init_cash_sample_table(conn)
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT ts_ms, cash_usdt
|
||||
FROM instance_cash_samples
|
||||
WHERE ts_ms >= ?
|
||||
ORDER BY ts_ms ASC
|
||||
""",
|
||||
(start_ms,),
|
||||
).fetchall()
|
||||
points = [{"t": int(r["ts_ms"]), "v": float(r["cash_usdt"])} for r in rows]
|
||||
return {
|
||||
"ok": True,
|
||||
"range": rk,
|
||||
"points": points,
|
||||
"start_ms": start_ms,
|
||||
"end_ms": now_ms,
|
||||
"latest": points[-1]["v"] if points else None,
|
||||
}
|
||||
|
||||
|
||||
def sample_once(get_db: Callable, fetch_balances: Callable) -> bool:
|
||||
"""拉取余额并写入采样;成功返回 True."""
|
||||
global _last_sample_at
|
||||
try:
|
||||
bal = fetch_balances()
|
||||
except Exception:
|
||||
return False
|
||||
cash = cash_usdt_from_balances(bal if isinstance(bal, dict) else None)
|
||||
if cash is None:
|
||||
return False
|
||||
with _lock:
|
||||
conn = get_db()
|
||||
try:
|
||||
record_cash_sample(conn, cash)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
_last_sample_at = time.time()
|
||||
return True
|
||||
|
||||
|
||||
def start_cash_sample_loop(get_db: Callable, fetch_balances: Callable) -> None:
|
||||
global _thread
|
||||
if _thread and _thread.is_alive():
|
||||
return
|
||||
_stop.clear()
|
||||
|
||||
def _loop() -> None:
|
||||
# 启动后立刻采一笔,避免曲线长期空白
|
||||
try:
|
||||
sample_once(get_db, fetch_balances)
|
||||
except Exception:
|
||||
pass
|
||||
while not _stop.is_set():
|
||||
if _stop.wait(cash_sample_interval_sec()):
|
||||
break
|
||||
try:
|
||||
sample_once(get_db, fetch_balances)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_thread = threading.Thread(target=_loop, daemon=True, name="instance-cash-sample")
|
||||
_thread.start()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""实例数据统计:复盘 KPI + 资金曲线 API,以及后台现金采样."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import Flask, jsonify, request
|
||||
|
||||
|
||||
def install_instance_data_stats(app: Flask, app_module: Any) -> None:
|
||||
get_db: Callable = app_module.get_db
|
||||
login_required = app_module.login_required
|
||||
|
||||
def _fetch_balances() -> dict[str, Any]:
|
||||
ex = getattr(app_module, "exchange_options", None)
|
||||
if ex is None:
|
||||
return {}
|
||||
from lib.exchange.okx_options_lib import fetch_options_balances
|
||||
|
||||
return fetch_options_balances(ex, force=False) or {}
|
||||
|
||||
@app.route("/api/instance/cash_curve")
|
||||
@login_required
|
||||
def api_instance_cash_curve():
|
||||
from lib.instance.instance_cash_sample_lib import (
|
||||
query_cash_series_with_db,
|
||||
sample_once,
|
||||
)
|
||||
|
||||
# 打开页面时尽量补一笔最新点(失败忽略)
|
||||
try:
|
||||
sample_once(get_db, _fetch_balances)
|
||||
except Exception:
|
||||
pass
|
||||
range_key = (request.args.get("range") or "7d").strip()
|
||||
conn = get_db()
|
||||
try:
|
||||
return jsonify(query_cash_series_with_db(conn, range_key=range_key))
|
||||
except Exception as e:
|
||||
return jsonify({"ok": False, "msg": f"加载资金曲线失败: {e}"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 后台采样(daemon);无密钥时会空转跳过
|
||||
try:
|
||||
from lib.instance.instance_cash_sample_lib import start_cash_sample_loop
|
||||
|
||||
start_cash_sample_loop(get_db, _fetch_balances)
|
||||
except Exception as e:
|
||||
print(f"[instance_cash_sample] start skipped: {e}", flush=True)
|
||||
@@ -15,7 +15,7 @@ DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = {
|
||||
"show_nav_strategy": False,
|
||||
"show_nav_strategy_records": False,
|
||||
"show_nav_records": False, # 永续交易记录与复盘已移除
|
||||
"show_nav_stats": False,
|
||||
"show_nav_stats": True, # 数据统计(复盘 KPI + 资金曲线);可在系统设置关闭
|
||||
"show_nav_risk_policy": True,
|
||||
"show_nav_live_guide": True,
|
||||
"show_nav_system_guide": False,
|
||||
@@ -37,7 +37,7 @@ DISPLAY_LABELS: dict[str, str] = {
|
||||
"show_nav_strategy": "策略交易",
|
||||
"show_nav_strategy_records": "策略交易记录",
|
||||
"show_nav_records": "交易记录与复盘",
|
||||
"show_nav_stats": "统计分析",
|
||||
"show_nav_stats": "数据统计",
|
||||
"show_nav_risk_policy": "风控说明",
|
||||
"show_nav_live_guide": "实盘说明",
|
||||
"show_nav_system_guide": "系统说明",
|
||||
@@ -111,8 +111,8 @@ def display_prefs_template_context(get_db: Callable) -> dict[str, Any]:
|
||||
def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool:
|
||||
prefs = normalize_display_prefs(display or {})
|
||||
t = (tab or "").strip()
|
||||
if t in ("trade", "records", "stats", "hedge_plan"):
|
||||
return False # 永续下单/记录/统计已下线;关键位监控保留
|
||||
if t in ("trade", "records", "hedge_plan"):
|
||||
return False # 永续下单/记录已下线;关键位监控保留
|
||||
key = NAV_TAB_ALLOWED.get(t)
|
||||
if not key:
|
||||
return True
|
||||
@@ -124,6 +124,7 @@ def display_meta_for_ui() -> list[dict[str, Any]]:
|
||||
"show_nav_dashboard",
|
||||
"show_nav_account_ledger",
|
||||
"show_nav_key_monitor",
|
||||
"show_nav_stats",
|
||||
"show_nav_risk_policy",
|
||||
"show_nav_live_guide",
|
||||
"show_nav_system_guide",
|
||||
|
||||
@@ -62,7 +62,7 @@ def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
|
||||
key_history=page == "key_monitor",
|
||||
key_list=page in ("key_monitor", "trade") or is_strategy,
|
||||
orders=page == "trade" or is_strategy,
|
||||
stats_bundle=page == "stats",
|
||||
stats_bundle=False, # 旧永续统计已弃用;数据统计页走复盘 API + 资金曲线
|
||||
strategy=is_strategy,
|
||||
orphan_live=page == "trade" and is_shell,
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ EMBED_TABS: tuple[str, ...] = (
|
||||
"key_monitor",
|
||||
"options",
|
||||
"options_review",
|
||||
"stats",
|
||||
"risk_policy",
|
||||
"live_guide",
|
||||
"system_guide",
|
||||
@@ -27,7 +28,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
|
||||
"/": "options",
|
||||
"/trade": "options", # 实盘下单界面已移除
|
||||
"/records": "options_review", # 永续交易记录与复盘已移除
|
||||
"/stats": "options",
|
||||
"/stats": "stats",
|
||||
"/dashboard": "dashboard",
|
||||
"/account_ledger": "account_ledger",
|
||||
"/key_monitor": "key_monitor",
|
||||
|
||||
@@ -42,6 +42,7 @@ _COMMON_STATIC_ASSETS: dict[str, str] = {
|
||||
"options_settings.js": "application/javascript; charset=utf-8",
|
||||
"options_position_cards.js": "application/javascript; charset=utf-8",
|
||||
"options_review.js": "application/javascript; charset=utf-8",
|
||||
"options_data_stats.js": "application/javascript; charset=utf-8",
|
||||
"order_entry_model.js": "application/javascript; charset=utf-8",
|
||||
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
||||
"focus_chart_page.css": "text/css; charset=utf-8",
|
||||
|
||||
@@ -127,6 +127,8 @@
|
||||
{% include 'options_panel.html' %}
|
||||
{% elif page == 'options_review' %}
|
||||
{% include 'options_review_panel.html' %}
|
||||
{% elif page == 'stats' %}
|
||||
{% include 'options_data_stats_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
{% if options_nav_visible and display.show_nav_options_review %}
|
||||
<a href="/options/review" data-embed-tab="options_review" class="{% if initial_tab == 'options_review' %}active{% endif %}">期权复盘</a>
|
||||
{% endif %}
|
||||
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
|
||||
{% if display.show_nav_risk_policy %}
|
||||
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a>
|
||||
{% endif %}
|
||||
@@ -66,7 +67,7 @@
|
||||
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
|
||||
|
||||
{% include 'instance_header_panel.html' %}
|
||||
{% if initial_tab not in ('settings', 'risk_policy', 'live_guide', 'system_guide', 'env_config', 'options_review') and include_transfer_block %}
|
||||
{% if initial_tab not in ('settings', 'risk_policy', 'live_guide', 'system_guide', 'env_config', 'options_review', 'stats') and include_transfer_block %}
|
||||
{% include 'instance_top_bar.html' %}
|
||||
{% endif %}
|
||||
|
||||
@@ -113,6 +114,7 @@
|
||||
{% if options_nav_visible and display.show_nav_options_review %}
|
||||
<a href="/options/review" data-embed-tab="options_review">期权复盘</a>
|
||||
{% endif %}
|
||||
<a href="/stats" data-embed-tab="stats"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
|
||||
{% if display.show_nav_risk_policy %}
|
||||
<a href="/risk_policy" data-embed-tab="risk_policy">风控说明</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -168,15 +168,13 @@
|
||||
{% if display.show_nav_records %}
|
||||
<a href="/records" data-embed-tab="records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
|
||||
{% endif %}
|
||||
{% if display.show_nav_stats %}
|
||||
<a href="/stats" data-embed-tab="stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
|
||||
{% endif %}
|
||||
{% if options_nav_visible and display.show_nav_options %}
|
||||
<a href="/options" data-embed-tab="options" class="{% if page == 'options' %}active{% endif %}">期权</a>
|
||||
{% endif %}
|
||||
{% if options_nav_visible and display.show_nav_options_review %}
|
||||
<a href="/options/review" data-embed-tab="options_review" class="{% if page == 'options_review' %}active{% endif %}">期权复盘</a>
|
||||
{% endif %}
|
||||
<a href="/stats" data-embed-tab="stats" class="{% if page == 'stats' %}active{% endif %}"{% if not display.show_nav_stats %} style="display:none"{% endif %}>数据统计</a>
|
||||
{% if display.show_nav_risk_policy %}
|
||||
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
|
||||
{% endif %}
|
||||
@@ -194,7 +192,7 @@
|
||||
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
|
||||
|
||||
{% include 'instance_header_panel.html' %}
|
||||
{% if page not in ('settings', 'risk_policy', 'live_guide', 'system_guide', 'env_config', 'options', 'options_review') %}
|
||||
{% if page not in ('settings', 'risk_policy', 'live_guide', 'system_guide', 'env_config', 'options', 'options_review', 'stats') %}
|
||||
{% include 'instance_top_bar.html' %}
|
||||
{% endif %}
|
||||
|
||||
@@ -359,6 +357,8 @@
|
||||
{% include 'options_panel.html' %}
|
||||
{% elif page == 'options_review' %}
|
||||
{% include 'options_review_panel.html' %}
|
||||
{% elif page == 'stats' %}
|
||||
{% include 'options_data_stats_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -387,44 +387,6 @@
|
||||
{% include 'settings_panel.html' %}
|
||||
{% endif %}
|
||||
|
||||
{% if page == 'stats' %}
|
||||
<div class="card stats-card full" id="stats-card">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap">
|
||||
<h2 style="margin-bottom:0">数据统计</h2>
|
||||
<button type="button" class="stats-toggle" id="stats-toggle-btn" onclick="toggleStatsCard()">折叠</button>
|
||||
</div>
|
||||
<div class="stats-content" id="stats-content">
|
||||
<div class="sub" style="margin-bottom:12px;color:#8892b0;font-size:.82rem">
|
||||
统计分析按<strong>北京时间 {{ stats_bundle.stats_reset_hour }}:00</strong>切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计):
|
||||
<strong style="color:#cfd3ef">{{ stats_bundle.total_opens_all }}</strong> 次
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom:14px;align-items:center">
|
||||
<label style="display:flex;align-items:center;gap:8px;font-size:.88rem;color:#cfd3ef">
|
||||
统计品类
|
||||
<select id="stats-segment-select" onchange="switchStatsSegment()" style="min-width:200px">
|
||||
{% for seg in stats_bundle.segments %}
|
||||
<option value="{{ seg.key }}">{{ seg.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{% for seg in stats_bundle.segments %}
|
||||
<div class="stats-segment-block stats-segment-panel" data-stats-segment="{{ seg.key }}"{% if not loop.first %} style="display:none"{% endif %}>
|
||||
<div class="stats-period-tabs" role="tablist" aria-label="统计周期">
|
||||
<button type="button" class="stats-period-tab active" data-stats-period="day" role="tab" aria-selected="true" onclick="switchStatsPeriod('day')">日统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="week" role="tab" aria-selected="false" onclick="switchStatsPeriod('week')">周统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="month" role="tab" aria-selected="false" onclick="switchStatsPeriod('month')">月统计</button>
|
||||
<button type="button" class="stats-period-tab" data-stats-period="all" role="tab" aria-selected="false" onclick="switchStatsPeriod('all')">全部统计</button>
|
||||
</div>
|
||||
{{ period_stats_pane("day", seg.day) }}
|
||||
{{ period_stats_pane("week", seg.week) }}
|
||||
{{ period_stats_pane("month", seg.month) }}
|
||||
{{ period_stats_pane("all", seg.all) }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal" id="imgModal" onclick="closeModal()">
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
{# 实例数据统计:上方期权复盘 KPI/分组,下方资金曲线 #}
|
||||
<div class="card ods-page full" id="options-data-stats" data-source-type="option_spot">
|
||||
<style>
|
||||
.ods-page{grid-column:1/-1}
|
||||
.ods-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}
|
||||
.ods-head h2{margin:0;font-size:1.05rem}
|
||||
.ods-section{margin-bottom:22px}
|
||||
.ods-section-title{font-weight:650;font-size:.9rem;margin-bottom:4px}
|
||||
.ods-section-desc{font-size:.72rem;opacity:.7;margin:0 0 12px}
|
||||
.ods-kpi-row{
|
||||
display:grid;grid-template-columns:repeat(6,minmax(0,1fr));
|
||||
gap:8px;margin-bottom:12px;
|
||||
}
|
||||
.ods-kpi-tile{
|
||||
border:1px solid rgba(127,127,127,.22);border-radius:10px;
|
||||
padding:10px 12px;background:rgba(0,0,0,.2);min-width:0;
|
||||
}
|
||||
.ods-kpi-label{font-size:.7rem;opacity:.7;margin-bottom:4px}
|
||||
.ods-kpi-value{font-size:.95rem;font-weight:650;letter-spacing:.01em;word-break:break-all}
|
||||
.ods-stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px}
|
||||
.ods-stat-card{
|
||||
border:1px solid rgba(127,127,127,.22);border-radius:10px;
|
||||
padding:10px 12px;background:rgba(0,0,0,.16);font-size:.76rem;
|
||||
}
|
||||
.ods-stat-card-title{
|
||||
font-weight:650;margin-bottom:8px;font-size:.78rem;
|
||||
padding-bottom:6px;border-bottom:1px solid rgba(127,127,127,.18);
|
||||
}
|
||||
.ods-stat-row{
|
||||
display:flex;justify-content:space-between;align-items:baseline;gap:10px;
|
||||
padding:5px 0;border-bottom:1px solid rgba(127,127,127,.1);
|
||||
}
|
||||
.ods-stat-row:last-child{border-bottom:none;padding-bottom:0}
|
||||
.ods-stat-key{opacity:.9;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
||||
.ods-stat-val{flex-shrink:0;font-variant-numeric:tabular-nums;opacity:.85}
|
||||
.ods-curve-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:10px}
|
||||
.ods-range-btn{
|
||||
font-size:.76rem;padding:4px 10px;border-radius:6px;cursor:pointer;
|
||||
border:1px solid rgba(127,127,127,.3);background:transparent;color:inherit;
|
||||
}
|
||||
.ods-range-btn.active{border-color:rgba(90,160,255,.7);background:rgba(90,160,255,.12)}
|
||||
.ods-curve-meta{font-size:.72rem;opacity:.75;margin-left:auto}
|
||||
.ods-curve-wrap{
|
||||
border:1px solid rgba(127,127,127,.22);border-radius:10px;
|
||||
padding:12px 10px 8px;background:rgba(0,0,0,.14);
|
||||
}
|
||||
.ods-curve-svg{width:100%;height:240px;display:block}
|
||||
.ods-pnl-pos{color:#3dd68c}
|
||||
.ods-pnl-neg{color:#f07178}
|
||||
@media (max-width:900px){.ods-kpi-row{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
||||
@media (max-width:640px){
|
||||
.ods-kpi-row{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.ods-curve-svg{height:200px}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="ods-head">
|
||||
<h2>数据统计</h2>
|
||||
<span class="muted" id="ods-status" style="font-size:.72rem"></span>
|
||||
<button type="button" class="btn-secondary" id="ods-reload-btn" style="font-size:.76rem;padding:4px 10px">刷新</button>
|
||||
</div>
|
||||
|
||||
<section class="ods-section" aria-labelledby="ods-review-heading">
|
||||
<div class="ods-section-title" id="ods-review-heading">期权复盘统计</div>
|
||||
<p class="ods-section-desc">与「期权复盘」页统计同源;复盘页内统计仍保留。</p>
|
||||
<div id="ods-kpi" class="ods-kpi-row"></div>
|
||||
<div id="ods-stats-groups" class="ods-stats-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="ods-section" aria-labelledby="ods-curve-heading">
|
||||
<div class="ods-section-title" id="ods-curve-heading">资金曲线</div>
|
||||
<p class="ods-section-desc">现金余额(交易账户+资金账户,不含权利金与浮盈);后台定时采样。</p>
|
||||
<div class="ods-curve-toolbar">
|
||||
<button type="button" class="ods-range-btn active" data-range="today">今日</button>
|
||||
<button type="button" class="ods-range-btn" data-range="7d">7日</button>
|
||||
<button type="button" class="ods-range-btn" data-range="30d">30日</button>
|
||||
<button type="button" class="ods-range-btn" data-range="all">全部</button>
|
||||
<span class="ods-curve-meta" id="ods-curve-meta">—</span>
|
||||
</div>
|
||||
<div class="ods-curve-wrap">
|
||||
<svg id="ods-curve-svg" class="ods-curve-svg" viewBox="0 0 640 240" preserveAspectRatio="none" role="img" aria-label="资金曲线"></svg>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="/static/options_data_stats.js?v=1"></script>
|
||||
Reference in New Issue
Block a user