Add real-time data dashboard with account, positions, keys, and closes.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -222,6 +222,7 @@ def _static_asset_v() -> str:
|
|||||||
base = os.path.dirname(os.path.abspath(__file__))
|
base = os.path.dirname(os.path.abspath(__file__))
|
||||||
rels = (
|
rels = (
|
||||||
"static/js/trade.js",
|
"static/js/trade.js",
|
||||||
|
"static/js/dashboard.js",
|
||||||
"static/js/orientation.js",
|
"static/js/orientation.js",
|
||||||
"static/css/records.css",
|
"static/css/records.css",
|
||||||
"static/js/records.js",
|
"static/js/records.js",
|
||||||
@@ -229,6 +230,7 @@ def _static_asset_v() -> str:
|
|||||||
"static/css/mobile.css",
|
"static/css/mobile.css",
|
||||||
"static/css/responsive.css",
|
"static/css/responsive.css",
|
||||||
"static/css/trade.css",
|
"static/css/trade.css",
|
||||||
|
"static/css/dashboard.css",
|
||||||
"static/css/base.css",
|
"static/css/base.css",
|
||||||
)
|
)
|
||||||
mtimes = []
|
mtimes = []
|
||||||
@@ -1632,6 +1634,34 @@ def api_stats_refresh():
|
|||||||
return jsonify(data)
|
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")
|
@app.route("/market")
|
||||||
@login_required
|
@login_required
|
||||||
@require_nav("market")
|
@require_nav("market")
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -11,6 +11,7 @@ from typing import Callable
|
|||||||
|
|
||||||
# 可在系统设置中开关的导航项
|
# 可在系统设置中开关的导航项
|
||||||
NAV_TOGGLES: dict[str, str] = {
|
NAV_TOGGLES: dict[str, str] = {
|
||||||
|
"dashboard": "数据看板",
|
||||||
"fees": "手续费配置",
|
"fees": "手续费配置",
|
||||||
"plans": "开单计划",
|
"plans": "开单计划",
|
||||||
"market": "行情K线",
|
"market": "行情K线",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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, '>')
|
||||||
|
.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 = '<tr><td colspan="8" class="text-muted">暂无关键位监控</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keysBody.innerHTML = keys.map(function (k) {
|
||||||
|
return (
|
||||||
|
'<tr data-key-id="' + k.id + '">' +
|
||||||
|
'<td>' + escHtml(k.symbol_name || k.symbol) + '</td>' +
|
||||||
|
'<td>' + escHtml(k.monitor_type || '—') + '</td>' +
|
||||||
|
'<td>' + escHtml(k.bar_period || '—') + '</td>' +
|
||||||
|
'<td>' + fmtNum(k.upper) + '</td>' +
|
||||||
|
'<td>' + fmtNum(k.lower) + '</td>' +
|
||||||
|
'<td class="dash-k-price">' + (k.price != null ? fmtNum(k.price) : '—') + '</td>' +
|
||||||
|
'<td class="dash-k-dist-up">' + (k.dist_upper != null ? fmtNum(k.dist_upper) : '—') + '</td>' +
|
||||||
|
'<td class="dash-k-dist-down">' + (k.dist_lower != null ? fmtNum(k.dist_lower) : '—') + '</td>' +
|
||||||
|
'</tr>'
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchKeys(keys) {
|
||||||
|
if (!keysBody || !keys) return;
|
||||||
|
keys.forEach(function (k) {
|
||||||
|
var row = keysBody.querySelector('tr[data-key-id="' + k.id + '"]');
|
||||||
|
if (!row) return;
|
||||||
|
var priceEl = row.querySelector('.dash-k-price');
|
||||||
|
var upEl = row.querySelector('.dash-k-dist-up');
|
||||||
|
var downEl = row.querySelector('.dash-k-dist-down');
|
||||||
|
if (priceEl) priceEl.textContent = k.price != null ? fmtNum(k.price) : '—';
|
||||||
|
if (upEl) upEl.textContent = k.dist_upper != null ? fmtNum(k.dist_upper) : '—';
|
||||||
|
if (downEl) downEl.textContent = k.dist_lower != null ? fmtNum(k.dist_lower) : '—';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCloses(closes) {
|
||||||
|
if (!closesBody) return;
|
||||||
|
if (!closes || !closes.length) {
|
||||||
|
closesBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
closesBody.innerHTML = closes.map(function (c) {
|
||||||
|
var pc = pnlClass(c.pnl_net != null ? c.pnl_net : c.pnl);
|
||||||
|
return (
|
||||||
|
'<tr data-close-id="' + c.id + '">' +
|
||||||
|
'<td>' + escHtml(c.symbol) + '</td>' +
|
||||||
|
'<td>' + escHtml(c.direction_label || c.direction) + '</td>' +
|
||||||
|
'<td>' + fmtNum(c.lots) + '</td>' +
|
||||||
|
'<td>' + fmtNum(c.entry_price) + '</td>' +
|
||||||
|
'<td>' + fmtNum(c.close_price) + '</td>' +
|
||||||
|
'<td class="' + pnlClass(c.pnl) + '">' + fmtPnl(c.pnl) + '</td>' +
|
||||||
|
'<td class="' + pc + '">' + fmtPnl(c.pnl_net) + '</td>' +
|
||||||
|
'<td>' + escHtml(c.close_time || '—') + '</td>' +
|
||||||
|
'</tr>'
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyKeys(keys) {
|
||||||
|
if (!keysBody) return;
|
||||||
|
var ids = (keys || []).map(function (k) { return String(k.id); }).join(',');
|
||||||
|
if (!ids) {
|
||||||
|
keysBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无关键位监控</td></tr>';
|
||||||
|
lastKeyIds = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ids !== lastKeyIds) {
|
||||||
|
lastKeyIds = ids;
|
||||||
|
renderKeys(keys);
|
||||||
|
} else {
|
||||||
|
patchKeys(keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCloses(closes) {
|
||||||
|
if (!closesBody) return;
|
||||||
|
if (!closes || !closes.length) {
|
||||||
|
closesBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无平仓记录</td></tr>';
|
||||||
|
lastCloseHeadId = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var headId = closes[0].id;
|
||||||
|
if (headId !== lastCloseHeadId) {
|
||||||
|
lastCloseHeadId = headId;
|
||||||
|
renderCloses(closes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionRows(data) {
|
||||||
|
return (data.rows || []).filter(function (r) {
|
||||||
|
return r.order_state !== 'pending' && (r.lots || 0) > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPositions(rows) {
|
||||||
|
if (!posBody) return;
|
||||||
|
if (!rows.length) {
|
||||||
|
posBody.innerHTML = '<tr><td colspan="8" class="text-muted">暂无持仓</td></tr>';
|
||||||
|
posRowCache = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
posRowCache = {};
|
||||||
|
posBody.innerHTML = rows.map(function (r) {
|
||||||
|
var key = r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || ''));
|
||||||
|
posRowCache[key] = true;
|
||||||
|
var name = r.symbol || r.symbol_name || r.symbol_code || '—';
|
||||||
|
return (
|
||||||
|
'<tr data-pos-key="' + escHtml(key) + '">' +
|
||||||
|
'<td>' + escHtml(name) + '</td>' +
|
||||||
|
'<td>' + escHtml(r.direction_label || r.direction) + '</td>' +
|
||||||
|
'<td class="dash-p-lots">' + escHtml(String(r.lots)) + '</td>' +
|
||||||
|
'<td class="dash-p-entry">' + fmtNum(r.entry_price) + '</td>' +
|
||||||
|
'<td class="dash-p-mark">' + (r.current_price != null ? fmtNum(r.current_price) : '—') + '</td>' +
|
||||||
|
'<td class="dash-p-pnl ' + pnlClass(r.float_pnl) + '">' + fmtPnl(r.float_pnl) + '</td>' +
|
||||||
|
'<td class="dash-p-margin">' + (r.margin != null ? fmtMoney(r.margin) : '—') + '</td>' +
|
||||||
|
'<td class="dash-p-sltp">' + escHtml(slTpText(r)) + '</td>' +
|
||||||
|
'</tr>'
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPosRow(key) {
|
||||||
|
if (!posBody || !key) return null;
|
||||||
|
var rows = posBody.querySelectorAll('tr[data-pos-key]');
|
||||||
|
for (var i = 0; i < rows.length; i++) {
|
||||||
|
if (rows[i].getAttribute('data-pos-key') === key) return rows[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchPositionQuotes(quotes) {
|
||||||
|
if (!quotes) return;
|
||||||
|
quotes.forEach(function (q) {
|
||||||
|
var row = findPosRow(q.key || q.position_key);
|
||||||
|
if (!row) return;
|
||||||
|
var markEl = row.querySelector('.dash-p-mark');
|
||||||
|
var pnlEl = row.querySelector('.dash-p-pnl');
|
||||||
|
if (markEl && q.mark_price != null) markEl.textContent = fmtNum(q.mark_price);
|
||||||
|
if (pnlEl && q.float_pnl != null) {
|
||||||
|
pnlEl.textContent = fmtPnl(q.float_pnl);
|
||||||
|
pnlEl.className = 'dash-p-pnl ' + pnlClass(q.float_pnl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPositionsData(data) {
|
||||||
|
if (!data) return;
|
||||||
|
if (data.ctp_status) updateCtpBadge(data.ctp_status);
|
||||||
|
if (data.trading_mode_label && modeBadge) {
|
||||||
|
modeBadge.textContent = data.trading_mode_label;
|
||||||
|
}
|
||||||
|
if (equityEl && data.capital != null) {
|
||||||
|
equityEl.textContent = fmtMoney(data.capital);
|
||||||
|
}
|
||||||
|
var rows = positionRows(data);
|
||||||
|
var keys = rows.map(function (r) {
|
||||||
|
return r.key || r.position_key || ((r.symbol_code || '') + ':' + (r.direction || ''));
|
||||||
|
}).sort().join('|');
|
||||||
|
var cachedKeys = Object.keys(posRowCache).sort().join('|');
|
||||||
|
if (keys !== cachedKeys) {
|
||||||
|
renderPositions(rows);
|
||||||
|
} else {
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
var key = r.key || r.position_key;
|
||||||
|
var row = findPosRow(key);
|
||||||
|
if (!row) return;
|
||||||
|
var lotsEl = row.querySelector('.dash-p-lots');
|
||||||
|
var entryEl = row.querySelector('.dash-p-entry');
|
||||||
|
var markEl = row.querySelector('.dash-p-mark');
|
||||||
|
var pnlEl = row.querySelector('.dash-p-pnl');
|
||||||
|
var marginEl = row.querySelector('.dash-p-margin');
|
||||||
|
var sltpEl = row.querySelector('.dash-p-sltp');
|
||||||
|
if (lotsEl) lotsEl.textContent = String(r.lots);
|
||||||
|
if (entryEl) entryEl.textContent = fmtNum(r.entry_price);
|
||||||
|
if (markEl) markEl.textContent = r.current_price != null ? fmtNum(r.current_price) : '—';
|
||||||
|
if (pnlEl) {
|
||||||
|
pnlEl.textContent = fmtPnl(r.float_pnl);
|
||||||
|
pnlEl.className = 'dash-p-pnl ' + pnlClass(r.float_pnl);
|
||||||
|
}
|
||||||
|
if (marginEl) marginEl.textContent = r.margin != null ? fmtMoney(r.margin) : '—';
|
||||||
|
if (sltpEl) sltpEl.textContent = slTpText(r);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollDashboard() {
|
||||||
|
fetch('/api/dashboard/live')
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data.ok) return;
|
||||||
|
applyAccount(data);
|
||||||
|
applyKeys(data.keys || []);
|
||||||
|
applyCloses(data.closes || []);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (updatedEl) updatedEl.textContent = '看板数据加载失败';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectPositionStream() {
|
||||||
|
if (positionSource) {
|
||||||
|
positionSource.close();
|
||||||
|
positionSource = null;
|
||||||
|
}
|
||||||
|
positionSource = new EventSource('/api/trading/stream');
|
||||||
|
positionSource.addEventListener('positions', function (ev) {
|
||||||
|
try {
|
||||||
|
applyPositionsData(JSON.parse(ev.data));
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
});
|
||||||
|
positionSource.addEventListener('position_quotes', function (ev) {
|
||||||
|
try {
|
||||||
|
var data = JSON.parse(ev.data);
|
||||||
|
patchPositionQuotes(data.quotes || []);
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
});
|
||||||
|
positionSource.onerror = function () {
|
||||||
|
if (positionSource) {
|
||||||
|
positionSource.close();
|
||||||
|
positionSource = null;
|
||||||
|
}
|
||||||
|
setTimeout(connectPositionStream, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
pollDashboard();
|
||||||
|
pollTimer = setInterval(pollDashboard, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
if (positionSource) {
|
||||||
|
positionSource.close();
|
||||||
|
positionSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
startPolling();
|
||||||
|
if (!positionSource) connectPositionStream();
|
||||||
|
} else {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
startPolling();
|
||||||
|
connectPositionStream();
|
||||||
|
})();
|
||||||
@@ -81,6 +81,7 @@
|
|||||||
<button type="button" class="nav-backdrop" id="nav-backdrop" aria-label="关闭菜单" hidden></button>
|
<button type="button" class="nav-backdrop" id="nav-backdrop" aria-label="关闭菜单" hidden></button>
|
||||||
<nav class="site-nav" id="site-nav">
|
<nav class="site-nav" id="site-nav">
|
||||||
<a href="{{ url_for('positions') }}" class="{% if request.endpoint in ('positions', 'trade_page', 'recommend_page') %}active{% endif %}">下单监控</a>
|
<a href="{{ url_for('positions') }}" class="{% if request.endpoint in ('positions', 'trade_page', 'recommend_page') %}active{% endif %}">下单监控</a>
|
||||||
|
{% if nav_items.dashboard %}<a href="{{ url_for('dashboard') }}" class="{% if request.endpoint == 'dashboard' %}active{% endif %}">数据看板</a>{% endif %}
|
||||||
{% if nav_items.strategy %}<a href="{{ url_for('strategy_page') }}" class="{% if request.endpoint in ('strategy_page', 'strategy_records_page') %}active{% endif %}">策略交易</a>{% endif %}
|
{% if nav_items.strategy %}<a href="{{ url_for('strategy_page') }}" class="{% if request.endpoint in ('strategy_page', 'strategy_records_page') %}active{% endif %}">策略交易</a>{% endif %}
|
||||||
{% if nav_items.plans %}<a href="{{ url_for('plans') }}" class="{% if request.endpoint == 'plans' %}active{% endif %}">开单计划</a>{% endif %}
|
{% if nav_items.plans %}<a href="{{ url_for('plans') }}" class="{% if request.endpoint == 'plans' %}active{% endif %}">开单计划</a>{% endif %}
|
||||||
<a href="{{ url_for('keys') }}" class="{% if request.endpoint == 'keys' %}active{% endif %}">关键位监控</a>
|
<a href="{{ url_for('keys') }}" class="{% if request.endpoint == 'keys' %}active{% endif %}">关键位监控</a>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
{# Copyright (c) 2025-2026 马建军. All rights reserved. 专有软件,详见 LICENSE.zh-CN.txt #}
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}数据看板 - 国内期货 · 交易复盘系统{% endblock %}
|
||||||
|
{% block extra_css %}
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}?v={{ asset_v }}">
|
||||||
|
{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="dashboard-page">
|
||||||
|
<div class="dashboard-top">
|
||||||
|
<div class="dashboard-top-left">
|
||||||
|
<span class="badge planned" id="dash-mode-badge">—</span>
|
||||||
|
<span class="badge planned" id="dash-ctp-badge">CTP 检测中…</span>
|
||||||
|
<span class="text-muted dash-updated" id="dash-updated">正在加载…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card dashboard-account-card">
|
||||||
|
<div class="stat-grid stat-grid-summary dashboard-account-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="label">账户权益</div>
|
||||||
|
<div class="value" id="dash-equity">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="label">占用保证金</div>
|
||||||
|
<div class="value" id="dash-margin">—</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="label">可用权益</div>
|
||||||
|
<div class="value" id="dash-available">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card dashboard-section">
|
||||||
|
<h2>持仓信息</h2>
|
||||||
|
<div class="card-scroll">
|
||||||
|
<table class="dashboard-table" id="dash-positions-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>品种</th>
|
||||||
|
<th>方向</th>
|
||||||
|
<th>手数</th>
|
||||||
|
<th>均价</th>
|
||||||
|
<th>现价</th>
|
||||||
|
<th>浮盈亏</th>
|
||||||
|
<th>保证金</th>
|
||||||
|
<th>止损/止盈</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="dash-positions-body">
|
||||||
|
<tr><td colspan="8" class="text-muted">加载中…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card dashboard-section">
|
||||||
|
<h2>关键位监控</h2>
|
||||||
|
<div class="card-scroll">
|
||||||
|
<table class="dashboard-table" id="dash-keys-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>品种</th>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>周期</th>
|
||||||
|
<th>上沿</th>
|
||||||
|
<th>下沿</th>
|
||||||
|
<th>现价</th>
|
||||||
|
<th>距上沿</th>
|
||||||
|
<th>距下沿</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="dash-keys-body">
|
||||||
|
<tr><td colspan="8" class="text-muted">加载中…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card dashboard-section">
|
||||||
|
<h2>平仓记录</h2>
|
||||||
|
<div class="card-scroll">
|
||||||
|
<table class="dashboard-table" id="dash-closes-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>品种</th>
|
||||||
|
<th>方向</th>
|
||||||
|
<th>手数</th>
|
||||||
|
<th>开仓</th>
|
||||||
|
<th>平仓</th>
|
||||||
|
<th>盈亏</th>
|
||||||
|
<th>净盈亏</th>
|
||||||
|
<th>平仓时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="dash-closes-body">
|
||||||
|
<tr><td colspan="8" class="text-muted">加载中…</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="{{ url_for('static', filename='js/dashboard.js') }}?v={{ asset_v }}"></script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user