feat: market divergence scan tabs (4h/1d/1w) with Top20 MACD confluence colors
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+195
-1
@@ -44,6 +44,17 @@ from lib.hub.hub_volume_rank_lib import (
|
||||
seconds_until_next_reset,
|
||||
volume_rank_reset_hour,
|
||||
)
|
||||
from lib.hub.hub_divergence_scan_lib import (
|
||||
SCAN_TIMEFRAMES,
|
||||
cache_is_stale,
|
||||
chart_candles_to_bars,
|
||||
get_cached_scan,
|
||||
load_scan_cache,
|
||||
merge_exchange_scan,
|
||||
normalize_ohlcv_rows,
|
||||
save_scan_cache,
|
||||
scan_top_symbols,
|
||||
)
|
||||
from lib.hub.hub_symbol_archive_lib import (
|
||||
ARCHIVE_DEFAULT_TIMEFRAME,
|
||||
ARCHIVE_QUOTES_MAX,
|
||||
@@ -179,6 +190,9 @@ _last_archive_sync: dict | None = None
|
||||
_volume_rank_stop: asyncio.Event | None = None
|
||||
_volume_rank_task: asyncio.Task | None = None
|
||||
_volume_rank_cache: dict | None = None
|
||||
_divergence_scan_stop: asyncio.Event | None = None
|
||||
_divergence_scan_task: asyncio.Task | None = None
|
||||
_divergence_scan_cache: dict | None = None
|
||||
_backup_stop: asyncio.Event | None = None
|
||||
_backup_task: asyncio.Task | None = None
|
||||
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
|
||||
@@ -486,6 +500,103 @@ def _refresh_volume_ranks(*, force: bool = False) -> dict:
|
||||
return out
|
||||
|
||||
|
||||
def _get_divergence_scan_cache() -> dict:
|
||||
global _divergence_scan_cache
|
||||
if _divergence_scan_cache is None:
|
||||
_divergence_scan_cache = load_scan_cache()
|
||||
return _divergence_scan_cache
|
||||
|
||||
|
||||
def _refresh_divergence_scans(
|
||||
*,
|
||||
exchange_key: str | None = None,
|
||||
force: bool = False,
|
||||
) -> dict:
|
||||
global _divergence_scan_cache
|
||||
vol_cache = _get_volume_rank_cache()
|
||||
rank_date = vol_cache.get("rank_date") or rank_date_label()
|
||||
cache = _get_divergence_scan_cache()
|
||||
targets = enabled_exchanges(load_settings())
|
||||
if exchange_key:
|
||||
ex_k = str(exchange_key).strip().lower()
|
||||
targets = [ex for ex in targets if str(ex.get("key") or "").strip().lower() == ex_k]
|
||||
errors: list[str] = []
|
||||
scanned_count = 0
|
||||
for ex in targets:
|
||||
ex_key = str(ex.get("key") or "").strip().lower()
|
||||
if not ex_key or not ex.get("enabled"):
|
||||
continue
|
||||
if not force and not cache_is_stale(cache, ex_key, rank_date=rank_date):
|
||||
continue
|
||||
rank_payload = get_cached_rank(vol_cache, ex_key, top_n=TOP_N_DEFAULT)
|
||||
rank_items = []
|
||||
for row in rank_payload.get("items") or []:
|
||||
rank_items.append(
|
||||
{
|
||||
**row,
|
||||
"volume_label": format_volume_quote(row.get("volume_quote")),
|
||||
}
|
||||
)
|
||||
if not rank_items:
|
||||
msg = str(rank_payload.get("error") or "无 Top20 排名数据")
|
||||
errors.append(f"{ex_key}:{msg}")
|
||||
cache = merge_exchange_scan(cache, ex_key, rank_date=rank_date, items=[], error=msg)
|
||||
continue
|
||||
|
||||
ex_ref = ex
|
||||
|
||||
def _fetch_bars(symbol: str, timeframe: str, _ex=ex_ref, _ex_key=ex_key) -> list[dict]:
|
||||
def remote_fetch(**kwargs):
|
||||
tf_use = kwargs.get("timeframe") or timeframe
|
||||
return _fetch_instance_ohlcv_sync(
|
||||
_ex,
|
||||
symbol=kwargs.get("symbol") or symbol,
|
||||
timeframe=tf_use,
|
||||
since_ms=kwargs.get("since_ms"),
|
||||
limit=int(kwargs.get("limit") or chart_initial_limit(tf_use)),
|
||||
)
|
||||
|
||||
result = resolve_chart_bars(
|
||||
_ex_key,
|
||||
symbol,
|
||||
timeframe,
|
||||
remote_fetch,
|
||||
force_refresh=False,
|
||||
limit=chart_initial_limit(timeframe),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
remote = remote_fetch(
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
since_ms=None,
|
||||
limit=chart_initial_limit(timeframe),
|
||||
)
|
||||
return normalize_ohlcv_rows(remote.get("bars") or [])
|
||||
return normalize_ohlcv_rows(result.get("candles") or [])
|
||||
|
||||
try:
|
||||
items = scan_top_symbols(rank_items, _fetch_bars)
|
||||
cache = merge_exchange_scan(
|
||||
cache, ex_key, rank_date=rank_date, items=items, error=None
|
||||
)
|
||||
scanned_count += 1
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
errors.append(f"{ex_key}:{msg}")
|
||||
cache = merge_exchange_scan(cache, ex_key, rank_date=rank_date, items=[], error=msg)
|
||||
save_scan_cache(cache)
|
||||
_divergence_scan_cache = cache
|
||||
out: dict = {
|
||||
"ok": True,
|
||||
"rank_date": rank_date,
|
||||
"scanned_exchanges": scanned_count,
|
||||
"updated_at": cache.get("updated_at"),
|
||||
}
|
||||
if errors:
|
||||
out["errors"] = errors[:8]
|
||||
return out
|
||||
|
||||
|
||||
async def _volume_rank_loop() -> None:
|
||||
global _volume_rank_stop
|
||||
stop = _volume_rank_stop
|
||||
@@ -493,6 +604,7 @@ async def _volume_rank_loop() -> None:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(_refresh_volume_ranks, force=False)
|
||||
await asyncio.to_thread(_refresh_divergence_scans, force=False)
|
||||
except Exception:
|
||||
pass
|
||||
while not stop.is_set():
|
||||
@@ -506,6 +618,30 @@ async def _volume_rank_loop() -> None:
|
||||
break
|
||||
try:
|
||||
await asyncio.to_thread(_refresh_volume_ranks, force=True)
|
||||
await asyncio.to_thread(_refresh_divergence_scans, force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
async def _divergence_scan_loop() -> None:
|
||||
global _divergence_scan_stop
|
||||
stop = _divergence_scan_stop
|
||||
if stop is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(_refresh_divergence_scans, force=False)
|
||||
except Exception:
|
||||
pass
|
||||
while not stop.is_set():
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=3600.0)
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
if stop.is_set():
|
||||
break
|
||||
try:
|
||||
await asyncio.to_thread(_refresh_divergence_scans, force=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -563,7 +699,7 @@ async def _backup_scheduler_loop() -> None:
|
||||
@asynccontextmanager
|
||||
async def _hub_lifespan(_app: FastAPI):
|
||||
global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task
|
||||
global _backup_stop, _backup_task
|
||||
global _backup_stop, _backup_task, _divergence_scan_stop, _divergence_scan_task
|
||||
set_supervisor_notify_hook(supervisor_store.bump)
|
||||
await board_store.start(_run_board_aggregate)
|
||||
await dashboard_store.start(_run_dashboard_aggregate)
|
||||
@@ -573,6 +709,8 @@ async def _hub_lifespan(_app: FastAPI):
|
||||
_archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync")
|
||||
_volume_rank_stop = asyncio.Event()
|
||||
_volume_rank_task = asyncio.create_task(_volume_rank_loop(), name="hub-volume-rank")
|
||||
_divergence_scan_stop = asyncio.Event()
|
||||
_divergence_scan_task = asyncio.create_task(_divergence_scan_loop(), name="hub-divergence-scan")
|
||||
_backup_stop = asyncio.Event()
|
||||
_backup_task = asyncio.create_task(_backup_scheduler_loop(), name="hub-backup-scheduler")
|
||||
try:
|
||||
@@ -608,6 +746,16 @@ async def _hub_lifespan(_app: FastAPI):
|
||||
pass
|
||||
_volume_rank_task = None
|
||||
_volume_rank_stop = None
|
||||
if _divergence_scan_stop:
|
||||
_divergence_scan_stop.set()
|
||||
if _divergence_scan_task:
|
||||
_divergence_scan_task.cancel()
|
||||
try:
|
||||
await _divergence_scan_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_divergence_scan_task = None
|
||||
_divergence_scan_stop = None
|
||||
await chart_poll_store.stop()
|
||||
await supervisor_store.stop()
|
||||
await dashboard_store.stop()
|
||||
@@ -1169,6 +1317,7 @@ def api_chart_meta():
|
||||
"exchanges": exchanges,
|
||||
"volume_rank_top_n": TOP_N_DEFAULT,
|
||||
"volume_rank_reset_hour": volume_rank_reset_hour(),
|
||||
"divergence_scan_tabs": list(SCAN_TIMEFRAMES),
|
||||
}
|
||||
|
||||
|
||||
@@ -1240,6 +1389,51 @@ async def api_chart_volume_rank_refresh():
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/chart/divergence-scan")
|
||||
def api_chart_divergence_scan(
|
||||
exchange_key: str = "",
|
||||
tab: str = "4h",
|
||||
refresh: str = "",
|
||||
):
|
||||
force = (refresh or "").strip().lower() in ("1", "true", "yes", "on")
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
if not ex_k:
|
||||
raise HTTPException(status_code=400, detail="缺少 exchange_key")
|
||||
tab_key = (tab or "4h").strip().lower()
|
||||
if tab_key not in SCAN_TIMEFRAMES:
|
||||
raise HTTPException(status_code=400, detail="tab 须为 4h / 1d / 1w")
|
||||
if force:
|
||||
_refresh_volume_ranks(force=False)
|
||||
result = _refresh_divergence_scans(exchange_key=ex_k, force=True)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("msg") or "扫描失败")
|
||||
else:
|
||||
vol_cache = _get_volume_rank_cache()
|
||||
rank_date = vol_cache.get("rank_date") or rank_date_label()
|
||||
cache = _get_divergence_scan_cache()
|
||||
if cache_is_stale(cache, ex_k, rank_date=rank_date):
|
||||
_refresh_divergence_scans(exchange_key=ex_k, force=True)
|
||||
cache = _get_divergence_scan_cache()
|
||||
payload = get_cached_scan(_get_divergence_scan_cache(), ex_k, tab=tab_key)
|
||||
err = ((payload.get("error") or "") if not payload.get("items") else "")
|
||||
if err and not payload.get("items"):
|
||||
payload["ok"] = False
|
||||
payload["msg"] = err
|
||||
payload["tab_label"] = {"4h": "4h背离", "1d": "日线背离", "1w": "周线背离"}.get(tab_key, tab_key)
|
||||
return payload
|
||||
|
||||
|
||||
@app.post("/api/chart/divergence-scan/refresh")
|
||||
async def api_chart_divergence_scan_refresh(exchange_key: str = ""):
|
||||
ex_k = (exchange_key or "").strip().lower()
|
||||
if not ex_k:
|
||||
raise HTTPException(status_code=400, detail="缺少 exchange_key")
|
||||
result = await asyncio.to_thread(_refresh_divergence_scans, exchange_key=ex_k, force=True)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=502, detail=result.get("msg") or "扫描失败")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/chart/ohlcv")
|
||||
def api_chart_ohlcv(
|
||||
exchange_key: str = "",
|
||||
|
||||
@@ -4066,6 +4066,39 @@ body.login-page {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.market-scan-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.market-scan-tab {
|
||||
flex: 0 0 auto;
|
||||
min-height: 34px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 6px;
|
||||
background: var(--inset-surface);
|
||||
color: var(--muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
font-family: var(--font);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.market-scan-tab:hover {
|
||||
border-color: rgba(0, 255, 157, 0.35);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.market-scan-tab.is-active {
|
||||
border-color: rgba(0, 255, 157, 0.45);
|
||||
background: rgba(0, 255, 157, 0.12);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.market-vol-rank-btn:hover {
|
||||
border-color: rgba(0, 255, 157, 0.35);
|
||||
background: rgba(0, 255, 157, 0.08);
|
||||
@@ -4153,6 +4186,60 @@ body.login-page {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.market-vol-rank-item.confluence-c1 {
|
||||
border-left: 3px solid #6b8cae;
|
||||
}
|
||||
|
||||
.market-vol-rank-item.confluence-c2 {
|
||||
border-left: 3px solid #e6a23c;
|
||||
}
|
||||
|
||||
.market-vol-rank-item.confluence-c3 {
|
||||
border-left: 3px solid #ff4d8d;
|
||||
}
|
||||
|
||||
.market-vol-rank-item.confluence-split {
|
||||
border-left: 3px solid #8a8f98;
|
||||
}
|
||||
|
||||
.market-vol-rank-item.is-div-scan {
|
||||
grid-template-columns: auto 28px 1fr auto;
|
||||
}
|
||||
|
||||
.market-vol-rank-badge {
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.market-vol-rank-badge.confluence-c1 {
|
||||
background: rgba(107, 140, 174, 0.22);
|
||||
color: #9eb8d4;
|
||||
}
|
||||
|
||||
.market-vol-rank-badge.confluence-c2 {
|
||||
background: rgba(230, 162, 60, 0.2);
|
||||
color: #f0c070;
|
||||
}
|
||||
|
||||
.market-vol-rank-badge.confluence-c3 {
|
||||
background: rgba(255, 77, 141, 0.18);
|
||||
color: #ff8cb8;
|
||||
}
|
||||
|
||||
.market-vol-rank-badge.confluence-split {
|
||||
background: rgba(138, 143, 152, 0.22);
|
||||
color: #b8bcc4;
|
||||
}
|
||||
|
||||
.market-vol-rank-div {
|
||||
font-size: 0.68rem;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.market-vol-rank-no {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
@@ -154,11 +154,11 @@
|
||||
const elSymbol = document.getElementById("market-symbol");
|
||||
const elVolRankMeta = document.getElementById("market-vol-rank-meta");
|
||||
const elVolRankList = document.getElementById("market-vol-rank-list");
|
||||
const elVolRankBtn = document.getElementById("market-vol-rank-btn");
|
||||
const elFsVolRankBtn = document.getElementById("market-fs-vol-rank-btn");
|
||||
const elVolRankSheet = document.getElementById("market-vol-rank-sheet");
|
||||
const elVolRankAnchor = document.getElementById("market-vol-rank-anchor");
|
||||
const elVolRankAnchorFs = document.getElementById("market-vol-rank-anchor-fs");
|
||||
let activeScanTab = "top20";
|
||||
let scanSheetOpen = false;
|
||||
const elTf = document.getElementById("market-timeframe");
|
||||
const elRefresh = document.getElementById("market-refresh");
|
||||
const elStatus = document.getElementById("market-status");
|
||||
@@ -2965,6 +2965,19 @@
|
||||
void postChartUnwatch();
|
||||
}
|
||||
|
||||
function allScanTabButtons() {
|
||||
return Array.prototype.slice.call(document.querySelectorAll(".market-scan-tab"));
|
||||
}
|
||||
|
||||
function setActiveScanTab(tab) {
|
||||
activeScanTab = tab || "top20";
|
||||
allScanTabButtons().forEach(function (btn) {
|
||||
const on = btn.getAttribute("data-scan-tab") === activeScanTab;
|
||||
btn.classList.toggle("is-active", on);
|
||||
btn.setAttribute("aria-selected", on ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
function mountVolRankSheet(forFullscreen) {
|
||||
if (!elVolRankSheet) return;
|
||||
const anchor = forFullscreen ? elVolRankAnchorFs : elVolRankAnchor;
|
||||
@@ -2972,40 +2985,142 @@
|
||||
anchor.appendChild(elVolRankSheet);
|
||||
}
|
||||
|
||||
function setVolRankBtnActive(btn, on) {
|
||||
if (!btn) return;
|
||||
btn.classList.toggle("is-active", on);
|
||||
btn.setAttribute("aria-expanded", on ? "true" : "false");
|
||||
}
|
||||
|
||||
function setVolRankSheetOpen(open) {
|
||||
function setScanSheetOpen(open, tab) {
|
||||
const on = !!open;
|
||||
scanSheetOpen = on;
|
||||
if (tab) setActiveScanTab(tab);
|
||||
if (elVolRankSheet) {
|
||||
elVolRankSheet.classList.toggle("hidden", !on);
|
||||
elVolRankSheet.setAttribute("aria-hidden", on ? "false" : "true");
|
||||
}
|
||||
setVolRankBtnActive(elVolRankBtn, on);
|
||||
setVolRankBtnActive(elFsVolRankBtn, on);
|
||||
if (on) void loadVolumeRank();
|
||||
if (on) void loadScanPanel(false);
|
||||
}
|
||||
|
||||
function loadScanPanel(forceRefresh) {
|
||||
if (activeScanTab === "top20") {
|
||||
void loadVolumeRank(forceRefresh);
|
||||
return;
|
||||
}
|
||||
void loadDivergenceScan(activeScanTab, forceRefresh);
|
||||
}
|
||||
|
||||
function bindVolRankPanel() {
|
||||
function toggleVolRankSheet() {
|
||||
const open = elVolRankSheet && elVolRankSheet.classList.contains("hidden");
|
||||
setVolRankSheetOpen(open);
|
||||
}
|
||||
if (elVolRankBtn) elVolRankBtn.addEventListener("click", toggleVolRankSheet);
|
||||
if (elFsVolRankBtn) elFsVolRankBtn.addEventListener("click", toggleVolRankSheet);
|
||||
allScanTabButtons().forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
const tab = btn.getAttribute("data-scan-tab") || "top20";
|
||||
if (scanSheetOpen && activeScanTab === tab) {
|
||||
setScanSheetOpen(false);
|
||||
return;
|
||||
}
|
||||
setScanSheetOpen(true, tab);
|
||||
});
|
||||
});
|
||||
document.addEventListener("pointerdown", function (ev) {
|
||||
if (!elVolRankSheet || elVolRankSheet.classList.contains("hidden")) return;
|
||||
const t = ev.target;
|
||||
if (elVolRankSheet.contains(t)) return;
|
||||
if (elVolRankBtn && elVolRankBtn.contains(t)) return;
|
||||
if (elFsVolRankBtn && elFsVolRankBtn.contains(t)) return;
|
||||
setVolRankSheetOpen(false);
|
||||
if (t && t.closest && t.closest(".market-scan-tabs")) return;
|
||||
setScanSheetOpen(false);
|
||||
});
|
||||
}
|
||||
|
||||
function applyScanSymbolSelection(symbol, tabTf) {
|
||||
if (!symbol) return;
|
||||
if (elSymbol) elSymbol.value = symbol;
|
||||
if (elFsSymbol) elFsSymbol.value = symbol;
|
||||
if (tabTf && tabTf !== "top20") {
|
||||
if (elTf) elTf.value = tabTf;
|
||||
if (elFsTf) elFsTf.value = tabTf;
|
||||
if (elIndMacd) elIndMacd.checked = true;
|
||||
indicatorState.macd = true;
|
||||
}
|
||||
setScanSheetOpen(false);
|
||||
loadChart(false);
|
||||
}
|
||||
|
||||
function renderDivergenceScan(data) {
|
||||
if (!elVolRankMeta || !elVolRankList) return;
|
||||
elVolRankList.innerHTML = "";
|
||||
const tabLabel = (data && data.tab_label) || "背离";
|
||||
if (!data || !data.ok || !data.items || !data.items.length) {
|
||||
elVolRankMeta.textContent =
|
||||
(data && data.msg) ||
|
||||
tabLabel + ":Top20 内暂无 MACD 背离(可点「清库重拉」后重试扫描)";
|
||||
return;
|
||||
}
|
||||
const rankDate = data.rank_date || "—";
|
||||
const updated = data.scanned_at || data.updated_at || "—";
|
||||
let meta =
|
||||
tabLabel +
|
||||
" · Top20 内 MACD 档A · 交易日 " +
|
||||
rankDate +
|
||||
" · 扫描 " +
|
||||
updated +
|
||||
" · " +
|
||||
data.items.length +
|
||||
" 条";
|
||||
elVolRankMeta.textContent = meta;
|
||||
const curSym = (elSymbol && elSymbol.value.trim().toUpperCase()) || "";
|
||||
const tabTf = data.tab || activeScanTab;
|
||||
data.items.forEach(function (row) {
|
||||
const li = document.createElement("li");
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
const css = row.confluence_css || "none";
|
||||
btn.className = "market-vol-rank-item is-div-scan confluence-" + css;
|
||||
if (row.symbol && row.symbol.toUpperCase() === curSym) {
|
||||
btn.classList.add("is-active");
|
||||
}
|
||||
btn.dataset.symbol = row.symbol || "";
|
||||
const dirLabel = row.is_split
|
||||
? "分歧"
|
||||
: row.tab_direction_label || row.direction_label || "";
|
||||
const confLabel = row.is_split ? row.split_detail || "分歧" : row.confluence_kind || "";
|
||||
const fresh = row.tab_freshness || "";
|
||||
btn.innerHTML =
|
||||
'<span class="market-vol-rank-badge confluence-' +
|
||||
css +
|
||||
'">' +
|
||||
(confLabel || "—") +
|
||||
'</span><span class="market-vol-rank-no">' +
|
||||
(row.rank || "") +
|
||||
'</span><span class="market-vol-rank-sym">' +
|
||||
(row.symbol || "") +
|
||||
'</span><span class="market-vol-rank-div">' +
|
||||
dirLabel +
|
||||
(fresh ? " · " + fresh : "") +
|
||||
"</span>";
|
||||
btn.addEventListener("click", function () {
|
||||
applyScanSymbolSelection(row.symbol, tabTf);
|
||||
});
|
||||
li.appendChild(btn);
|
||||
elVolRankList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadDivergenceScan(tab, forceRefresh) {
|
||||
const exKey = (elExchange && elExchange.value) || "";
|
||||
if (!exKey || !elVolRankMeta) return;
|
||||
elVolRankMeta.textContent = "扫描背离…";
|
||||
if (elVolRankList) elVolRankList.innerHTML = "";
|
||||
try {
|
||||
let url =
|
||||
"/api/chart/divergence-scan?exchange_key=" +
|
||||
encodeURIComponent(exKey) +
|
||||
"&tab=" +
|
||||
encodeURIComponent(tab || "4h");
|
||||
if (forceRefresh) url += "&refresh=1";
|
||||
const r = await fetch(url, { credentials: "same-origin" });
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
throw new Error((data && data.detail) || (data && data.msg) || "加载失败");
|
||||
}
|
||||
renderDivergenceScan(data);
|
||||
} catch (e) {
|
||||
renderDivergenceScan({ ok: false, msg: String(e.message || e), tab_label: tab });
|
||||
}
|
||||
}
|
||||
|
||||
function renderVolumeRank(data) {
|
||||
if (!elVolRankMeta || !elVolRankList) return;
|
||||
elVolRankList.innerHTML = "";
|
||||
@@ -3056,11 +3171,7 @@
|
||||
(row.volume_label || "") +
|
||||
"</span>";
|
||||
btn.addEventListener("click", function () {
|
||||
if (!row.symbol) return;
|
||||
if (elSymbol) elSymbol.value = row.symbol;
|
||||
if (elFsSymbol) elFsSymbol.value = row.symbol;
|
||||
setVolRankSheetOpen(false);
|
||||
loadChart(false);
|
||||
applyScanSymbolSelection(row.symbol, "top20");
|
||||
});
|
||||
li.appendChild(btn);
|
||||
elVolRankList.appendChild(li);
|
||||
|
||||
@@ -258,16 +258,12 @@
|
||||
<span>币种</span>
|
||||
<div class="market-symbol-wrap">
|
||||
<input id="market-symbol" type="text" value="BTC/USDT" placeholder="BTC/USDT" autocomplete="off" />
|
||||
<button
|
||||
type="button"
|
||||
id="market-vol-rank-btn"
|
||||
class="market-vol-rank-btn"
|
||||
title="昨日成交额 Top20(每早8点更新)"
|
||||
aria-expanded="false"
|
||||
aria-controls="market-vol-rank-sheet"
|
||||
>
|
||||
Top20
|
||||
</button>
|
||||
<div class="market-scan-tabs" role="tablist" aria-label="成交额与背离筛选">
|
||||
<button type="button" class="market-scan-tab is-active" data-scan-tab="top20" aria-selected="true">Top20</button>
|
||||
<button type="button" class="market-scan-tab" data-scan-tab="4h" aria-selected="false">4h背离</button>
|
||||
<button type="button" class="market-scan-tab" data-scan-tab="1d" aria-selected="false">日线背离</button>
|
||||
<button type="button" class="market-scan-tab" data-scan-tab="1w" aria-selected="false">周线背离</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="market-field">
|
||||
@@ -342,16 +338,12 @@
|
||||
<span>币种</span>
|
||||
<div class="market-symbol-wrap">
|
||||
<input id="market-fs-symbol" type="text" placeholder="BTC/USDT" autocomplete="off" />
|
||||
<button
|
||||
type="button"
|
||||
id="market-fs-vol-rank-btn"
|
||||
class="market-vol-rank-btn"
|
||||
title="昨日成交额 Top20(每早8点更新)"
|
||||
aria-expanded="false"
|
||||
aria-controls="market-vol-rank-sheet"
|
||||
>
|
||||
Top20
|
||||
</button>
|
||||
<div class="market-scan-tabs" role="tablist" aria-label="成交额与背离筛选">
|
||||
<button type="button" class="market-scan-tab is-active" data-scan-tab="top20" aria-selected="true">Top20</button>
|
||||
<button type="button" class="market-scan-tab" data-scan-tab="4h" aria-selected="false">4h背离</button>
|
||||
<button type="button" class="market-scan-tab" data-scan-tab="1d" aria-selected="false">日线背离</button>
|
||||
<button type="button" class="market-scan-tab" data-scan-tab="1w" aria-selected="false">周线背离</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="market-field market-fs-field">
|
||||
@@ -1148,7 +1140,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_draw.js?v=20260609-market-day-split"></script>
|
||||
<script src="/assets/chart.js?v=20260706-ema144"></script>
|
||||
<script src="/assets/chart.js?v=20260706-div-scan"></script>
|
||||
<script src="/assets/plan.js?v=20260614-plan-refresh"></script>
|
||||
<script src="/assets/calculator.js?v=3"></script>
|
||||
<script src="/assets/trade_stats_calendar.js?v=3"></script>
|
||||
|
||||
Reference in New Issue
Block a user