93e148a3e7
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>
402 lines
13 KiB
JavaScript
402 lines
13 KiB
JavaScript
/**
|
|
* 实盘/关键位放大 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);
|