diff --git a/app.py b/app.py index c77899b..957150e 100644 --- a/app.py +++ b/app.py @@ -1581,6 +1581,9 @@ def init_db(): from lib.options.options_db import init_options_tables init_options_tables(conn) + from lib.instance.instance_cash_sample_lib import init_cash_sample_table + + init_cash_sample_table(conn) from lib.sim.db_lib import init_sim_tables init_sim_tables(conn) from lib.trade.account_risk_lib import ensure_account_risk_schema @@ -4277,7 +4280,10 @@ def records_page(): @app.route("/stats") @login_required def stats_page(): - return redirect("/options") + redir = redirect_to_embed_shell_if_enabled("stats") + if redir is not None: + return redir + return render_main_page("stats") @app.route("/dashboard") @@ -5730,6 +5736,10 @@ from lib.options.options_review_register import install_options_review install_options_review(app, _REPO_ROOT, app_module=sys.modules[__name__]) +from lib.instance.instance_data_stats_register import install_instance_data_stats + +install_instance_data_stats(app, sys.modules[__name__]) + from lib.hub.hub_bridge_register import register_hub_bridge_routes register_hub_bridge_routes(app, sys.modules[__name__]) diff --git a/lib/common/static/options_data_stats.js b/lib/common/static/options_data_stats.js new file mode 100644 index 0000000..f08b825 --- /dev/null +++ b/lib/common/static/options_data_stats.js @@ -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, """); + } + + 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 ( + '
' + + '' + + escapeHtml(keyLabel) + + " · " + + g.count + + "笔" + + '' + + fmtPnl(g.pnl_sum) + + " / 胜" + + (g.win_rate || 0) + + "%" + + "
" + ); + }) + .join(""); + return ( + '
' + + title + + "
" + + lines + + "
" + ); + } + + 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 = '
统计加载失败
'; + 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 ( + '
' + + pair[0] + + '
' + + pair[1] + + "
" + ); + }) + .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 || '
暂无分组数据
'; + }) + .catch(function () { + kpi.innerHTML = '
统计加载失败
'; + 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 = + '暂无采样数据,请稍后再看'; + 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 ( + '' + + '' + + Number(v).toFixed(0) + + "" + ); + }) + .join(""); + + var last = points[points.length - 1]; + svg.innerHTML = + gridHtml + + '' + + ''; + } + + 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); diff --git a/lib/instance/instance_cash_sample_lib.py b/lib/instance/instance_cash_sample_lib.py new file mode 100644 index 0000000..6c76a9e --- /dev/null +++ b/lib/instance/instance_cash_sample_lib.py @@ -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() diff --git a/lib/instance/instance_data_stats_register.py b/lib/instance/instance_data_stats_register.py new file mode 100644 index 0000000..485a0d4 --- /dev/null +++ b/lib/instance/instance_data_stats_register.py @@ -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) diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py index d215954..b363f1e 100644 --- a/lib/instance/instance_display_prefs_lib.py +++ b/lib/instance/instance_display_prefs_lib.py @@ -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", diff --git a/lib/instance/instance_embed_context_lib.py b/lib/instance/instance_embed_context_lib.py index c88a0a1..2385d9d 100644 --- a/lib/instance/instance_embed_context_lib.py +++ b/lib/instance/instance_embed_context_lib.py @@ -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, ) diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py index ceff27c..8fa7caa 100644 --- a/lib/instance/instance_embed_lib.py +++ b/lib/instance/instance_embed_lib.py @@ -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", diff --git a/lib/instance/instance_static_lib.py b/lib/instance/instance_static_lib.py index ccbee36..4d23840 100644 --- a/lib/instance/instance_static_lib.py +++ b/lib/instance/instance_static_lib.py @@ -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", diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html index 819d572..ddd193d 100644 --- a/lib/instance/templates/embed_page_fragment.html +++ b/lib/instance/templates/embed_page_fragment.html @@ -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 %} diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 756a581..a539e18 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -49,6 +49,7 @@ {% if options_nav_visible and display.show_nav_options_review %} 期权复盘 {% endif %} + {% if display.show_nav_risk_policy %} 风控说明 {% endif %} @@ -66,7 +67,7 @@ {% 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 %} 期权复盘 {% endif %} + 数据统计 {% if display.show_nav_risk_policy %} 风控说明 {% endif %} diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index ba829e8..a6c7af6 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -168,15 +168,13 @@ {% if display.show_nav_records %} 交易记录与复盘 {% endif %} - {% if display.show_nav_stats %} - 统计分析 - {% endif %} {% if options_nav_visible and display.show_nav_options %} 期权 {% endif %} {% if options_nav_visible and display.show_nav_options_review %} 期权复盘 {% endif %} + {% if display.show_nav_risk_policy %} 风控说明 {% endif %} @@ -194,7 +192,7 @@ {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% 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' %} -
-
-

数据统计

- -
-
-
- 统计分析按北京时间 {{ stats_bundle.stats_reset_hour }}:00切日计入(与顶栏 UTC 列表窗无关).历史总开仓(累计): - {{ stats_bundle.total_opens_all }} 次 -
-
- -
- {% for seg in stats_bundle.segments %} - - {% endfor %} -
-
- {% endif %}