diff --git a/app.py b/app.py index 37d53aa..9dcb70f 100644 --- a/app.py +++ b/app.py @@ -222,6 +222,7 @@ def _static_asset_v() -> str: base = os.path.dirname(os.path.abspath(__file__)) rels = ( "static/js/trade.js", + "static/js/dashboard.js", "static/js/orientation.js", "static/css/records.css", "static/js/records.js", @@ -229,6 +230,7 @@ def _static_asset_v() -> str: "static/css/mobile.css", "static/css/responsive.css", "static/css/trade.css", + "static/css/dashboard.css", "static/css/base.css", ) mtimes = [] @@ -1632,6 +1634,34 @@ def api_stats_refresh(): return jsonify(data) +_dashboard_sync_tick = {"n": 0} + + +@app.route("/dashboard") +@login_required +@require_nav("dashboard") +def dashboard(): + return render_template("dashboard.html") + + +@app.route("/api/dashboard/live") +@login_required +def api_dashboard_live(): + if not nav_enabled(get_setting, "dashboard"): + return jsonify({"ok": False, "error": "数据看板已在系统设置中关闭"}), 403 + from dashboard_lib import build_dashboard_payload + + _dashboard_sync_tick["n"] += 1 + sync_trades = _dashboard_sync_tick["n"] % 5 == 0 + payload = build_dashboard_payload( + get_db=get_db, + get_setting=get_setting, + fetch_price=fetch_price, + sync_ctp_trades=sync_trades, + ) + return jsonify(payload) + + @app.route("/market") @login_required @require_nav("market") diff --git a/dashboard_lib.py b/dashboard_lib.py new file mode 100644 index 0000000..2c98ae3 --- /dev/null +++ b/dashboard_lib.py @@ -0,0 +1,157 @@ +# Copyright (c) 2025-2026 马建军. All rights reserved. +# 专有软件 — 未经授权禁止复制、传播、转售。 +# 详见 LICENSE.zh-CN.txt 与 docs/软件购买与使用协议.md + +"""数据看板:账户、关键位、平仓记录聚合。""" +from __future__ import annotations + +from datetime import datetime +from typing import Any, Callable, Optional +from zoneinfo import ZoneInfo + +_TZ = ZoneInfo("Asia/Shanghai") + + +def _direction_label(direction: str) -> str: + return "做多" if (direction or "").strip().lower() == "long" else "做空" + + +def build_dashboard_payload( + *, + get_db: Callable, + get_setting: Callable[[str, str], str], + fetch_price: Callable[[str, str, str], Optional[float]], + closes_limit: int = 40, + sync_ctp_trades: bool = False, +) -> dict[str, Any]: + from trading_context import get_account_capital, get_trading_mode, trading_mode_label + from vnpy_bridge import ctp_account_margin_used, ctp_status, get_bridge + + mode = get_trading_mode(get_setting) + ctp_st = dict(ctp_status(mode) or {}) + conn = get_db() + try: + capital = float(get_account_capital(conn, get_setting) or 0) + equity = capital + available: Optional[float] = None + margin_used: Optional[float] = None + + if ctp_st.get("connected"): + if sync_ctp_trades: + try: + from ctp_trade_sync import sync_trade_logs_from_ctp + + sync_trade_logs_from_ctp( + conn, mode, capital=capital, trading_mode=mode, + ) + conn.commit() + except Exception: + pass + try: + b = get_bridge() + if b.connected_mode == mode and b.ping(): + acc = b.get_account() or {} + else: + acc = {} + balance = float(acc.get("balance") or 0) + if balance > 0: + equity = balance + avail = acc.get("available") + if avail is not None: + available = round(float(avail), 2) + mu = ctp_account_margin_used(mode) + if mu is not None and mu > 0: + margin_used = round(float(mu), 2) + elif available is not None and equity > 0: + margin_used = round(max(0.0, equity - available), 2) + except Exception: + pass + + key_rows = conn.execute( + """ + SELECT id, symbol, symbol_name, market_code, sina_code, + monitor_type, direction, upper, lower, trade_mode, + bar_period, trailing_be + FROM key_monitors + WHERE status='active' OR status IS NULL + ORDER BY id DESC + """ + ).fetchall() + keys: list[dict[str, Any]] = [] + for r in key_rows: + sym = r["symbol"] + market = r["market_code"] or "" + sina = r["sina_code"] or "" + upper = float(r["upper"] or 0) + lower = float(r["lower"] or 0) + price = fetch_price(sym, market, sina) + dist_upper = dist_lower = None + if price is not None: + dist_upper = round(upper - float(price), 2) + dist_lower = round(float(price) - lower, 2) + mtype = r["monitor_type"] or "" + keys.append({ + "id": r["id"], + "symbol": sym, + "symbol_name": r["symbol_name"] or sym, + "monitor_type": mtype, + "direction": r["direction"] or "", + "direction_label": _direction_label(r["direction"] or "long") + if r["direction"] else "", + "upper": upper, + "lower": lower, + "trade_mode": r["trade_mode"] or "", + "bar_period": r["bar_period"] or "5m", + "trailing_be": bool(r["trailing_be"]), + "price": price, + "dist_upper": dist_upper, + "dist_lower": dist_lower, + }) + + close_rows = conn.execute( + """ + SELECT id, symbol, symbol_name, direction, lots, + entry_price, close_price, pnl, pnl_net, fee, + close_time, result, source + FROM trade_logs + ORDER BY id DESC + LIMIT ? + """, + (max(1, min(200, closes_limit)),), + ).fetchall() + closes: list[dict[str, Any]] = [] + for r in close_rows: + closes.append({ + "id": r["id"], + "symbol": r["symbol_name"] or r["symbol"], + "symbol_code": r["symbol"], + "direction": r["direction"] or "long", + "direction_label": _direction_label(r["direction"] or "long"), + "lots": float(r["lots"] or 0), + "entry_price": float(r["entry_price"] or 0), + "close_price": float(r["close_price"] or 0), + "pnl": float(r["pnl"] or 0) if r["pnl"] is not None else None, + "pnl_net": float(r["pnl_net"] or 0) if r["pnl_net"] is not None else None, + "fee": float(r["fee"] or 0) if r["fee"] is not None else None, + "close_time": (r["close_time"] or "")[:16].replace("T", " "), + "result": r["result"] or "", + "source": r["source"] or "", + }) + + now_iso = datetime.now(_TZ).strftime("%Y-%m-%d %H:%M:%S") + return { + "ok": True, + "updated_at": now_iso, + "trading_mode_label": trading_mode_label(get_setting), + "ctp_status": ctp_st, + "account": { + "equity": round(equity, 2), + "margin_used": margin_used, + "available": available, + "capital_fallback": round(capital, 2), + }, + "keys": keys, + "closes": closes, + } + finally: + conn.close() diff --git a/nav_settings.py b/nav_settings.py index 21a1650..6d3258a 100644 --- a/nav_settings.py +++ b/nav_settings.py @@ -11,6 +11,7 @@ from typing import Callable # 可在系统设置中开关的导航项 NAV_TOGGLES: dict[str, str] = { + "dashboard": "数据看板", "fees": "手续费配置", "plans": "开单计划", "market": "行情K线", diff --git a/static/css/dashboard.css b/static/css/dashboard.css new file mode 100644 index 0000000..b5dff39 --- /dev/null +++ b/static/css/dashboard.css @@ -0,0 +1,86 @@ +/* Copyright (c) 2025-2026 马建军. All rights reserved. 详见 LICENSE.zh-CN.txt */ + +.dashboard-page { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.dashboard-top { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.5rem; +} + +.dashboard-top-left { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.45rem; +} + +.dash-updated { + font-size: 0.78rem; +} + +.dashboard-account-card { + margin-bottom: 0; +} + +.dashboard-account-grid { + margin-bottom: 0; +} + +.dashboard-account-grid .stat-item { + min-width: 6.5rem; +} + +.dashboard-account-grid .stat-item .value { + font-size: 1rem; + font-weight: 700; +} + +.dashboard-section h2 { + margin-bottom: 0.65rem; +} + +.dashboard-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.dashboard-table th, +.dashboard-table td { + padding: 0.45rem 0.55rem; + border-bottom: 1px solid var(--table-border); + text-align: left; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.dashboard-table tbody tr:last-child td { + border-bottom: none; +} + +.dashboard-table .pnl-pos { + color: var(--profit); + font-weight: 600; +} + +.dashboard-table .pnl-neg { + color: var(--loss); + font-weight: 600; +} + +@media (max-width: 767px) { + .dashboard-table { + font-size: 0.75rem; + } + .dashboard-table th, + .dashboard-table td { + padding: 0.35rem 0.4rem; + } +} diff --git a/static/js/dashboard.js b/static/js/dashboard.js new file mode 100644 index 0000000..2b2b3b0 --- /dev/null +++ b/static/js/dashboard.js @@ -0,0 +1,367 @@ +/* Copyright (c) 2025-2026 马建军. All rights reserved. + * 详见 LICENSE.zh-CN.txt + */ +(function () { + 'use strict'; + + var posBody = document.getElementById('dash-positions-body'); + var keysBody = document.getElementById('dash-keys-body'); + var closesBody = document.getElementById('dash-closes-body'); + var equityEl = document.getElementById('dash-equity'); + var marginEl = document.getElementById('dash-margin'); + var availEl = document.getElementById('dash-available'); + var ctpBadge = document.getElementById('dash-ctp-badge'); + var modeBadge = document.getElementById('dash-mode-badge'); + var updatedEl = document.getElementById('dash-updated'); + + var pollTimer = null; + var positionSource = null; + var posRowCache = {}; + var lastKeyIds = ''; + var lastCloseHeadId = null; + + function fmtNum(v, digits) { + if (v === null || v === undefined || v === '') return '—'; + var n = Number(v); + if (isNaN(n)) return '—'; + if (digits != null) return n.toFixed(digits); + var s = n.toFixed(2); + return s.replace(/\.?0+$/, function (m) { return m === '.00' ? '.00' : ''; }); + } + + function fmtMoney(v) { + if (v === null || v === undefined) return '—'; + return fmtNum(v, 2) + ' 元'; + } + + function fmtPnl(v) { + if (v === null || v === undefined) return '—'; + var n = Number(v); + if (isNaN(n)) return '—'; + return (n >= 0 ? '+' : '') + fmtNum(n, 2) + ' 元'; + } + + function pnlClass(v) { + if (v === null || v === undefined) return ''; + var n = Number(v); + if (isNaN(n) || n === 0) return ''; + return n > 0 ? 'pnl-pos' : 'pnl-neg'; + } + + function escHtml(s) { + return String(s || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + function slTpText(row) { + var parts = []; + if (row.stop_loss != null) parts.push('SL ' + fmtNum(row.stop_loss)); + if (row.take_profit != null) parts.push('TP ' + fmtNum(row.take_profit)); + if (row.trailing_be) parts.push('移动保本'); + return parts.length ? parts.join(' · ') : '—'; + } + + function updateCtpBadge(st) { + if (!ctpBadge || !st) return; + var connected = !!st.connected; + var connecting = !!st.connecting && !(st.login_cooldown_sec > 0); + ctpBadge.className = 'badge ' + (connected ? 'profit' : (connecting ? 'planned' : 'loss')); + if (connected) { + ctpBadge.textContent = 'CTP 已连接'; + } else if (connecting) { + ctpBadge.textContent = 'CTP 连接中…'; + } else if (st.disabled_hint) { + ctpBadge.textContent = 'CTP 自动连接已关闭'; + } else if (st.last_error) { + ctpBadge.textContent = 'CTP 未连接'; + ctpBadge.title = st.last_error; + } else { + ctpBadge.textContent = 'CTP 未连接'; + ctpBadge.title = ''; + } + } + + function applyAccount(data) { + if (!data) return; + var acc = data.account || {}; + var st = data.ctp_status || {}; + updateCtpBadge(st); + if (modeBadge && data.trading_mode_label) { + modeBadge.textContent = data.trading_mode_label; + modeBadge.className = 'badge dir'; + } + if (updatedEl && data.updated_at) { + updatedEl.textContent = '更新 ' + data.updated_at; + } + if (equityEl) { + equityEl.textContent = fmtMoney(acc.equity != null ? acc.equity : acc.capital_fallback); + } + if (marginEl) { + marginEl.textContent = acc.margin_used != null ? fmtMoney(acc.margin_used) : '—'; + } + if (availEl) { + availEl.textContent = acc.available != null ? fmtMoney(acc.available) : '—'; + } + } + + function renderKeys(keys) { + if (!keysBody) return; + if (!keys || !keys.length) { + keysBody.innerHTML = '