行情区图表:成交量、十字线 OHLCV、可视高低点、日线满 500 根
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+6
-9
@@ -11,6 +11,7 @@ from typing import Any, Callable, Optional
|
|||||||
from hub_ohlcv_lib import (
|
from hub_ohlcv_lib import (
|
||||||
TIMEFRAME_MS,
|
TIMEFRAME_MS,
|
||||||
bar_limit_for_timeframe,
|
bar_limit_for_timeframe,
|
||||||
|
chart_fetch_start_ms,
|
||||||
format_price_by_tick,
|
format_price_by_tick,
|
||||||
last_closed_bar_open_ms,
|
last_closed_bar_open_ms,
|
||||||
normalize_chart_timeframe,
|
normalize_chart_timeframe,
|
||||||
@@ -233,14 +234,15 @@ def resolve_chart_bars(
|
|||||||
|
|
||||||
need = bar_limit_for_timeframe(tf)
|
need = bar_limit_for_timeframe(tf)
|
||||||
now_ms = int(time.time() * 1000)
|
now_ms = int(time.time() * 1000)
|
||||||
start_ms = window_start_ms(tf, need, retention_days(), now_ms)
|
fetch_start_ms = chart_fetch_start_ms(tf, need, now_ms)
|
||||||
|
db_read_start_ms = window_start_ms(tf, need, retention_days(), now_ms)
|
||||||
last_closed = last_closed_bar_open_ms(tf, now_ms)
|
last_closed = last_closed_bar_open_ms(tf, now_ms)
|
||||||
|
|
||||||
db_rows: list[dict[str, Any]] = []
|
db_rows: list[dict[str, Any]] = []
|
||||||
if not force_refresh:
|
if not force_refresh:
|
||||||
period_ms = TIMEFRAME_MS[tf]
|
period_ms = TIMEFRAME_MS[tf]
|
||||||
db_rows = load_bars_range(
|
db_rows = load_bars_range(
|
||||||
ex_k, sym, tf, max(0, start_ms - period_ms), now_ms + period_ms, db_path
|
ex_k, sym, tf, max(0, db_read_start_ms - period_ms), now_ms + period_ms, db_path
|
||||||
)
|
)
|
||||||
|
|
||||||
newest_db = db_rows[-1]["open_time_ms"] if db_rows else None
|
newest_db = db_rows[-1]["open_time_ms"] if db_rows else None
|
||||||
@@ -253,7 +255,7 @@ def resolve_chart_bars(
|
|||||||
remote_err: Optional[str] = None
|
remote_err: Optional[str] = None
|
||||||
|
|
||||||
if need_fetch:
|
if need_fetch:
|
||||||
since = start_ms
|
since = fetch_start_ms
|
||||||
if db_rows and not force_refresh:
|
if db_rows and not force_refresh:
|
||||||
since = min(since, int(db_rows[0]["open_time_ms"]))
|
since = min(since, int(db_rows[0]["open_time_ms"]))
|
||||||
remote = remote_fetch(
|
remote = remote_fetch(
|
||||||
@@ -265,7 +267,7 @@ def resolve_chart_bars(
|
|||||||
if remote.get("ok") and remote.get("bars"):
|
if remote.get("ok") and remote.get("bars"):
|
||||||
fetched = upsert_bars(ex_k, sym, tf, remote["bars"], db_path)
|
fetched = upsert_bars(ex_k, sym, tf, remote["bars"], db_path)
|
||||||
price_tick = remote.get("price_tick")
|
price_tick = remote.get("price_tick")
|
||||||
db_rows = load_bars_range(ex_k, sym, tf, start_ms, now_ms, db_path)
|
db_rows = load_bars_range(ex_k, sym, tf, fetch_start_ms, now_ms, db_path)
|
||||||
else:
|
else:
|
||||||
remote_err = remote.get("msg") or remote.get("error") or "实例拉取 K 线失败"
|
remote_err = remote.get("msg") or remote.get("error") or "实例拉取 K 线失败"
|
||||||
if not db_rows:
|
if not db_rows:
|
||||||
@@ -282,9 +284,6 @@ def resolve_chart_bars(
|
|||||||
if fetched:
|
if fetched:
|
||||||
from_cache = max(0, len(candles) - min(fetched, len(candles)))
|
from_cache = max(0, len(candles) - min(fetched, len(candles)))
|
||||||
|
|
||||||
hi = max(candles, key=lambda x: x["high"])
|
|
||||||
lo = min(candles, key=lambda x: x["low"])
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"symbol": sym,
|
"symbol": sym,
|
||||||
@@ -297,8 +296,6 @@ def resolve_chart_bars(
|
|||||||
"fetched": fetched,
|
"fetched": fetched,
|
||||||
"purged": purged,
|
"purged": purged,
|
||||||
"price_tick": price_tick,
|
"price_tick": price_tick,
|
||||||
"range_high": {"time": hi["time"], "price": hi["high"]},
|
|
||||||
"range_low": {"time": lo["time"], "price": lo["low"]},
|
|
||||||
"stale": bool(remote_err),
|
"stale": bool(remote_err),
|
||||||
"stale_message": remote_err if remote_err else None,
|
"stale_message": remote_err if remote_err else None,
|
||||||
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ def last_closed_bar_open_ms(timeframe: str, now_ms: int | None = None) -> int:
|
|||||||
|
|
||||||
|
|
||||||
def window_start_ms(timeframe: str, need: int, retention_days: int, now_ms: int | None = None) -> int:
|
def window_start_ms(timeframe: str, need: int, retention_days: int, now_ms: int | None = None) -> int:
|
||||||
|
"""本地库清理/读库窗口:不超过 retention_days。"""
|
||||||
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||||
period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)]
|
period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)]
|
||||||
retention_cutoff = now - max(1, int(retention_days)) * 86400000
|
retention_cutoff = now - max(1, int(retention_days)) * 86400000
|
||||||
@@ -47,6 +48,13 @@ def window_start_ms(timeframe: str, need: int, retention_days: int, now_ms: int
|
|||||||
return max(retention_cutoff, want)
|
return max(retention_cutoff, want)
|
||||||
|
|
||||||
|
|
||||||
|
def chart_fetch_start_ms(timeframe: str, need: int, now_ms: int | None = None) -> int:
|
||||||
|
"""行情展示拉取起点:按 need 根回看(日线 500 / 日内 1000),不受 DB 保留天数限制。"""
|
||||||
|
now = int(now_ms if now_ms is not None else time.time() * 1000)
|
||||||
|
period = TIMEFRAME_MS[normalize_chart_timeframe(timeframe)]
|
||||||
|
return max(0, now - max(1, int(need)) * period)
|
||||||
|
|
||||||
|
|
||||||
def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]:
|
def price_tick_from_market(exchange, exchange_symbol: str) -> Optional[float]:
|
||||||
try:
|
try:
|
||||||
markets = getattr(exchange, "markets", None) or {}
|
markets = getattr(exchange, "markets", None) or {}
|
||||||
|
|||||||
@@ -363,7 +363,7 @@ def api_chart_meta():
|
|||||||
def api_chart_ohlcv(
|
def api_chart_ohlcv(
|
||||||
exchange_key: str = "",
|
exchange_key: str = "",
|
||||||
symbol: str = "",
|
symbol: str = "",
|
||||||
timeframe: str = "5m",
|
timeframe: str = "1d",
|
||||||
refresh: str = "",
|
refresh: str = "",
|
||||||
):
|
):
|
||||||
ex = _find_exchange_by_key(exchange_key)
|
ex = _find_exchange_by_key(exchange_key)
|
||||||
|
|||||||
@@ -1988,6 +1988,14 @@ body.login-page {
|
|||||||
border: 1px solid var(--border-soft);
|
border: 1px solid var(--border-soft);
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: opacity 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.market-ohlcv-overlay.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.market-ohlcv-title {
|
.market-ohlcv-title {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 中控行情区:单图 + 周期切换,数据来自 /api/chart/ohlcv(本地库优先)。
|
* 中控行情区:K 线 + 底部成交量;十字线时显示 OHLCV;可视区间高低点。
|
||||||
*/
|
*/
|
||||||
(function () {
|
(function () {
|
||||||
const TF_ORDER = ["1m", "5m", "15m", "1h", "4h", "1d", "1w"];
|
|
||||||
const chartHost = document.getElementById("market-chart");
|
const chartHost = document.getElementById("market-chart");
|
||||||
if (!chartHost) return;
|
if (!chartHost) return;
|
||||||
|
|
||||||
@@ -12,6 +11,7 @@
|
|||||||
const elRefresh = document.getElementById("market-refresh");
|
const elRefresh = document.getElementById("market-refresh");
|
||||||
const elStatus = document.getElementById("market-status");
|
const elStatus = document.getElementById("market-status");
|
||||||
const elUpdated = document.getElementById("market-updated");
|
const elUpdated = document.getElementById("market-updated");
|
||||||
|
const elOverlay = document.querySelector(".market-ohlcv-overlay");
|
||||||
const elO = document.getElementById("mkt-o");
|
const elO = document.getElementById("mkt-o");
|
||||||
const elH = document.getElementById("mkt-h");
|
const elH = document.getElementById("mkt-h");
|
||||||
const elL = document.getElementById("mkt-l");
|
const elL = document.getElementById("mkt-l");
|
||||||
@@ -22,9 +22,11 @@
|
|||||||
|
|
||||||
let chart = null;
|
let chart = null;
|
||||||
let candleSeries = null;
|
let candleSeries = null;
|
||||||
|
let volumeSeries = null;
|
||||||
let priceTick = null;
|
let priceTick = null;
|
||||||
let rangeMarkers = [];
|
let rangeMarkers = [];
|
||||||
let lastCandles = [];
|
let lastCandles = [];
|
||||||
|
let candleByTime = {};
|
||||||
let chartMeta = null;
|
let chartMeta = null;
|
||||||
let loadToken = 0;
|
let loadToken = 0;
|
||||||
let marketInited = false;
|
let marketInited = false;
|
||||||
@@ -38,6 +40,33 @@
|
|||||||
return n.toFixed(2);
|
return n.toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
let text = n.toFixed(d);
|
||||||
|
if (text.indexOf(".") >= 0) text = text.replace(/\.?0+$/, "");
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOverlayVisible(on) {
|
||||||
|
if (!elOverlay) return;
|
||||||
|
elOverlay.classList.toggle("is-active", !!on);
|
||||||
|
}
|
||||||
|
|
||||||
function paintOhlcv(bar) {
|
function paintOhlcv(bar) {
|
||||||
if (!bar) {
|
if (!bar) {
|
||||||
["o", "h", "l", "c", "v"].forEach(function (k) {
|
["o", "h", "l", "c", "v"].forEach(function (k) {
|
||||||
@@ -46,15 +75,43 @@
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (elO) elO.textContent = bar.open != null ? String(bar.open) : "-";
|
if (elO) elO.textContent = fmtPrice(bar.open);
|
||||||
if (elH) elH.textContent = bar.high != null ? String(bar.high) : "-";
|
if (elH) elH.textContent = fmtPrice(bar.high);
|
||||||
if (elL) elL.textContent = bar.low != null ? String(bar.low) : "-";
|
if (elL) elL.textContent = fmtPrice(bar.low);
|
||||||
if (elC) elC.textContent = bar.close != null ? String(bar.close) : "-";
|
if (elC) elC.textContent = fmtPrice(bar.close);
|
||||||
if (elV) elV.textContent = fmtVol(bar.volume);
|
if (elV) elV.textContent = fmtVol(bar.volume);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideOhlcvOverlay() {
|
||||||
|
setOverlayVisible(false);
|
||||||
|
paintOhlcv(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexCandles(candles) {
|
||||||
|
candleByTime = {};
|
||||||
|
(candles || []).forEach(function (c) {
|
||||||
|
if (c && c.time != null) candleByTime[c.time] = c;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function candleAtTime(t) {
|
||||||
|
if (t == null) return null;
|
||||||
|
return candleByTime[t] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVolumeData(candles) {
|
||||||
|
return (candles || []).map(function (c) {
|
||||||
|
const up = Number(c.close) >= Number(c.open);
|
||||||
|
return {
|
||||||
|
time: c.time,
|
||||||
|
value: Number(c.volume) || 0,
|
||||||
|
color: up ? "rgba(0, 255, 157, 0.5)" : "rgba(255, 77, 109, 0.5)",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function ensureChart() {
|
function ensureChart() {
|
||||||
if (chart && candleSeries) return true;
|
if (chart && candleSeries && volumeSeries) return true;
|
||||||
if (!window.LightweightCharts) {
|
if (!window.LightweightCharts) {
|
||||||
if (elStatus) {
|
if (elStatus) {
|
||||||
elStatus.className = "market-status err";
|
elStatus.className = "market-status err";
|
||||||
@@ -64,40 +121,77 @@
|
|||||||
}
|
}
|
||||||
chart = LightweightCharts.createChart(chartHost, {
|
chart = LightweightCharts.createChart(chartHost, {
|
||||||
layout: { background: { color: "#0a1018" }, textColor: "#b8d4e8" },
|
layout: { background: { color: "#0a1018" }, textColor: "#b8d4e8" },
|
||||||
grid: { vertLines: { color: "#1a2838" }, horzLines: { color: "#1a2838" } },
|
grid: {
|
||||||
|
vertLines: { visible: false },
|
||||||
|
horzLines: { visible: false },
|
||||||
|
},
|
||||||
rightPriceScale: { borderColor: "#2a4058" },
|
rightPriceScale: { borderColor: "#2a4058" },
|
||||||
timeScale: { borderColor: "#2a4058", timeVisible: true, secondsVisible: false },
|
timeScale: { borderColor: "#2a4058", timeVisible: true, secondsVisible: false },
|
||||||
crosshair: { mode: LightweightCharts.CrosshairMode ? LightweightCharts.CrosshairMode.Normal : 0 },
|
crosshair: {
|
||||||
|
mode: LightweightCharts.CrosshairMode
|
||||||
|
? LightweightCharts.CrosshairMode.Normal
|
||||||
|
: 0,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const opts = {
|
|
||||||
|
const candleOpts = {
|
||||||
upColor: "#00ff9d",
|
upColor: "#00ff9d",
|
||||||
downColor: "#ff4d6d",
|
downColor: "#ff4d6d",
|
||||||
borderVisible: false,
|
borderVisible: false,
|
||||||
wickUpColor: "#00ff9d",
|
wickUpColor: "#00ff9d",
|
||||||
wickDownColor: "#ff4d6d",
|
wickDownColor: "#ff4d6d",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof chart.addCandlestickSeries === "function") {
|
if (typeof chart.addCandlestickSeries === "function") {
|
||||||
candleSeries = chart.addCandlestickSeries(opts);
|
candleSeries = chart.addCandlestickSeries(candleOpts);
|
||||||
} else if (
|
} else if (
|
||||||
typeof chart.addSeries === "function" &&
|
typeof chart.addSeries === "function" &&
|
||||||
window.LightweightCharts &&
|
window.LightweightCharts &&
|
||||||
window.LightweightCharts.CandlestickSeries
|
window.LightweightCharts.CandlestickSeries
|
||||||
) {
|
) {
|
||||||
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, candleOpts);
|
||||||
}
|
}
|
||||||
if (!candleSeries) return false;
|
if (!candleSeries) return false;
|
||||||
|
|
||||||
chart.subscribeCrosshairMove(function (param) {
|
const volOpts = {
|
||||||
if (!param || !param.time || !param.seriesData) return;
|
priceFormat: { type: "volume" },
|
||||||
const d = param.seriesData.get(candleSeries);
|
priceScaleId: "volume",
|
||||||
if (!d) return;
|
lastValueVisible: false,
|
||||||
paintOhlcv({
|
};
|
||||||
open: d.open,
|
if (typeof chart.addHistogramSeries === "function") {
|
||||||
high: d.high,
|
volumeSeries = chart.addHistogramSeries(volOpts);
|
||||||
low: d.low,
|
} else if (
|
||||||
close: d.close,
|
typeof chart.addSeries === "function" &&
|
||||||
volume: d.volume,
|
window.LightweightCharts &&
|
||||||
|
window.LightweightCharts.HistogramSeries
|
||||||
|
) {
|
||||||
|
volumeSeries = chart.addSeries(window.LightweightCharts.HistogramSeries, volOpts);
|
||||||
|
}
|
||||||
|
if (!volumeSeries) return false;
|
||||||
|
|
||||||
|
chart.priceScale("right").applyOptions({
|
||||||
|
scaleMargins: { top: 0.06, bottom: 0.28 },
|
||||||
});
|
});
|
||||||
|
chart.priceScale("volume").applyOptions({
|
||||||
|
scaleMargins: { top: 0.78, bottom: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
chart.subscribeCrosshairMove(function (param) {
|
||||||
|
if (!param || param.time == null) {
|
||||||
|
hideOhlcvOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bar = candleAtTime(param.time);
|
||||||
|
if (!bar) {
|
||||||
|
hideOhlcvOverlay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOverlayVisible(true);
|
||||||
|
paintOhlcv(bar);
|
||||||
|
});
|
||||||
|
|
||||||
|
chart.timeScale().subscribeVisibleLogicalRangeChange(function () {
|
||||||
|
updateVisibleRangeMarkers();
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("resize", function () {
|
window.addEventListener("resize", function () {
|
||||||
@@ -105,6 +199,7 @@
|
|||||||
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||||
});
|
});
|
||||||
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||||
|
hideOhlcvOverlay();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,36 +212,48 @@
|
|||||||
rangeMarkers = [];
|
rangeMarkers = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRangeMarkers(data) {
|
function updateVisibleRangeMarkers() {
|
||||||
clearMarkers();
|
clearMarkers();
|
||||||
if (!candleSeries || !data) return;
|
if (!candleSeries || !chart || !lastCandles.length) return;
|
||||||
const hi = data.range_high;
|
|
||||||
const lo = data.range_low;
|
const range = chart.timeScale().getVisibleLogicalRange();
|
||||||
if (hi && hi.price != null) {
|
if (!range) return;
|
||||||
|
|
||||||
|
const from = Math.max(0, Math.floor(range.from));
|
||||||
|
const to = Math.min(lastCandles.length - 1, Math.ceil(range.to));
|
||||||
|
if (to < from) return;
|
||||||
|
|
||||||
|
let hi = null;
|
||||||
|
let lo = null;
|
||||||
|
for (let i = from; i <= to; i++) {
|
||||||
|
const c = lastCandles[i];
|
||||||
|
if (!c) continue;
|
||||||
|
if (!hi || c.high > hi.high) hi = c;
|
||||||
|
if (!lo || c.low < lo.low) lo = c;
|
||||||
|
}
|
||||||
|
if (!hi || !lo) return;
|
||||||
|
|
||||||
rangeMarkers.push(
|
rangeMarkers.push(
|
||||||
candleSeries.createPriceLine({
|
candleSeries.createPriceLine({
|
||||||
price: Number(hi.price),
|
price: Number(hi.high),
|
||||||
color: "#ffb84d",
|
color: "#ffb84d",
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
lineStyle: 2,
|
lineStyle: 2,
|
||||||
axisLabelVisible: true,
|
axisLabelVisible: true,
|
||||||
title: "区间高",
|
title: "可视高",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
|
||||||
if (lo && lo.price != null) {
|
|
||||||
rangeMarkers.push(
|
rangeMarkers.push(
|
||||||
candleSeries.createPriceLine({
|
candleSeries.createPriceLine({
|
||||||
price: Number(lo.price),
|
price: Number(lo.low),
|
||||||
color: "#4cd97f",
|
color: "#4cd97f",
|
||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
lineStyle: 2,
|
lineStyle: 2,
|
||||||
axisLabelVisible: true,
|
axisLabelVisible: true,
|
||||||
title: "区间低",
|
title: "可视低",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function readQuery() {
|
function readQuery() {
|
||||||
const qs = new URLSearchParams(window.location.search);
|
const qs = new URLSearchParams(window.location.search);
|
||||||
@@ -158,6 +265,11 @@
|
|||||||
if (tf && elTf) elTf.value = tf;
|
if (tf && elTf) elTf.value = tf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyDefaults() {
|
||||||
|
if (elSymbol && !elSymbol.value.trim()) elSymbol.value = "BTC/USDT";
|
||||||
|
if (elTf && !elTf.value) elTf.value = "1d";
|
||||||
|
}
|
||||||
|
|
||||||
async function loadMeta() {
|
async function loadMeta() {
|
||||||
const r = await fetch("/api/chart/meta", { credentials: "same-origin" });
|
const r = await fetch("/api/chart/meta", { credentials: "same-origin" });
|
||||||
chartMeta = await r.json();
|
chartMeta = await r.json();
|
||||||
@@ -170,13 +282,14 @@
|
|||||||
elExchange.appendChild(opt);
|
elExchange.appendChild(opt);
|
||||||
});
|
});
|
||||||
readQuery();
|
readQuery();
|
||||||
|
applyDefaults();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadChart(force) {
|
async function loadChart(force) {
|
||||||
if (!ensureChart()) return;
|
if (!ensureChart()) return;
|
||||||
const exKey = (elExchange && elExchange.value) || "";
|
const exKey = (elExchange && elExchange.value) || "";
|
||||||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||||||
const tf = (elTf && elTf.value) || "5m";
|
const tf = (elTf && elTf.value) || "1d";
|
||||||
if (!exKey || !sym) {
|
if (!exKey || !sym) {
|
||||||
if (elStatus) {
|
if (elStatus) {
|
||||||
elStatus.className = "market-status err";
|
elStatus.className = "market-status err";
|
||||||
@@ -189,6 +302,7 @@
|
|||||||
elStatus.className = "market-status";
|
elStatus.className = "market-status";
|
||||||
elStatus.textContent = "加载中…";
|
elStatus.textContent = "加载中…";
|
||||||
}
|
}
|
||||||
|
hideOhlcvOverlay();
|
||||||
if (elSymLabel) elSymLabel.textContent = sym;
|
if (elSymLabel) elSymLabel.textContent = sym;
|
||||||
if (elTfLabel) elTfLabel.textContent = tf;
|
if (elTfLabel) elTfLabel.textContent = tf;
|
||||||
|
|
||||||
@@ -212,23 +326,19 @@
|
|||||||
|
|
||||||
priceTick = data.price_tick;
|
priceTick = data.price_tick;
|
||||||
lastCandles = data.candles;
|
lastCandles = data.candles;
|
||||||
candleSeries.setData(data.candles);
|
indexCandles(lastCandles);
|
||||||
|
candleSeries.setData(lastCandles);
|
||||||
|
volumeSeries.setData(buildVolumeData(lastCandles));
|
||||||
chart.timeScale().fitContent();
|
chart.timeScale().fitContent();
|
||||||
addRangeMarkers(data);
|
updateVisibleRangeMarkers();
|
||||||
|
|
||||||
const ohlcv = data.ohlcv || {};
|
|
||||||
paintOhlcv({
|
|
||||||
open: ohlcv.open,
|
|
||||||
high: ohlcv.high,
|
|
||||||
low: ohlcv.low,
|
|
||||||
close: ohlcv.close,
|
|
||||||
volume: ohlcv.volume,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const limit = data.limit || lastCandles.length;
|
||||||
let hint =
|
let hint =
|
||||||
"已加载 " +
|
"已加载 " +
|
||||||
data.candles.length +
|
data.candles.length +
|
||||||
" 根(库 " +
|
" 根(目标 " +
|
||||||
|
limit +
|
||||||
|
")· 库 " +
|
||||||
(data.from_cache || 0) +
|
(data.from_cache || 0) +
|
||||||
" / 新拉 " +
|
" / 新拉 " +
|
||||||
(data.fetched || 0) +
|
(data.fetched || 0) +
|
||||||
@@ -274,10 +384,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const btnLoad = document.getElementById("market-load");
|
const btnLoad = document.getElementById("market-load");
|
||||||
if (btnLoad) btnLoad.addEventListener("click", function () {
|
if (btnLoad) {
|
||||||
|
btnLoad.addEventListener("click", function () {
|
||||||
loadChart(false);
|
loadChart(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.hubMarketChart = {
|
window.hubMarketChart = {
|
||||||
init: async function () {
|
init: async function () {
|
||||||
@@ -293,7 +405,10 @@
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
if (document.getElementById("page-market") && !document.getElementById("page-market").classList.contains("hidden")) {
|
if (
|
||||||
|
document.getElementById("page-market") &&
|
||||||
|
!document.getElementById("page-market").classList.contains("hidden")
|
||||||
|
) {
|
||||||
window.hubMarketChart.init();
|
window.hubMarketChart.init();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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-market" />
|
<link rel="stylesheet" href="/assets/app.css?v=20260528-hub-market2" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-bg" aria-hidden="true"></div>
|
<div class="app-bg" aria-hidden="true"></div>
|
||||||
@@ -76,17 +76,17 @@
|
|||||||
</label>
|
</label>
|
||||||
<label class="market-field">
|
<label class="market-field">
|
||||||
<span>币种</span>
|
<span>币种</span>
|
||||||
<input id="market-symbol" type="text" placeholder="TON/USDT" autocomplete="off" />
|
<input id="market-symbol" type="text" value="BTC/USDT" placeholder="BTC/USDT" autocomplete="off" />
|
||||||
</label>
|
</label>
|
||||||
<label class="market-field">
|
<label class="market-field">
|
||||||
<span>周期</span>
|
<span>周期</span>
|
||||||
<select id="market-timeframe">
|
<select id="market-timeframe">
|
||||||
<option value="1m">1m</option>
|
<option value="1m">1m</option>
|
||||||
<option value="5m" selected>5m</option>
|
<option value="5m">5m</option>
|
||||||
<option value="15m">15m</option>
|
<option value="15m">15m</option>
|
||||||
<option value="1h">1h</option>
|
<option value="1h">1h</option>
|
||||||
<option value="4h">4h</option>
|
<option value="4h">4h</option>
|
||||||
<option value="1d">1d</option>
|
<option value="1d" selected>1d</option>
|
||||||
<option value="1w">1w</option>
|
<option value="1w">1w</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<div class="market-ohlcv-overlay" aria-label="K线详情">
|
<div class="market-ohlcv-overlay" aria-label="K线详情">
|
||||||
<div class="market-ohlcv-title">
|
<div class="market-ohlcv-title">
|
||||||
<span id="mkt-symbol-label">—</span>
|
<span id="mkt-symbol-label">—</span>
|
||||||
<span id="mkt-tf-label">5m</span>
|
<span id="mkt-tf-label">1d</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="market-ohlcv-grid">
|
<div class="market-ohlcv-grid">
|
||||||
<div><span class="k">开</span><span id="mkt-o">—</span></div>
|
<div><span class="k">开</span><span id="mkt-o">—</span></div>
|
||||||
@@ -179,7 +179,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-market"></script>
|
<script src="/assets/chart.js?v=20260528-hub-market2"></script>
|
||||||
<script src="/assets/app.js?v=20260528-hub-market"></script>
|
<script src="/assets/app.js?v=20260528-hub-market2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ from hub_kline_store import (
|
|||||||
upsert_bars,
|
upsert_bars,
|
||||||
window_start_ms,
|
window_start_ms,
|
||||||
)
|
)
|
||||||
from hub_ohlcv_lib import TIMEFRAME_MS
|
from hub_ohlcv_lib import TIMEFRAME_MS, chart_fetch_start_ms, window_start_ms
|
||||||
|
|
||||||
|
|
||||||
class TestHubKlineStore(unittest.TestCase):
|
class TestHubKlineStore(unittest.TestCase):
|
||||||
@@ -28,7 +28,18 @@ class TestHubKlineStore(unittest.TestCase):
|
|||||||
|
|
||||||
def test_bar_limits(self):
|
def test_bar_limits(self):
|
||||||
self.assertEqual(bar_limit_for_timeframe("5m"), 1000)
|
self.assertEqual(bar_limit_for_timeframe("5m"), 1000)
|
||||||
|
self.assertEqual(bar_limit_for_timeframe("1h"), 1000)
|
||||||
self.assertEqual(bar_limit_for_timeframe("1d"), 500)
|
self.assertEqual(bar_limit_for_timeframe("1d"), 500)
|
||||||
|
self.assertEqual(bar_limit_for_timeframe("1w"), 500)
|
||||||
|
|
||||||
|
def test_chart_fetch_window_exceeds_retention(self):
|
||||||
|
import time
|
||||||
|
|
||||||
|
now = int(time.time() * 1000)
|
||||||
|
need = bar_limit_for_timeframe("1d")
|
||||||
|
fetch_start = chart_fetch_start_ms("1d", need, now)
|
||||||
|
db_start = window_start_ms("1d", need, retention_days(), now)
|
||||||
|
self.assertLess(fetch_start, db_start)
|
||||||
|
|
||||||
def test_purge_retention(self):
|
def test_purge_retention(self):
|
||||||
import time
|
import time
|
||||||
|
|||||||
Reference in New Issue
Block a user