行情区:K线全屏、可选技术指标与交易所价格精度对齐
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -276,6 +276,19 @@ def resolve_chart_bars(
|
|||||||
if len(db_rows) > need:
|
if len(db_rows) > need:
|
||||||
db_rows = 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)
|
candles = _to_chart_candles(db_rows)
|
||||||
if not candles:
|
if not candles:
|
||||||
return {"ok": False, "msg": remote_err or "无 K 线数据", "purged": purged}
|
return {"ok": False, "msg": remote_err or "无 K 线数据", "purged": purged}
|
||||||
|
|||||||
+52
-15
@@ -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]:
|
def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]:
|
||||||
|
"""最小价格变动单位(与交易所 tick / price_to_precision 一致)。"""
|
||||||
try:
|
try:
|
||||||
markets = getattr(exchange, "markets", None) or {}
|
if not getattr(exchange, "markets", None):
|
||||||
m = markets.get(exchange_symbol) or {}
|
exchange.load_markets()
|
||||||
prec = m.get("precision") or {}
|
market = exchange.market(exchange_symbol)
|
||||||
p = prec.get("price")
|
except Exception:
|
||||||
if p is not None:
|
return None
|
||||||
p = float(p)
|
|
||||||
if p > 0:
|
info = market.get("info") or {}
|
||||||
return p
|
if isinstance(info, dict):
|
||||||
info = m.get("info") or {}
|
for key in ("tickSize", "tickSz", "price_increment", "order_price_round", "quote_increment"):
|
||||||
for key in ("tickSize", "price_increment", "order_price_round"):
|
|
||||||
if info.get(key) not in (None, ""):
|
if info.get(key) not in (None, ""):
|
||||||
try:
|
try:
|
||||||
v = float(info[key])
|
v = float(info[key])
|
||||||
@@ -74,11 +74,52 @@ def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]:
|
|||||||
return v
|
return v
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
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:
|
except Exception:
|
||||||
pass
|
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
|
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:
|
def format_price_by_tick(value: Any, tick: Optional[float]) -> str:
|
||||||
if value in (None, ""):
|
if value in (None, ""):
|
||||||
return "-"
|
return "-"
|
||||||
@@ -89,11 +130,7 @@ def format_price_by_tick(value: Any, tick: Optional[float]) -> str:
|
|||||||
if v == 0:
|
if v == 0:
|
||||||
return "0"
|
return "0"
|
||||||
if tick and tick > 0:
|
if tick and tick > 0:
|
||||||
decimals = max(0, min(12, int(round(-math.log10(tick))) if tick < 1 else 0))
|
return f"{v:.{_decimals_from_tick(float(tick))}f}"
|
||||||
if tick >= 1:
|
|
||||||
decimals = 0
|
|
||||||
text = f"{v:.{decimals}f}"
|
|
||||||
return text.rstrip("0").rstrip(".") if "." in text else text
|
|
||||||
av = abs(v)
|
av = abs(v)
|
||||||
if av >= 10000:
|
if av >= 10000:
|
||||||
d = 2
|
d = 2
|
||||||
|
|||||||
@@ -509,6 +509,10 @@ body.hub-instance-frame-open {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body.market-chart-fs-open {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.instance-frame-shell {
|
.instance-frame-shell {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1982,6 +1986,104 @@ body.login-page {
|
|||||||
min-height: 440px;
|
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 {
|
.market-ohlcv-bar {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
|||||||
@@ -49,8 +49,27 @@
|
|||||||
const elPosSize = document.getElementById("mkt-pos-size");
|
const elPosSize = document.getElementById("mkt-pos-size");
|
||||||
const elPosOrders = document.getElementById("market-pos-orders");
|
const elPosOrders = document.getElementById("market-pos-orders");
|
||||||
const elPosClear = document.getElementById("market-pos-clear");
|
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 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 chart = null;
|
||||||
let candleSeries = null;
|
let candleSeries = null;
|
||||||
@@ -133,13 +152,288 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncChartWrapLayout() {
|
function syncChartWrapLayout() {
|
||||||
const wrap = chartHost && chartHost.closest(".market-chart-wrap");
|
const wrap = elChartWrap || (chartHost && chartHost.closest(".market-chart-wrap"));
|
||||||
if (wrap && elPosPanel) {
|
if (wrap && elPosPanel && !chartFullscreen) {
|
||||||
wrap.classList.toggle("has-pos-panel", !elPosPanel.classList.contains("hidden"));
|
wrap.classList.toggle("has-pos-panel", !elPosPanel.classList.contains("hidden"));
|
||||||
}
|
}
|
||||||
resizeChart();
|
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) {
|
function renderPosPanel(ctx) {
|
||||||
if (!elPosPanel || !ctx) {
|
if (!elPosPanel || !ctx) {
|
||||||
clearPosPanel();
|
clearPosPanel();
|
||||||
@@ -260,17 +554,55 @@
|
|||||||
return n.toFixed(2);
|
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) {
|
function fmtPrice(v) {
|
||||||
if (v == null || Number.isNaN(Number(v))) return "-";
|
if (v == null || Number.isNaN(Number(v))) return "-";
|
||||||
const n = Number(v);
|
const n = Number(v);
|
||||||
if (n === 0) return "0";
|
if (n === 0) return "0";
|
||||||
const tick = priceTick;
|
const dec = decimalsFromTick(priceTick);
|
||||||
if (tick && tick > 0) {
|
if (dec != null) return n.toFixed(dec);
|
||||||
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 av = Math.abs(n);
|
const av = Math.abs(n);
|
||||||
let d = 8;
|
let d = 8;
|
||||||
if (av >= 10000) d = 2;
|
if (av >= 10000) d = 2;
|
||||||
@@ -504,6 +836,7 @@
|
|||||||
wickDownColor: "#ff4d6d",
|
wickDownColor: "#ff4d6d",
|
||||||
lastValueVisible: false,
|
lastValueVisible: false,
|
||||||
priceLineVisible: false,
|
priceLineVisible: false,
|
||||||
|
priceFormat: tickToPriceFormat(priceTick),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof chart.addCandlestickSeries === "function") {
|
if (typeof chart.addCandlestickSeries === "function") {
|
||||||
@@ -533,12 +866,11 @@
|
|||||||
}
|
}
|
||||||
if (!volumeSeries) return false;
|
if (!volumeSeries) return false;
|
||||||
|
|
||||||
chart.priceScale("right").applyOptions({
|
chart.priceScale("macd");
|
||||||
scaleMargins: { top: 0.06, bottom: CANDLE_SCALE_BOTTOM },
|
chart.priceScale("rsi");
|
||||||
});
|
|
||||||
chart.priceScale("volume").applyOptions({
|
applyScaleLayout();
|
||||||
scaleMargins: { top: VOLUME_SCALE_TOP, bottom: VOLUME_SCALE_BOTTOM },
|
applyChartPriceFormat();
|
||||||
});
|
|
||||||
applyPriceAutoScale();
|
applyPriceAutoScale();
|
||||||
|
|
||||||
chart.subscribeCrosshairMove(function (param) {
|
chart.subscribeCrosshairMove(function (param) {
|
||||||
@@ -733,6 +1065,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
priceTick = data.price_tick;
|
priceTick = data.price_tick;
|
||||||
|
applyChartPriceFormat();
|
||||||
lastCandles = data.candles;
|
lastCandles = data.candles;
|
||||||
indexCandles(lastCandles);
|
indexCandles(lastCandles);
|
||||||
candleSeries.setData(lastCandles);
|
candleSeries.setData(lastCandles);
|
||||||
@@ -748,6 +1081,7 @@
|
|||||||
updateVisibleRangeMarkers();
|
updateVisibleRangeMarkers();
|
||||||
syncPosContextForView(exKey, sym);
|
syncPosContextForView(exKey, sym);
|
||||||
showLatestOhlcv();
|
showLatestOhlcv();
|
||||||
|
updateIndicators();
|
||||||
scheduleChartResize();
|
scheduleChartResize();
|
||||||
|
|
||||||
const limit = data.limit || lastCandles.length;
|
const limit = data.limit || lastCandles.length;
|
||||||
@@ -820,6 +1154,25 @@
|
|||||||
clearPosContext();
|
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 = {
|
window.hubMarketChart = {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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'" />
|
<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>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-bg" aria-hidden="true"></div>
|
<div class="app-bg" aria-hidden="true"></div>
|
||||||
@@ -97,12 +97,23 @@
|
|||||||
<span id="market-updated" class="toolbar-meta"></span>
|
<span id="market-updated" class="toolbar-meta"></span>
|
||||||
</div>
|
</div>
|
||||||
<p id="market-status" class="market-status"></p>
|
<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-bar" aria-label="K线详情">
|
||||||
<div class="market-ohlcv-title">
|
<div class="market-ohlcv-title">
|
||||||
<span id="mkt-exchange-label" class="mkt-exchange-tag"></span>
|
<span id="mkt-exchange-label" class="mkt-exchange-tag"></span>
|
||||||
<span id="mkt-symbol-label">—</span>
|
<span id="mkt-symbol-label">—</span>
|
||||||
<span id="mkt-tf-label">1d</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>
|
||||||
<div class="market-ohlcv-row">
|
<div class="market-ohlcv-row">
|
||||||
<span class="ohlcv-item"><span class="k">开</span><span id="mkt-o">—</span></span>
|
<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 id="market-pos-orders" class="market-pos-orders"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="market-chart-body">
|
<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-exchange-badge" class="market-exchange-badge" aria-hidden="true"></div>
|
||||||
<div id="market-chart" class="market-chart-host"></div>
|
<div id="market-chart" class="market-chart-host"></div>
|
||||||
<div id="market-price-tag" class="market-price-tag hidden" aria-hidden="true">
|
<div id="market-price-tag" class="market-price-tag hidden" aria-hidden="true">
|
||||||
@@ -204,7 +216,7 @@
|
|||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
<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>
|
<script src="/assets/app.js?v=20260528-hub-tpsl-fix"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,6 +21,48 @@ class _FakeExchange:
|
|||||||
|
|
||||||
|
|
||||||
class TestHubOhlcvLib(unittest.TestCase):
|
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):
|
def test_pagination_continues_when_page_smaller_than_chunk(self):
|
||||||
"""Gate 等常返回 299 根/次,不应误判为已到末尾。"""
|
"""Gate 等常返回 299 根/次,不应误判为已到末尾。"""
|
||||||
base = 1_700_000_000_000
|
base = 1_700_000_000_000
|
||||||
|
|||||||
Reference in New Issue
Block a user