行情区:K线全屏、可选技术指标与交易所价格精度对齐

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-02 14:24:36 +08:00
parent 01d26e9833
commit 84ac9134db
6 changed files with 592 additions and 33 deletions
+13
View File
@@ -276,6 +276,19 @@ def resolve_chart_bars(
if len(db_rows) > need:
db_rows = db_rows[-need:]
if price_tick is None:
try:
tick_probe = remote_fetch(
symbol=sym,
timeframe=tf,
since_ms=None,
limit=3,
)
if tick_probe.get("ok"):
price_tick = tick_probe.get("price_tick")
except Exception:
pass
candles = _to_chart_candles(db_rows)
if not candles:
return {"ok": False, "msg": remote_err or "无 K 线数据", "purged": purged}
+52 -15
View File
@@ -56,17 +56,17 @@ def chart_fetch_start_ms(timeframe: str, need: int, now_ms: int | None = None) -
def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]:
"""最小价格变动单位(与交易所 tick / price_to_precision 一致)。"""
try:
markets = getattr(exchange, "markets", None) or {}
m = markets.get(exchange_symbol) or {}
prec = m.get("precision") or {}
p = prec.get("price")
if p is not None:
p = float(p)
if p > 0:
return p
info = m.get("info") or {}
for key in ("tickSize", "price_increment", "order_price_round"):
if not getattr(exchange, "markets", None):
exchange.load_markets()
market = exchange.market(exchange_symbol)
except Exception:
return None
info = market.get("info") or {}
if isinstance(info, dict):
for key in ("tickSize", "tickSz", "price_increment", "order_price_round", "quote_increment"):
if info.get(key) not in (None, ""):
try:
v = float(info[key])
@@ -74,11 +74,52 @@ def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]:
return v
except (TypeError, ValueError):
pass
limits = market.get("limits") or {}
price_limits = limits.get("price") or {}
if price_limits.get("min") not in (None, ""):
try:
v = float(price_limits["min"])
if v > 0:
return v
except (TypeError, ValueError):
pass
try:
sample = exchange.price_to_precision(exchange_symbol, 12345.678901234)
s = str(sample).strip()
if "." in s:
frac = s.split(".", 1)[1]
if frac:
return 10 ** (-len(frac))
return 1.0
except Exception:
pass
prec = (market.get("precision") or {}).get("price")
if prec is not None:
try:
p = float(prec)
if p >= 1 and abs(p - round(p)) < 1e-9 and p <= 12:
return 10 ** (-int(p))
if 0 < p < 1:
return p
except (TypeError, ValueError):
pass
return None
def _decimals_from_tick(tick: float) -> int:
if tick >= 1:
return 0
s = f"{tick:.12f}".rstrip("0")
if "." in s:
frac = s.split(".", 1)[1]
if frac:
return min(12, len(frac))
return max(0, min(12, int(round(-math.log10(tick)))))
def format_price_by_tick(value: Any, tick: Optional[float]) -> str:
if value in (None, ""):
return "-"
@@ -89,11 +130,7 @@ def format_price_by_tick(value: Any, tick: Optional[float]) -> str:
if v == 0:
return "0"
if tick and tick > 0:
decimals = max(0, min(12, int(round(-math.log10(tick))) if tick < 1 else 0))
if tick >= 1:
decimals = 0
text = f"{v:.{decimals}f}"
return text.rstrip("0").rstrip(".") if "." in text else text
return f"{v:.{_decimals_from_tick(float(tick))}f}"
av = abs(v)
if av >= 10000:
d = 2
+102
View File
@@ -509,6 +509,10 @@ body.hub-instance-frame-open {
overflow: hidden;
}
body.market-chart-fs-open {
overflow: hidden;
}
.instance-frame-shell {
position: fixed;
inset: 0;
@@ -1982,6 +1986,104 @@ body.login-page {
min-height: 440px;
}
.market-chart-wrap.is-fullscreen {
position: fixed;
inset: 0;
z-index: 8500;
width: 100vw;
height: 100vh !important;
max-height: none;
min-height: 0;
border-radius: 0;
border: none;
}
.market-chart-wrap.is-fullscreen.has-pos-panel {
height: 100vh !important;
}
.market-chart-actions {
margin-left: auto;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 10px;
}
.market-ind-menu {
position: relative;
font-size: 0.72rem;
}
.market-ind-menu summary {
cursor: pointer;
list-style: none;
padding: 2px 10px;
border-radius: 4px;
border: 1px solid var(--border-soft);
color: var(--muted);
user-select: none;
}
.market-ind-menu summary::-webkit-details-marker {
display: none;
}
.market-ind-menu[open] summary {
color: var(--accent);
border-color: rgba(0, 255, 157, 0.35);
}
.market-ind-options {
position: absolute;
right: 0;
top: calc(100% + 4px);
z-index: 20;
min-width: 168px;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border-soft);
background: rgba(10, 16, 26, 0.98);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
display: flex;
flex-direction: column;
gap: 6px;
}
.market-ind-opt {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: var(--text);
white-space: nowrap;
}
.market-ind-opt input {
accent-color: var(--accent);
}
.market-fs-btn,
.market-fs-exit {
font-size: 0.72rem;
padding: 2px 10px;
}
.market-fs-exit {
position: absolute;
top: 8px;
left: 8px;
z-index: 12;
}
.market-chart-wrap.is-fullscreen .market-fs-exit:not(.hidden) {
display: inline-flex !important;
}
.market-chart-wrap.is-fullscreen .market-fs-btn {
display: none;
}
.market-ohlcv-bar {
flex: 0 0 auto;
padding: 8px 12px;
+368 -15
View File
@@ -49,8 +49,27 @@
const elPosSize = document.getElementById("mkt-pos-size");
const elPosOrders = document.getElementById("market-pos-orders");
const elPosClear = document.getElementById("market-pos-clear");
const elChartWrap = document.getElementById("market-chart-wrap");
const elFsBtn = document.getElementById("market-chart-fullscreen");
const elFsExit = document.getElementById("market-chart-fs-exit");
const elIndEma = document.getElementById("market-ind-ema");
const elIndMacd = document.getElementById("market-ind-macd");
const elIndRsi = document.getElementById("market-ind-rsi");
const HUB_MARKET_POS_CTX_KEY = "hubMarketPosContext";
const EMA_FAST = 21;
const EMA_SLOW = 55;
let chartFullscreen = false;
const indicatorState = { ema: false, macd: false, rsi: false };
const indSeries = {
ema21: null,
ema55: null,
macdLine: null,
macdSignal: null,
macdHist: null,
rsi: null,
};
let chart = null;
let candleSeries = null;
@@ -133,13 +152,288 @@
}
function syncChartWrapLayout() {
const wrap = chartHost && chartHost.closest(".market-chart-wrap");
if (wrap && elPosPanel) {
const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap"));
if (wrap && elPosPanel && !chartFullscreen) {
wrap.classList.toggle("has-pos-panel", !elPosPanel.classList.contains("hidden"));
}
resizeChart();
}
function readIndicatorState() {
indicatorState.ema = !!(elIndEma && elIndEma.checked);
indicatorState.macd = !!(elIndMacd && elIndMacd.checked);
indicatorState.rsi = !!(elIndRsi && elIndRsi.checked);
}
function emaArray(values, period) {
const result = new Array(values.length).fill(null);
const k = 2 / (period + 1);
let ema = null;
for (let i = 0; i < values.length; i++) {
const v = values[i];
if (v == null || !Number.isFinite(v)) continue;
if (ema == null) {
if (i < period - 1) continue;
let sum = 0;
let ok = true;
for (let j = i - period + 1; j <= i; j++) {
const x = values[j];
if (x == null || !Number.isFinite(x)) {
ok = false;
break;
}
sum += x;
}
if (!ok) continue;
ema = sum / period;
} else {
ema = v * k + ema * (1 - k);
}
result[i] = ema;
}
return result;
}
function buildEmaSeries(candles, period) {
const closes = candles.map(function (c) {
return Number(c.close);
});
const vals = emaArray(closes, period);
const out = [];
for (let i = 0; i < candles.length; i++) {
if (vals[i] == null) continue;
out.push({ time: candles[i].time, value: vals[i] });
}
return out;
}
function buildMacdData(candles) {
const closes = candles.map(function (c) {
return Number(c.close);
});
const ema12 = emaArray(closes, 12);
const ema26 = emaArray(closes, 26);
const macd = new Array(closes.length).fill(null);
for (let i = 0; i < closes.length; i++) {
if (ema12[i] == null || ema26[i] == null) continue;
macd[i] = ema12[i] - ema26[i];
}
const signal = emaArray(macd, 9);
const macdLine = [];
const signalLine = [];
const histData = [];
for (let i = 0; i < candles.length; i++) {
const t = candles[i].time;
if (macd[i] != null) macdLine.push({ time: t, value: macd[i] });
if (signal[i] != null) signalLine.push({ time: t, value: signal[i] });
if (macd[i] != null && signal[i] != null) {
const h = macd[i] - signal[i];
histData.push({
time: t,
value: h,
color: h >= 0 ? "rgba(0, 255, 157, 0.55)" : "rgba(255, 77, 109, 0.55)",
});
}
}
return { macdLine, signalLine, histData };
}
function buildRsiSeries(candles, period) {
const out = [];
if (!candles || candles.length < period + 1) return out;
let avgGain = 0;
let avgLoss = 0;
for (let i = 1; i <= period; i++) {
const ch = Number(candles[i].close) - Number(candles[i - 1].close);
if (ch >= 0) avgGain += ch;
else avgLoss -= ch;
}
avgGain /= period;
avgLoss /= period;
let rsi = 50;
if (avgLoss <= 0) rsi = 100;
else if (avgGain <= 0) rsi = 0;
else rsi = 100 - 100 / (1 + avgGain / avgLoss);
out.push({ time: candles[period].time, value: rsi });
for (let i = period + 1; i < candles.length; i++) {
const ch = Number(candles[i].close) - Number(candles[i - 1].close);
const gain = ch > 0 ? ch : 0;
const loss = ch < 0 ? -ch : 0;
avgGain = (avgGain * (period - 1) + gain) / period;
avgLoss = (avgLoss * (period - 1) + loss) / period;
if (avgLoss <= 0) rsi = 100;
else if (avgGain <= 0) rsi = 0;
else rsi = 100 - 100 / (1 + avgGain / avgLoss);
out.push({ time: candles[i].time, value: rsi });
}
return out;
}
function createLineSeries(opts) {
if (!chart) return null;
const base = {
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
};
const o = Object.assign(base, opts || {});
if (typeof chart.addLineSeries === "function") return chart.addLineSeries(o);
if (
typeof chart.addSeries === "function" &&
window.LightweightCharts &&
window.LightweightCharts.LineSeries
) {
return chart.addSeries(window.LightweightCharts.LineSeries, o);
}
return null;
}
function createHistSeries(opts) {
if (!chart) return null;
const base = { priceLineVisible: false, lastValueVisible: false };
const o = Object.assign(base, opts || {});
if (typeof chart.addHistogramSeries === "function") return chart.addHistogramSeries(o);
if (
typeof chart.addSeries === "function" &&
window.LightweightCharts &&
window.LightweightCharts.HistogramSeries
) {
return chart.addSeries(window.LightweightCharts.HistogramSeries, o);
}
return null;
}
function clearIndicatorSeries() {
if (!chart) return;
Object.keys(indSeries).forEach(function (k) {
if (indSeries[k]) {
try {
chart.removeSeries(indSeries[k]);
} catch (e) {}
indSeries[k] = null;
}
});
}
function applyScaleLayout() {
if (!chart) return;
const rsiOn = indicatorState.rsi;
const macdOn = indicatorState.macd;
let candleBottom = CANDLE_SCALE_BOTTOM;
let volTop = VOLUME_SCALE_TOP;
const volBottom = VOLUME_SCALE_BOTTOM;
if (rsiOn && macdOn) {
candleBottom = 0.52;
volTop = 0.84;
chart.priceScale("rsi").applyOptions({
scaleMargins: { top: 0.66, bottom: 0.18 },
borderColor: "#2a4058",
autoScale: true,
});
chart.priceScale("macd").applyOptions({
scaleMargins: { top: 0.48, bottom: 0.34 },
borderColor: "#2a4058",
autoScale: true,
});
} else if (rsiOn) {
candleBottom = 0.4;
volTop = 0.78;
chart.priceScale("rsi").applyOptions({
scaleMargins: { top: 0.62, bottom: 0.22 },
borderColor: "#2a4058",
autoScale: true,
});
} else if (macdOn) {
candleBottom = 0.42;
volTop = 0.78;
chart.priceScale("macd").applyOptions({
scaleMargins: { top: 0.44, bottom: 0.3 },
borderColor: "#2a4058",
autoScale: true,
});
}
chart.priceScale("right").applyOptions({
scaleMargins: { top: 0.06, bottom: candleBottom },
});
chart.priceScale("volume").applyOptions({
scaleMargins: { top: volTop, bottom: volBottom },
});
}
function updateIndicators() {
if (!chart || !lastCandles.length) return;
readIndicatorState();
clearIndicatorSeries();
applyScaleLayout();
if (indicatorState.ema) {
const pf = tickToPriceFormat(priceTick);
indSeries.ema21 = createLineSeries({
color: "#f0c040",
title: "EMA21",
priceScaleId: "right",
priceFormat: pf,
});
indSeries.ema55 = createLineSeries({
color: "#c878ff",
title: "EMA55",
priceScaleId: "right",
priceFormat: pf,
});
if (indSeries.ema21) indSeries.ema21.setData(buildEmaSeries(lastCandles, EMA_FAST));
if (indSeries.ema55) indSeries.ema55.setData(buildEmaSeries(lastCandles, EMA_SLOW));
}
if (indicatorState.macd) {
const macd = buildMacdData(lastCandles);
indSeries.macdLine = createLineSeries({
color: "#5b9cf5",
title: "MACD",
priceScaleId: "macd",
});
indSeries.macdSignal = createLineSeries({
color: "#ffb84d",
title: "Signal",
priceScaleId: "macd",
});
indSeries.macdHist = createHistSeries({ priceScaleId: "macd" });
if (indSeries.macdLine) indSeries.macdLine.setData(macd.macdLine);
if (indSeries.macdSignal) indSeries.macdSignal.setData(macd.signalLine);
if (indSeries.macdHist) indSeries.macdHist.setData(macd.histData);
}
if (indicatorState.rsi) {
indSeries.rsi = createLineSeries({
color: "#8fc8ff",
title: "RSI",
priceScaleId: "rsi",
});
if (indSeries.rsi) indSeries.rsi.setData(buildRsiSeries(lastCandles, 14));
}
scheduleChartResize();
}
function setChartFullscreen(on) {
chartFullscreen = !!on;
const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap"));
if (wrap) wrap.classList.toggle("is-fullscreen", chartFullscreen);
document.body.classList.toggle("market-chart-fs-open", chartFullscreen);
if (elFsBtn) elFsBtn.textContent = chartFullscreen ? "退出全屏" : "全屏";
if (elFsExit) {
if (chartFullscreen) elFsExit.classList.remove("hidden");
else elFsExit.classList.add("hidden");
}
scheduleChartResize();
}
function toggleChartFullscreen() {
setChartFullscreen(!chartFullscreen);
}
function renderPosPanel(ctx) {
if (!elPosPanel || !ctx) {
clearPosPanel();
@@ -260,17 +554,55 @@
return n.toFixed(2);
}
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) {
const minMove =
tick != null && Number.isFinite(Number(tick)) && Number(tick) > 0 ? Number(tick) : 0.01;
const precision = decimalsFromTick(minMove) ?? 2;
return { type: "price", precision: precision, minMove: minMove };
}
function applyChartPriceFormat() {
const pf = tickToPriceFormat(priceTick);
if (candleSeries && candleSeries.applyOptions) {
candleSeries.applyOptions({ priceFormat: pf });
}
if (indSeries.ema21 && indSeries.ema21.applyOptions) {
indSeries.ema21.applyOptions({ priceFormat: pf });
}
if (indSeries.ema55 && indSeries.ema55.applyOptions) {
indSeries.ema55.applyOptions({ priceFormat: pf });
}
if (chart) {
chart.applyOptions({
localization: {
priceFormatter: function (p) {
return fmtPrice(p);
},
},
});
}
}
function fmtPrice(v) {
if (v == null || Number.isNaN(Number(v))) return "-";
const n = Number(v);
if (n === 0) return "0";
const tick = priceTick;
if (tick && tick > 0) {
let decimals = tick >= 1 ? 0 : Math.max(0, Math.min(12, Math.round(-Math.log10(tick))));
let text = n.toFixed(decimals);
if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, "");
return text;
}
const dec = decimalsFromTick(priceTick);
if (dec != null) return n.toFixed(dec);
const av = Math.abs(n);
let d = 8;
if (av >= 10000) d = 2;
@@ -504,6 +836,7 @@
wickDownColor: "#ff4d6d",
lastValueVisible: false,
priceLineVisible: false,
priceFormat: tickToPriceFormat(priceTick),
};
if (typeof chart.addCandlestickSeries === "function") {
@@ -533,12 +866,11 @@
}
if (!volumeSeries) return false;
chart.priceScale("right").applyOptions({
scaleMargins: { top: 0.06, bottom: CANDLE_SCALE_BOTTOM },
});
chart.priceScale("volume").applyOptions({
scaleMargins: { top: VOLUME_SCALE_TOP, bottom: VOLUME_SCALE_BOTTOM },
});
chart.priceScale("macd");
chart.priceScale("rsi");
applyScaleLayout();
applyChartPriceFormat();
applyPriceAutoScale();
chart.subscribeCrosshairMove(function (param) {
@@ -733,6 +1065,7 @@
}
priceTick = data.price_tick;
applyChartPriceFormat();
lastCandles = data.candles;
indexCandles(lastCandles);
candleSeries.setData(lastCandles);
@@ -748,6 +1081,7 @@
updateVisibleRangeMarkers();
syncPosContextForView(exKey, sym);
showLatestOhlcv();
updateIndicators();
scheduleChartResize();
const limit = data.limit || lastCandles.length;
@@ -820,6 +1154,25 @@
clearPosContext();
});
}
if (elFsBtn) {
elFsBtn.addEventListener("click", function () {
toggleChartFullscreen();
});
}
if (elFsExit) {
elFsExit.addEventListener("click", function () {
setChartFullscreen(false);
});
}
[elIndEma, elIndMacd, elIndRsi].forEach(function (el) {
if (!el) return;
el.addEventListener("change", function () {
updateIndicators();
});
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape" && chartFullscreen) setChartFullscreen(false);
});
}
window.hubMarketChart = {
+15 -3
View File
@@ -8,7 +8,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
<link rel="stylesheet" href="/assets/app.css?v=20260528-hub-vol-fix" />
<link rel="stylesheet" href="/assets/app.css?v=20260528-hub-chart-ind" />
</head>
<body>
<div class="app-bg" aria-hidden="true"></div>
@@ -97,12 +97,23 @@
<span id="market-updated" class="toolbar-meta"></span>
</div>
<p id="market-status" class="market-status"></p>
<div class="market-chart-wrap">
<div id="market-chart-wrap" class="market-chart-wrap">
<div class="market-ohlcv-bar" aria-label="K线详情">
<div class="market-ohlcv-title">
<span id="mkt-exchange-label" class="mkt-exchange-tag"></span>
<span id="mkt-symbol-label"></span>
<span id="mkt-tf-label">1d</span>
<div class="market-chart-actions">
<details class="market-ind-menu">
<summary>技术指标</summary>
<div class="market-ind-options">
<label class="market-ind-opt"><input type="checkbox" id="market-ind-ema" value="ema" /> EMA交叉(21/55)</label>
<label class="market-ind-opt"><input type="checkbox" id="market-ind-macd" value="macd" /> MACD</label>
<label class="market-ind-opt"><input type="checkbox" id="market-ind-rsi" value="rsi" /> RSI</label>
</div>
</details>
<button type="button" id="market-chart-fullscreen" class="ghost market-fs-btn" title="K线全屏">全屏</button>
</div>
</div>
<div class="market-ohlcv-row">
<span class="ohlcv-item"><span class="k"></span><span id="mkt-o"></span></span>
@@ -125,6 +136,7 @@
<div id="market-pos-orders" class="market-pos-orders"></div>
</div>
<div class="market-chart-body">
<button type="button" id="market-chart-fs-exit" class="ghost market-fs-exit hidden" title="退出全屏 (Esc)">退出全屏</button>
<div id="market-exchange-badge" class="market-exchange-badge" aria-hidden="true"></div>
<div id="market-chart" class="market-chart-host"></div>
<div id="market-price-tag" class="market-price-tag hidden" aria-hidden="true">
@@ -204,7 +216,7 @@
<div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260528-hub-vol-fix"></script>
<script src="/assets/chart.js?v=20260528-hub-price-tick"></script>
<script src="/assets/app.js?v=20260528-hub-tpsl-fix"></script>
</body>
</html>
+42
View File
@@ -21,6 +21,48 @@ class _FakeExchange:
class TestHubOhlcvLib(unittest.TestCase):
def test_price_tick_from_decimal_precision(self):
class _Ex:
markets = {"BTC/USDT:USDT": {"precision": {"price": 2}, "info": {}, "limits": {}}}
def load_markets(self):
return self.markets
def market(self, sym):
return self.markets[sym]
def price_to_precision(self, sym, price):
return "12345.67"
tick = __import__("hub_ohlcv_lib", fromlist=["price_tick_from_market"]).price_tick_from_market(
_Ex(), "BTC/USDT:USDT"
)
self.assertAlmostEqual(tick, 0.01)
def test_price_tick_from_info_tick_size(self):
class _Ex:
markets = {
"INJ/USDT:USDT": {
"precision": {"price": 4},
"info": {"tickSize": "0.001"},
"limits": {},
}
}
def load_markets(self):
return self.markets
def market(self, sym):
return self.markets[sym]
def price_to_precision(self, sym, price):
return "7.123"
from hub_ohlcv_lib import price_tick_from_market
tick = price_tick_from_market(_Ex(), "INJ/USDT:USDT")
self.assertAlmostEqual(tick, 0.001)
def test_pagination_continues_when_page_smaller_than_chunk(self):
"""Gate 等常返回 299 根/次,不应误判为已到末尾。"""
base = 1_700_000_000_000