中控行情区与 K 线本地库(15 天滚动、按需拉取)
新增行情区单图与周期切换,K 线优先读 hub_kline.db,不足时经各实例 /api/hub/ohlcv 补齐;无后台定时更新。含回滚标签说明与单元测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -62,6 +62,10 @@ HUB_TRUST_LAN=true
|
||||
# 为 false 时不拉各实例 /api/price_snapshot(关键位门控简化为「-」,首屏明显更快)
|
||||
# HUB_BOARD_KEY_PRICES=true
|
||||
|
||||
# ---------- 行情区 K 线库(data/hub_kline.db,默认保留 15 天)----------
|
||||
# HUB_KLINE_RETENTION_DAYS=15
|
||||
# HUB_KLINE_DB_PATH=/opt/crypto_monitor/manual_trading_hub/data/hub_kline.db
|
||||
|
||||
# --- 子代理 agent.py(在 crypto_monitor_* 目录启动时另设 EXCHANGE / PORT)---
|
||||
# 与 HUB_BRIDGE_TOKEN 一致时可只设其一;agent 校验请求头 X-Control-Token
|
||||
# CONTROL_TOKEN=your-long-random-token
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 更新前快照(行情区 + K 线库)
|
||||
|
||||
更新前已打 Git 标签,回滚方式:
|
||||
|
||||
```bash
|
||||
cd /opt/crypto_monitor # 或你的仓库路径
|
||||
git fetch --tags
|
||||
git checkout snapshot/pre-hub-market-20260528
|
||||
# 恢复后重启:
|
||||
pm2 restart manual-trading-hub crypto_okx crypto_binance crypto_gate crypto_gate_bot
|
||||
```
|
||||
|
||||
回到最新主线:
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull
|
||||
```
|
||||
|
||||
K 线数据库(不纳入 Git):`manual_trading_hub/data/hub_kline.db`,回滚代码不会自动删除该文件。
|
||||
+119
-1
@@ -13,6 +13,9 @@ _REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
|
||||
from hub_kline_store import format_ohlcv_detail, resolve_chart_bars, retention_days
|
||||
from hub_ohlcv_lib import CHART_TIMEFRAMES, bar_limit_for_timeframe
|
||||
|
||||
from env_load import load_hub_dotenv
|
||||
|
||||
load_hub_dotenv()
|
||||
@@ -66,7 +69,7 @@ _allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower()
|
||||
# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护
|
||||
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
|
||||
DIR = Path(__file__).resolve().parent
|
||||
HUB_BUILD = "20260526-hub-key3col"
|
||||
HUB_BUILD = "20260528-hub-market"
|
||||
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
|
||||
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
|
||||
_board_key_prices_raw = (os.getenv("HUB_BOARD_KEY_PRICES", "true") or "").strip().lower()
|
||||
@@ -258,6 +261,7 @@ def root_redirect():
|
||||
|
||||
|
||||
@app.get("/monitor")
|
||||
@app.get("/market")
|
||||
@app.get("/settings")
|
||||
def shell_pages():
|
||||
return _shell_page()
|
||||
@@ -294,6 +298,120 @@ def api_save_settings(body: SettingsBody):
|
||||
return {"ok": True, "settings": load_settings()}
|
||||
|
||||
|
||||
def _find_exchange_by_key(exchange_key: str) -> dict | None:
|
||||
key = (exchange_key or "").strip().lower()
|
||||
if not key:
|
||||
return None
|
||||
for ex in load_settings().get("exchanges") or []:
|
||||
if str(ex.get("key") or "").strip().lower() == key:
|
||||
return ex
|
||||
if str(ex.get("id") or "").strip() == exchange_key.strip():
|
||||
return ex
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_instance_ohlcv_sync(
|
||||
ex: dict,
|
||||
*,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
since_ms: int | None,
|
||||
limit: int,
|
||||
) -> dict:
|
||||
base = (ex.get("flask_url") or "").rstrip("/")
|
||||
if not base:
|
||||
return {"ok": False, "msg": "未配置 flask_url"}
|
||||
params = {"symbol": symbol, "timeframe": timeframe, "limit": str(int(limit))}
|
||||
if since_ms is not None and int(since_ms) > 0:
|
||||
params["since_ms"] = str(int(since_ms))
|
||||
url = f"{base}/api/hub/ohlcv?{urlencode(params)}"
|
||||
try:
|
||||
with httpx.Client(timeout=HUB_FLASK_TIMEOUT) as client:
|
||||
r = client.get(url, headers=_hub_headers())
|
||||
if r.status_code >= 400:
|
||||
parsed = _parse_http_json_body(r)
|
||||
parsed.setdefault("ok", False)
|
||||
return parsed
|
||||
data = r.json() if r.content else {}
|
||||
return data if isinstance(data, dict) else {"ok": False, "msg": "无效 JSON"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "msg": str(e)}
|
||||
|
||||
|
||||
@app.get("/api/chart/meta")
|
||||
def api_chart_meta():
|
||||
tfs = ["1m", "5m", "15m", "1h", "4h", "1d", "1w"]
|
||||
exchanges = []
|
||||
for ex in enabled_exchanges(load_settings()):
|
||||
exchanges.append(
|
||||
{
|
||||
"id": ex.get("id"),
|
||||
"key": ex.get("key"),
|
||||
"name": ex.get("name"),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"timeframes": [tf for tf in tfs if tf in CHART_TIMEFRAMES],
|
||||
"retention_days": retention_days(),
|
||||
"limits": {tf: bar_limit_for_timeframe(tf) for tf in tfs if tf in CHART_TIMEFRAMES},
|
||||
"exchanges": exchanges,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/chart/ohlcv")
|
||||
def api_chart_ohlcv(
|
||||
exchange_key: str = "",
|
||||
symbol: str = "",
|
||||
timeframe: str = "5m",
|
||||
refresh: str = "",
|
||||
):
|
||||
ex = _find_exchange_by_key(exchange_key)
|
||||
if not ex:
|
||||
raise HTTPException(status_code=400, detail="交易所不存在")
|
||||
if not ex.get("enabled"):
|
||||
raise HTTPException(status_code=400, detail="该交易所未启用")
|
||||
sym = (symbol or "").strip().upper()
|
||||
if not sym:
|
||||
raise HTTPException(status_code=400, detail="请输入币种")
|
||||
ex_key = str(ex.get("key") or "").strip().lower()
|
||||
force = (refresh or "").strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
def remote_fetch(**kwargs):
|
||||
return _fetch_instance_ohlcv_sync(
|
||||
ex,
|
||||
symbol=kwargs.get("symbol") or sym,
|
||||
timeframe=kwargs.get("timeframe") or timeframe,
|
||||
since_ms=kwargs.get("since_ms"),
|
||||
limit=int(kwargs.get("limit") or bar_limit_for_timeframe(timeframe)),
|
||||
)
|
||||
|
||||
result = resolve_chart_bars(
|
||||
ex_key,
|
||||
sym,
|
||||
timeframe,
|
||||
remote_fetch,
|
||||
force_refresh=force,
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("msg") or "K线加载失败")
|
||||
tick = result.get("price_tick")
|
||||
last = result["candles"][-1] if result.get("candles") else None
|
||||
result["ohlcv"] = format_ohlcv_detail(
|
||||
{
|
||||
"open": last.get("open") if last else None,
|
||||
"high": last.get("high") if last else None,
|
||||
"low": last.get("low") if last else None,
|
||||
"close": last.get("close") if last else None,
|
||||
"volume": last.get("volume") if last else None,
|
||||
}
|
||||
if last
|
||||
else None,
|
||||
tick,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/settings/meta")
|
||||
def api_settings_meta():
|
||||
po = public_origin()
|
||||
|
||||
@@ -1920,3 +1920,95 @@ body.login-page {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 行情区 ---------- */
|
||||
.market-toolbar {
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.market-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.market-field select,
|
||||
.market-field input {
|
||||
min-width: 120px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text);
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
.market-status {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.market-status.err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.market-status.warn {
|
||||
color: #ffb84d;
|
||||
}
|
||||
|
||||
.market-chart-wrap {
|
||||
position: relative;
|
||||
height: min(72vh, 640px);
|
||||
min-height: 360px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
background: #0a1018;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.market-chart-host {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.market-ohlcv-overlay {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(8, 14, 24, 0.88);
|
||||
border: 1px solid var(--border-soft);
|
||||
font-size: 0.78rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.market-ohlcv-title {
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.market-ohlcv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4px 14px;
|
||||
}
|
||||
|
||||
.market-ohlcv-grid .k {
|
||||
color: var(--muted);
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.market-ohlcv-grid .market-vol {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@
|
||||
function currentPage() {
|
||||
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
|
||||
if (p.includes("settings")) return "settings";
|
||||
if (p.includes("market")) return "market";
|
||||
return "monitor";
|
||||
}
|
||||
|
||||
@@ -259,6 +260,9 @@
|
||||
if (page === "monitor") startMonitorPoll();
|
||||
else stopMonitorPoll();
|
||||
if (page === "settings") loadSettingsUI();
|
||||
if (page === "market" && window.hubMarketChart) {
|
||||
window.hubMarketChart.init();
|
||||
}
|
||||
}
|
||||
|
||||
function stopMonitorPoll() {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 中控行情区:单图 + 周期切换,数据来自 /api/chart/ohlcv(本地库优先)。
|
||||
*/
|
||||
(function () {
|
||||
const TF_ORDER = ["1m", "5m", "15m", "1h", "4h", "1d", "1w"];
|
||||
const chartHost = document.getElementById("market-chart");
|
||||
if (!chartHost) return;
|
||||
|
||||
const elExchange = document.getElementById("market-exchange");
|
||||
const elSymbol = document.getElementById("market-symbol");
|
||||
const elTf = document.getElementById("market-timeframe");
|
||||
const elRefresh = document.getElementById("market-refresh");
|
||||
const elStatus = document.getElementById("market-status");
|
||||
const elUpdated = document.getElementById("market-updated");
|
||||
const elO = document.getElementById("mkt-o");
|
||||
const elH = document.getElementById("mkt-h");
|
||||
const elL = document.getElementById("mkt-l");
|
||||
const elC = document.getElementById("mkt-c");
|
||||
const elV = document.getElementById("mkt-v");
|
||||
const elSymLabel = document.getElementById("mkt-symbol-label");
|
||||
const elTfLabel = document.getElementById("mkt-tf-label");
|
||||
|
||||
let chart = null;
|
||||
let candleSeries = null;
|
||||
let priceTick = null;
|
||||
let rangeMarkers = [];
|
||||
let lastCandles = [];
|
||||
let chartMeta = null;
|
||||
let loadToken = 0;
|
||||
let marketInited = false;
|
||||
|
||||
function fmtVol(v) {
|
||||
if (v == null || Number.isNaN(Number(v))) return "-";
|
||||
const n = Number(v);
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(2) + "B";
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(2) + "M";
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(2) + "K";
|
||||
return n.toFixed(2);
|
||||
}
|
||||
|
||||
function paintOhlcv(bar) {
|
||||
if (!bar) {
|
||||
["o", "h", "l", "c", "v"].forEach(function (k) {
|
||||
const el = { o: elO, h: elH, l: elL, c: elC, v: elV }[k];
|
||||
if (el) el.textContent = "-";
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (elO) elO.textContent = bar.open != null ? String(bar.open) : "-";
|
||||
if (elH) elH.textContent = bar.high != null ? String(bar.high) : "-";
|
||||
if (elL) elL.textContent = bar.low != null ? String(bar.low) : "-";
|
||||
if (elC) elC.textContent = bar.close != null ? String(bar.close) : "-";
|
||||
if (elV) elV.textContent = fmtVol(bar.volume);
|
||||
}
|
||||
|
||||
function ensureChart() {
|
||||
if (chart && candleSeries) return true;
|
||||
if (!window.LightweightCharts) {
|
||||
if (elStatus) {
|
||||
elStatus.className = "market-status err";
|
||||
elStatus.textContent = "图表库加载失败";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
chart = LightweightCharts.createChart(chartHost, {
|
||||
layout: { background: { color: "#0a1018" }, textColor: "#b8d4e8" },
|
||||
grid: { vertLines: { color: "#1a2838" }, horzLines: { color: "#1a2838" } },
|
||||
rightPriceScale: { borderColor: "#2a4058" },
|
||||
timeScale: { borderColor: "#2a4058", timeVisible: true, secondsVisible: false },
|
||||
crosshair: { mode: LightweightCharts.CrosshairMode ? LightweightCharts.CrosshairMode.Normal : 0 },
|
||||
});
|
||||
const opts = {
|
||||
upColor: "#00ff9d",
|
||||
downColor: "#ff4d6d",
|
||||
borderVisible: false,
|
||||
wickUpColor: "#00ff9d",
|
||||
wickDownColor: "#ff4d6d",
|
||||
};
|
||||
if (typeof chart.addCandlestickSeries === "function") {
|
||||
candleSeries = chart.addCandlestickSeries(opts);
|
||||
} else if (
|
||||
typeof chart.addSeries === "function" &&
|
||||
window.LightweightCharts &&
|
||||
window.LightweightCharts.CandlestickSeries
|
||||
) {
|
||||
candleSeries = chart.addSeries(window.LightweightCharts.CandlestickSeries, opts);
|
||||
}
|
||||
if (!candleSeries) return false;
|
||||
|
||||
chart.subscribeCrosshairMove(function (param) {
|
||||
if (!param || !param.time || !param.seriesData) return;
|
||||
const d = param.seriesData.get(candleSeries);
|
||||
if (!d) return;
|
||||
paintOhlcv({
|
||||
open: d.open,
|
||||
high: d.high,
|
||||
low: d.low,
|
||||
close: d.close,
|
||||
volume: d.volume,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
if (!chart) return;
|
||||
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||
});
|
||||
chart.applyOptions({ width: chartHost.clientWidth, height: chartHost.clientHeight });
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearMarkers() {
|
||||
rangeMarkers.forEach(function (m) {
|
||||
try {
|
||||
candleSeries.removePriceLine(m);
|
||||
} catch (e) {}
|
||||
});
|
||||
rangeMarkers = [];
|
||||
}
|
||||
|
||||
function addRangeMarkers(data) {
|
||||
clearMarkers();
|
||||
if (!candleSeries || !data) return;
|
||||
const hi = data.range_high;
|
||||
const lo = data.range_low;
|
||||
if (hi && hi.price != null) {
|
||||
rangeMarkers.push(
|
||||
candleSeries.createPriceLine({
|
||||
price: Number(hi.price),
|
||||
color: "#ffb84d",
|
||||
lineWidth: 1,
|
||||
lineStyle: 2,
|
||||
axisLabelVisible: true,
|
||||
title: "区间高",
|
||||
})
|
||||
);
|
||||
}
|
||||
if (lo && lo.price != null) {
|
||||
rangeMarkers.push(
|
||||
candleSeries.createPriceLine({
|
||||
price: Number(lo.price),
|
||||
color: "#4cd97f",
|
||||
lineWidth: 1,
|
||||
lineStyle: 2,
|
||||
axisLabelVisible: true,
|
||||
title: "区间低",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readQuery() {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const ex = qs.get("exchange_key") || qs.get("exchange") || "";
|
||||
const sym = qs.get("symbol") || "";
|
||||
const tf = qs.get("timeframe") || "";
|
||||
if (ex && elExchange) elExchange.value = ex;
|
||||
if (sym && elSymbol) elSymbol.value = sym;
|
||||
if (tf && elTf) elTf.value = tf;
|
||||
}
|
||||
|
||||
async function loadMeta() {
|
||||
const r = await fetch("/api/chart/meta", { credentials: "same-origin" });
|
||||
chartMeta = await r.json();
|
||||
if (!elExchange || !chartMeta.exchanges) return;
|
||||
elExchange.innerHTML = "";
|
||||
chartMeta.exchanges.forEach(function (ex) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = ex.key || ex.id;
|
||||
opt.textContent = ex.name || ex.key;
|
||||
elExchange.appendChild(opt);
|
||||
});
|
||||
readQuery();
|
||||
}
|
||||
|
||||
async function loadChart(force) {
|
||||
if (!ensureChart()) return;
|
||||
const exKey = (elExchange && elExchange.value) || "";
|
||||
const sym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||||
const tf = (elTf && elTf.value) || "5m";
|
||||
if (!exKey || !sym) {
|
||||
if (elStatus) {
|
||||
elStatus.className = "market-status err";
|
||||
elStatus.textContent = "请选择交易所并输入币种";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const myToken = ++loadToken;
|
||||
if (elStatus) {
|
||||
elStatus.className = "market-status";
|
||||
elStatus.textContent = "加载中…";
|
||||
}
|
||||
if (elSymLabel) elSymLabel.textContent = sym;
|
||||
if (elTfLabel) elTfLabel.textContent = tf;
|
||||
|
||||
const qs = new URLSearchParams({
|
||||
exchange_key: exKey,
|
||||
symbol: sym,
|
||||
timeframe: tf,
|
||||
});
|
||||
if (force) qs.set("refresh", "1");
|
||||
|
||||
try {
|
||||
const r = await fetch("/api/chart/ohlcv?" + qs.toString(), { credentials: "same-origin" });
|
||||
const data = await r.json();
|
||||
if (myToken !== loadToken) return;
|
||||
if (!r.ok) {
|
||||
throw new Error(data.detail || data.msg || "请求失败");
|
||||
}
|
||||
if (!data.ok || !data.candles || !data.candles.length) {
|
||||
throw new Error(data.msg || "无 K 线");
|
||||
}
|
||||
|
||||
priceTick = data.price_tick;
|
||||
lastCandles = data.candles;
|
||||
candleSeries.setData(data.candles);
|
||||
chart.timeScale().fitContent();
|
||||
addRangeMarkers(data);
|
||||
|
||||
const ohlcv = data.ohlcv || {};
|
||||
paintOhlcv({
|
||||
open: ohlcv.open,
|
||||
high: ohlcv.high,
|
||||
low: ohlcv.low,
|
||||
close: ohlcv.close,
|
||||
volume: ohlcv.volume,
|
||||
});
|
||||
|
||||
let hint =
|
||||
"已加载 " +
|
||||
data.candles.length +
|
||||
" 根(库 " +
|
||||
(data.from_cache || 0) +
|
||||
" / 新拉 " +
|
||||
(data.fetched || 0) +
|
||||
")· 保留 " +
|
||||
(data.retention_days || 15) +
|
||||
" 天";
|
||||
if (data.stale && data.stale_message) {
|
||||
hint += " · 缓存:" + data.stale_message;
|
||||
}
|
||||
if (elStatus) {
|
||||
elStatus.className = data.stale ? "market-status warn" : "market-status";
|
||||
elStatus.textContent = hint;
|
||||
}
|
||||
if (elUpdated) elUpdated.textContent = data.updated_at || "--";
|
||||
} catch (e) {
|
||||
if (myToken !== loadToken) return;
|
||||
if (elStatus) {
|
||||
elStatus.className = "market-status err";
|
||||
elStatus.textContent = String(e.message || e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function bind() {
|
||||
if (elRefresh) {
|
||||
elRefresh.addEventListener("click", function () {
|
||||
loadChart(true);
|
||||
});
|
||||
}
|
||||
if (elTf) {
|
||||
elTf.addEventListener("change", function () {
|
||||
loadChart(false);
|
||||
});
|
||||
}
|
||||
if (elExchange) {
|
||||
elExchange.addEventListener("change", function () {
|
||||
loadChart(false);
|
||||
});
|
||||
}
|
||||
if (elSymbol) {
|
||||
elSymbol.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Enter") loadChart(false);
|
||||
});
|
||||
}
|
||||
const btnLoad = document.getElementById("market-load");
|
||||
if (btnLoad) btnLoad.addEventListener("click", function () {
|
||||
loadChart(false);
|
||||
});
|
||||
}
|
||||
|
||||
window.hubMarketChart = {
|
||||
init: async function () {
|
||||
if (!marketInited) {
|
||||
marketInited = true;
|
||||
await loadMeta();
|
||||
bind();
|
||||
}
|
||||
await loadChart(false);
|
||||
},
|
||||
reload: function (force) {
|
||||
loadChart(!!force);
|
||||
},
|
||||
};
|
||||
|
||||
if (document.getElementById("page-market") && !document.getElementById("page-market").classList.contains("hidden")) {
|
||||
window.hubMarketChart.init();
|
||||
}
|
||||
})();
|
||||
@@ -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=20260530-hub-iframe" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260528-hub-market" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-bg" aria-hidden="true"></div>
|
||||
@@ -25,6 +25,7 @@
|
||||
<span id="sys-status" class="sys-pill" title="系统状态">SYNC</span>
|
||||
<nav class="top-nav">
|
||||
<a href="/monitor" id="nav-monitor">监控区</a>
|
||||
<a href="/market" id="nav-market">行情区</a>
|
||||
<a href="/settings" id="nav-settings">系统设置</a>
|
||||
</nav>
|
||||
<button type="button" id="btn-logout" class="ghost" title="退出登录">退出</button>
|
||||
@@ -56,6 +57,63 @@
|
||||
<div id="monitor-grid" class="grid-monitor"></div>
|
||||
</div>
|
||||
|
||||
<div id="page-market" class="page hidden">
|
||||
<div class="page-head">
|
||||
<h1><span class="head-tag">MKT</span> 行情区</h1>
|
||||
<p class="page-desc">按需拉取 K 线,本地库保留 15 天(无后台自动更新)</p>
|
||||
</div>
|
||||
<details class="hint-box">
|
||||
<summary>数据说明</summary>
|
||||
<div class="hint-body">
|
||||
优先读中控 <code>data/hub_kline.db</code>,不足时向所选交易所实例请求并写入库。<br />
|
||||
日内周期最多 1000 根,日线/周线最多 500 根。仅在本页操作或点「刷新」时拉取交易所。
|
||||
</div>
|
||||
</details>
|
||||
<div class="market-toolbar toolbar">
|
||||
<label class="market-field">
|
||||
<span>交易所</span>
|
||||
<select id="market-exchange"></select>
|
||||
</label>
|
||||
<label class="market-field">
|
||||
<span>币种</span>
|
||||
<input id="market-symbol" type="text" placeholder="TON/USDT" autocomplete="off" />
|
||||
</label>
|
||||
<label class="market-field">
|
||||
<span>周期</span>
|
||||
<select id="market-timeframe">
|
||||
<option value="1m">1m</option>
|
||||
<option value="5m" selected>5m</option>
|
||||
<option value="15m">15m</option>
|
||||
<option value="1h">1h</option>
|
||||
<option value="4h">4h</option>
|
||||
<option value="1d">1d</option>
|
||||
<option value="1w">1w</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" id="market-load" class="primary">加载</button>
|
||||
<button type="button" id="market-refresh" class="ghost">强制刷新</button>
|
||||
<span class="toolbar-spacer"></span>
|
||||
<span id="market-updated" class="toolbar-meta"></span>
|
||||
</div>
|
||||
<p id="market-status" class="market-status"></p>
|
||||
<div class="market-chart-wrap">
|
||||
<div class="market-ohlcv-overlay" aria-label="K线详情">
|
||||
<div class="market-ohlcv-title">
|
||||
<span id="mkt-symbol-label">—</span>
|
||||
<span id="mkt-tf-label">5m</span>
|
||||
</div>
|
||||
<div class="market-ohlcv-grid">
|
||||
<div><span class="k">开</span><span id="mkt-o">—</span></div>
|
||||
<div><span class="k">高</span><span id="mkt-h">—</span></div>
|
||||
<div><span class="k">低</span><span id="mkt-l">—</span></div>
|
||||
<div><span class="k">收</span><span id="mkt-c">—</span></div>
|
||||
<div class="market-vol"><span class="k">量</span><span id="mkt-v">—</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="market-chart" class="market-chart-host"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="instance-frame-shell" class="instance-frame-shell hidden" aria-hidden="true">
|
||||
<div class="instance-frame-toolbar">
|
||||
<button type="button" id="instance-frame-back" class="ghost">← 返回监控</button>
|
||||
@@ -120,6 +178,8 @@
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
<script src="/assets/app.js?v=20260530-hub-embed-sso"></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/app.js?v=20260528-hub-market"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user