fix: unify order/key focus K-line theme, PnL, RR and exchange price tick

Share focus_chart templates and APIs across four instances; align chart Y-axis, price lines and meta bar with exchange symbol precision and live unrealized PnL.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-04 16:45:51 +08:00
parent 3d55aa0975
commit 93e148a3e7
18 changed files with 1366 additions and 2128 deletions
+221
View File
@@ -0,0 +1,221 @@
/* 实盘/关键位放大页:与 instance_theme 联动,高对比 meta + 主题感知图表区 */
body.focus-page {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
padding: 14px;
margin: 0;
background: var(--focus-bg, #0b0d14);
color: var(--focus-fg, #eaeaea);
}
html[data-theme="light"] body.focus-page {
--focus-bg: #eef3f8;
--focus-fg: #142232;
--focus-card-bg: #fff;
--focus-card-border: #b8c8d8;
--focus-meta-bg: #fff;
--focus-meta-border: #9eb4c8;
--focus-meta-label: #2a4a66;
--focus-meta-value: #0a1628;
--focus-status: #4a6078;
--focus-chart-bg: #f0f4f9;
--focus-chart-border: #b8c8d8;
--focus-btn-bg: #fff;
--focus-btn-fg: #006e9a;
--focus-btn-border: rgba(0, 95, 140, 0.22);
--focus-input-bg: #fff;
--focus-input-fg: #142232;
--focus-input-border: #b8c8d8;
--focus-title: #0a1628;
--focus-pnl-up: #0a7a3d;
--focus-pnl-down: #c62828;
--focus-dir-short: #b71c1c;
--focus-dir-long: #0a7a3d;
}
html[data-theme="dark"] body.focus-page {
--focus-bg: #0b0d14;
--focus-fg: #eaeaea;
--focus-card-bg: #121726;
--focus-card-border: #2a3150;
--focus-meta-bg: #141b2f;
--focus-meta-border: #3d4f72;
--focus-meta-label: #c8d8f0;
--focus-meta-value: #f0f4ff;
--focus-status: #95a2c2;
--focus-chart-bg: #0f1320;
--focus-chart-border: #2a3150;
--focus-btn-bg: #151a2a;
--focus-btn-fg: #8fc8ff;
--focus-btn-border: #304164;
--focus-input-bg: #1a1a29;
--focus-input-fg: #fff;
--focus-input-border: #2e2e45;
--focus-title: #dbe4ff;
--focus-pnl-up: #3ddc84;
--focus-pnl-down: #ff7070;
--focus-dir-short: #ff8a80;
--focus-dir-long: #69f0ae;
}
body.focus-page * {
box-sizing: border-box;
}
.focus-page .container {
width: min(98vw, 1900px);
margin: 0 auto;
}
.focus-page .card {
background: var(--focus-card-bg);
border-radius: 10px;
padding: 12px;
border: 1px solid var(--focus-card-border);
margin-bottom: 12px;
}
.focus-page .row {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.focus-page .btn {
padding: 7px 10px;
border-radius: 8px;
text-decoration: none;
border: 1px solid var(--focus-btn-border);
background: var(--focus-btn-bg);
color: var(--focus-btn-fg);
cursor: pointer;
}
.focus-page .btn:hover {
filter: brightness(1.06);
}
.focus-page select,
.focus-page input,
.focus-page button {
padding: 8px 10px;
border-radius: 8px;
border: 1px solid var(--focus-input-border);
background: var(--focus-input-bg);
color: var(--focus-input-fg);
}
.focus-page .focus-title {
color: var(--focus-title);
font-weight: 700;
}
.focus-page .meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
margin-top: 10px;
}
.focus-page .meta-item {
background: var(--focus-meta-bg);
border: 1px solid var(--focus-meta-border);
border-radius: 8px;
padding: 10px 10px 9px;
}
.focus-page .meta-item .k {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--focus-meta-label);
}
.focus-page .meta-item .v {
font-size: 1.02rem;
font-weight: 600;
margin-top: 5px;
word-break: break-all;
color: var(--focus-meta-value);
}
.focus-page .meta-item--emph {
border-width: 2px;
border-color: var(--focus-meta-label);
}
.focus-page .meta-item--emph .k {
font-size: 0.82rem;
font-weight: 700;
}
.focus-page .meta-item--emph .v {
font-size: 1.12rem;
font-weight: 800;
}
.focus-page .meta-item--pnl .v {
font-size: 1.14rem;
font-weight: 800;
letter-spacing: 0.01em;
}
.focus-page .meta-pnl-up {
color: var(--focus-pnl-up) !important;
}
.focus-page .meta-pnl-down {
color: var(--focus-pnl-down) !important;
}
.focus-page .meta-dir-long {
color: var(--focus-dir-long) !important;
}
.focus-page .meta-dir-short {
color: var(--focus-dir-short) !important;
}
.focus-page .status {
font-size: 0.84rem;
color: var(--focus-status);
}
.focus-page .status.err {
color: var(--focus-pnl-down);
}
.focus-page #chart-wrap {
height: 560px;
background: var(--focus-chart-bg);
border: 1px solid var(--focus-chart-border);
border-radius: 10px;
padding: 8px;
}
.focus-page #chart {
width: 100%;
height: 100%;
}
.focus-page .empty {
padding: 18px;
color: var(--focus-status);
}
.focus-page .exchange-tag {
font-size: 0.72rem;
font-weight: 600;
color: #b8f5d0;
background: #14241e;
border: 1px solid #2d6a4f;
padding: 4px 10px;
border-radius: 999px;
margin-left: 8px;
}
html[data-theme="light"] .focus-page .exchange-tag {
color: #0a5c38;
background: #e8f5ee;
border-color: #7bc9a0;
}
+401
View File
@@ -0,0 +1,401 @@
/**
* 实盘/关键位放大 K 线:交易所 tick 精度、主题感知图表、高对比 meta。
*/
(function (global) {
"use strict";
let activePriceTick = null;
function currentTheme() {
return document.documentElement.getAttribute("data-theme") === "light"
? "light"
: "dark";
}
function chartTheme(theme) {
if (theme === "light") {
return {
layout: { background: { color: "#f0f4f9" }, textColor: "#142232" },
grid: { vertLines: { color: "#d0dae4" }, horzLines: { color: "#d0dae4" } },
rightPriceScale: { borderColor: "#b8c8d8" },
timeScale: { borderColor: "#b8c8d8" },
candle: {
upColor: "#0a7a3d",
downColor: "#c62828",
wickUpColor: "#0a7a3d",
wickDownColor: "#c62828",
},
};
}
return {
layout: { background: { color: "#0f1320" }, textColor: "#d6deff" },
grid: { vertLines: { color: "#1e263d" }, horzLines: { color: "#1e263d" } },
rightPriceScale: { borderColor: "#2a3150" },
timeScale: { borderColor: "#2a3150" },
candle: {
upColor: "#4cd97f",
downColor: "#ff6666",
wickUpColor: "#4cd97f",
wickDownColor: "#ff6666",
},
};
}
const SAFE_PRICE_FORMAT = { type: "price", precision: 4, minMove: 0.0001 };
function decimalsFromTick(tick) {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return null;
const minMove = Number(tick);
if (minMove >= 1) return 0;
const raw = String(minMove);
const sci = raw.match(/e-(\d+)/i);
if (sci) return Math.min(12, parseInt(sci[1], 10));
const fixed = minMove.toFixed(12);
const frac = fixed.split(".")[1] || "";
const trimmed = frac.replace(/0+$/, "");
if (trimmed.length) return Math.min(12, trimmed.length);
return Math.max(0, Math.min(12, Math.round(-Math.log10(minMove))));
}
function tickToPriceFormat(tick) {
try {
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) {
return { type: "price", precision: 2, minMove: 0.01 };
}
const minMove = Number(tick);
let prec = decimalsFromTick(minMove);
if (prec == null || prec < 0) prec = 4;
prec = Math.min(12, Math.max(0, Math.floor(prec)));
return { type: "price", precision: prec, minMove: minMove };
} catch (_) {
return SAFE_PRICE_FORMAT;
}
}
function roundToTick(v, tick) {
if (v == null || Number.isNaN(Number(v))) return v;
const n = Number(v);
if (tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0) return n;
const t = Number(tick);
const rounded = Math.round(n / t) * t;
const dec = decimalsFromTick(t);
if (dec == null) return rounded;
return parseFloat(rounded.toFixed(dec));
}
function fmtPriceByTick(v, tick) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(roundToTick(v, tick));
if (n === 0) return "0";
const dec = decimalsFromTick(tick);
if (dec != null) return n.toFixed(dec);
const av = Math.abs(n);
let d = 8;
if (av >= 10000) d = 2;
else if (av >= 100) d = 3;
else if (av >= 1) d = 4;
else if (av >= 0.01) d = 6;
const text = n.toFixed(d);
return text.includes(".") ? text.replace(/\.?0+$/, "") : text;
}
function setActivePriceTick(tick) {
activePriceTick =
tick == null || !Number.isFinite(Number(tick)) || Number(tick) <= 0
? null
: Number(tick);
}
function formatSigned(v, digits) {
digits = digits === undefined ? 2 : digits;
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
const sign = n > 0 ? "+" : "";
return sign + n.toFixed(digits);
}
function formatSignedPrice(v) {
if (v === null || typeof v === "undefined" || Number.isNaN(Number(v))) return "-";
const n = Number(v);
const body = fmtPriceByTick(Math.abs(n), activePriceTick);
if (body === "-") return "-";
return (n > 0 ? "+" : n < 0 ? "-" : "") + body;
}
function formatRrRatio(rr) {
if (rr === null || typeof rr === "undefined") return "-:1";
const n = Number(rr);
if (Number.isNaN(n)) return "-:1";
const body = Number.isInteger(n) ? String(n) : String(parseFloat(n.toFixed(2)));
return body + ":1";
}
function displayPrice(orderOrData, field, rawField) {
const dispKey = field + "_display";
if (orderOrData && orderOrData[dispKey] && orderOrData[dispKey] !== "-") {
return String(orderOrData[dispKey]);
}
const raw = orderOrData ? orderOrData[rawField || field] : null;
if (raw === null || typeof raw === "undefined" || Number.isNaN(Number(raw))) return "-";
return fmtPriceByTick(raw, activePriceTick);
}
function lineTitle(label, display) {
const d = display && display !== "-" ? display : "";
return d ? label + " " + d : label;
}
function paintOrderMeta(order) {
const symEl = document.getElementById("m-symbol");
const dirEl = document.getElementById("m-direction");
const pnlEl = document.getElementById("m-pnl");
if (symEl) symEl.textContent = order.symbol || "-";
if (dirEl) {
const isShort = order.direction === "short";
dirEl.textContent = isShort ? "做空" : "做多";
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
}
const set = function (id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
set("m-entry", displayPrice(order, "trigger_price"));
set("m-sl", displayPrice(order, "stop_loss"));
set("m-tp", displayPrice(order, "take_profit"));
set("m-rr", formatRrRatio(order.rr_ratio));
set(
"m-breakeven",
order.breakeven_enabled === false || order.breakeven_enabled === 0 ? "关闭" : "开启"
);
set(
"m-price",
order.current_price_display ||
order.price_display ||
displayPrice(order, "current_price")
);
if (pnlEl) {
pnlEl.textContent =
formatSigned(order.float_pnl, 2) +
"U (" +
formatSigned(order.float_pct, 2) +
"%)";
pnlEl.className = "v";
const pnl = Number(order.float_pnl || 0);
if (pnl > 0) pnlEl.classList.add("meta-pnl-up");
else if (pnl < 0) pnlEl.classList.add("meta-pnl-down");
}
}
function paintKeyMeta(data) {
const key = data.key_monitor || null;
const symEl = document.getElementById("m-symbol");
if (symEl) symEl.textContent = data.symbol || "-";
const set = function (id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
};
set(
"m-price",
data.current_price_display || displayPrice(data, "current_price")
);
const dirEl = document.getElementById("m-direction");
if (!key) {
set("m-type", "未匹配到关键位");
set("m-direction", "-");
if (dirEl) dirEl.className = "v";
set("m-upper", "-");
set("m-lower", "-");
set("m-updiff", "-");
set("m-lowdiff", "-");
return;
}
set("m-type", key.monitor_type || "-");
if (dirEl) {
const isShort = key.direction === "short";
dirEl.textContent = isShort ? "做空" : "做多";
dirEl.className = "v " + (isShort ? "meta-dir-short" : "meta-dir-long");
}
set("m-upper", key.upper_display || displayPrice(key, "upper"));
set("m-lower", key.lower_display || displayPrice(key, "lower"));
if (activePriceTick != null) {
set(
"m-updiff",
formatSignedPrice(key.upper_diff) +
" (" +
formatSigned(key.upper_pct, 2) +
"%)"
);
set(
"m-lowdiff",
formatSignedPrice(key.lower_diff) +
" (" +
formatSigned(key.lower_pct, 2) +
"%)"
);
} else {
set(
"m-updiff",
formatSigned(key.upper_diff, 4) + " (" + formatSigned(key.upper_pct, 2) + "%)"
);
set(
"m-lowdiff",
formatSigned(key.lower_diff, 4) + " (" + formatSigned(key.lower_pct, 2) + "%)"
);
}
}
function applyPriceFormatToSeries(series, pf) {
if (!series || !series.applyOptions) return;
try {
series.applyOptions({ priceFormat: pf });
} catch (_) {
try {
series.applyOptions({ priceFormat: SAFE_PRICE_FORMAT });
} catch (_2) {}
}
}
function createFocusChart(host) {
if (!global.LightweightCharts) return null;
const th = chartTheme(currentTheme());
const chart = global.LightweightCharts.createChart(host, {
layout: th.layout,
grid: th.grid,
rightPriceScale: th.rightPriceScale,
timeScale: Object.assign({ timeVisible: true, secondsVisible: false }, th.timeScale),
crosshair: { mode: 0 },
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
let candleSeries = null;
function applyChartPriceFormat() {
let pf = SAFE_PRICE_FORMAT;
try {
pf = tickToPriceFormat(activePriceTick);
} catch (_) {
pf = SAFE_PRICE_FORMAT;
}
applyPriceFormatToSeries(candleSeries, pf);
try {
chart.applyOptions({
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
} catch (_) {}
}
function setPriceTick(tick) {
setActivePriceTick(tick);
applyChartPriceFormat();
}
const opts = Object.assign({ borderVisible: false }, th.candle);
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(opts);
} else if (
typeof chart.addSeries === "function" &&
global.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, opts);
}
applyChartPriceFormat();
const priceLines = [];
function resetPriceLines() {
if (!candleSeries) return;
priceLines.forEach(function (line) {
try {
candleSeries.removePriceLine(line);
} catch (_) {}
});
priceLines.length = 0;
}
function addLine(price, title, color) {
if (!candleSeries || price === null || typeof price === "undefined") return;
const p = Number(roundToTick(price, activePriceTick));
if (Number.isNaN(p) || p <= 0) return;
priceLines.push(
candleSeries.createPriceLine({
price: p,
color: color,
lineWidth: 1,
lineStyle: 0,
axisLabelVisible: true,
title: title,
})
);
}
function applyTheme() {
const t = chartTheme(currentTheme());
chart.applyOptions({
layout: t.layout,
grid: t.grid,
rightPriceScale: t.rightPriceScale,
timeScale: t.timeScale,
localization: {
priceFormatter: function (p) {
return fmtPriceByTick(p, activePriceTick);
},
},
});
if (candleSeries && typeof candleSeries.applyOptions === "function") {
candleSeries.applyOptions(t.candle);
}
applyChartPriceFormat();
}
function resize() {
chart.applyOptions({ width: host.clientWidth, height: host.clientHeight });
}
global.addEventListener("resize", resize);
resize();
const obs = new MutationObserver(applyTheme);
obs.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
return {
chart: chart,
candleSeries: candleSeries,
resetPriceLines: resetPriceLines,
addLine: addLine,
applyTheme: applyTheme,
setPriceTick: setPriceTick,
ensureSeries: function () {
if (candleSeries) return true;
const t = chartTheme(currentTheme());
const o = Object.assign({ borderVisible: false }, t.candle);
if (typeof chart.addCandlestickSeries === "function") {
candleSeries = chart.addCandlestickSeries(o);
} else if (
typeof chart.addSeries === "function" &&
global.LightweightCharts.CandlestickSeries
) {
candleSeries = chart.addSeries(global.LightweightCharts.CandlestickSeries, o);
}
applyChartPriceFormat();
return !!candleSeries;
},
};
}
global.FocusChartPage = {
currentTheme: currentTheme,
chartTheme: chartTheme,
formatSigned: formatSigned,
formatRrRatio: formatRrRatio,
displayPrice: displayPrice,
lineTitle: lineTitle,
paintOrderMeta: paintOrderMeta,
paintKeyMeta: paintKeyMeta,
createFocusChart: createFocusChart,
setActivePriceTick: setActivePriceTick,
fmtPriceByTick: fmtPriceByTick,
};
})(typeof window !== "undefined" ? window : globalThis);