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:
@@ -38,6 +38,7 @@ HUB_DATA_FILES = (
|
|||||||
"hub_entry_plans.db",
|
"hub_entry_plans.db",
|
||||||
"hub_macro_calendar.db",
|
"hub_macro_calendar.db",
|
||||||
"hub_volume_rank.json",
|
"hub_volume_rank.json",
|
||||||
|
"hub_divergence_scan.json",
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_BACKUP_SETTINGS = {
|
DEFAULT_BACKUP_SETTINGS = {
|
||||||
|
|||||||
@@ -0,0 +1,465 @@
|
|||||||
|
"""行情区:Top20 内 MACD 背离扫描(档 A)+ 4h/日线/周线共振。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, Mapping, Sequence
|
||||||
|
|
||||||
|
from lib.hub.hub_volume_rank_lib import TOP_N_DEFAULT, get_cached_rank, volume_rank_timezone
|
||||||
|
|
||||||
|
SCAN_CACHE_VERSION = 1
|
||||||
|
SCAN_TIMEFRAMES: tuple[str, ...] = ("4h", "1d", "1w")
|
||||||
|
SWING_LOOKBACK = 4
|
||||||
|
SWING_ALIGN_BARS = 30
|
||||||
|
RECENCY_BARS = 60
|
||||||
|
MACD_FAST = 12
|
||||||
|
MACD_SLOW = 26
|
||||||
|
MACD_SIGNAL = 9
|
||||||
|
|
||||||
|
TAB_LABELS: dict[str, str] = {
|
||||||
|
"4h": "4h背离",
|
||||||
|
"1d": "日线背离",
|
||||||
|
"1w": "周线背离",
|
||||||
|
}
|
||||||
|
|
||||||
|
TF_SHORT: dict[str, str] = {"4h": "4h", "1d": "日线", "1w": "周线"}
|
||||||
|
|
||||||
|
|
||||||
|
def default_cache_path() -> Path:
|
||||||
|
from lib.paths import hub_data_dir
|
||||||
|
|
||||||
|
return hub_data_dir() / "hub_divergence_scan.json"
|
||||||
|
|
||||||
|
|
||||||
|
def ema_array(values: Sequence[float | None], period: int) -> list[float | None]:
|
||||||
|
out: list[float | None] = [None] * len(values)
|
||||||
|
if period <= 0 or len(values) < period:
|
||||||
|
return out
|
||||||
|
k = 2.0 / (period + 1)
|
||||||
|
sma = sum(v for v in values[:period] if v is not None) / period
|
||||||
|
out[period - 1] = sma
|
||||||
|
prev = sma
|
||||||
|
for i in range(period, len(values)):
|
||||||
|
v = values[i]
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
prev = v * k + prev * (1 - k)
|
||||||
|
out[i] = prev
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def find_swings(values: Sequence[float | None], lookback: int) -> tuple[list[dict], list[dict]]:
|
||||||
|
lows: list[dict] = []
|
||||||
|
highs: list[dict] = []
|
||||||
|
lb = max(1, int(lookback))
|
||||||
|
n = len(values)
|
||||||
|
for i in range(lb, n - lb):
|
||||||
|
v = values[i]
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
is_low = True
|
||||||
|
is_high = True
|
||||||
|
for j in range(1, lb + 1):
|
||||||
|
lv = values[i - j]
|
||||||
|
rv = values[i + j]
|
||||||
|
if lv is None or rv is None or v > lv or v > rv:
|
||||||
|
is_low = False
|
||||||
|
if lv is None or rv is None or v < lv or v < rv:
|
||||||
|
is_high = False
|
||||||
|
if is_low:
|
||||||
|
lows.append({"i": i, "v": float(v)})
|
||||||
|
if is_high:
|
||||||
|
highs.append({"i": i, "v": float(v)})
|
||||||
|
return lows, highs
|
||||||
|
|
||||||
|
|
||||||
|
def build_macd_by_index(closes: Sequence[float]) -> list[float | None]:
|
||||||
|
ema12 = ema_array(closes, MACD_FAST)
|
||||||
|
ema26 = ema_array(closes, MACD_SLOW)
|
||||||
|
macd: list[float | None] = [None] * len(closes)
|
||||||
|
for i in range(len(closes)):
|
||||||
|
if ema12[i] is not None and ema26[i] is not None:
|
||||||
|
macd[i] = ema12[i] - ema26[i]
|
||||||
|
return macd
|
||||||
|
|
||||||
|
|
||||||
|
def detect_latest_macd_divergence(
|
||||||
|
closes: Sequence[float],
|
||||||
|
*,
|
||||||
|
swing_lookback: int = SWING_LOOKBACK,
|
||||||
|
align_bars: int = SWING_ALIGN_BARS,
|
||||||
|
recency_bars: int = RECENCY_BARS,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""档 A:最近一对摆动 MACD 顶/底背离(与 chart.js detectDivergences 同类)。"""
|
||||||
|
if len(closes) < swing_lookback * 2 + 10:
|
||||||
|
return {"direction": None}
|
||||||
|
macd = build_macd_by_index(closes)
|
||||||
|
p_lows, p_highs = find_swings(closes, swing_lookback)
|
||||||
|
i_lows, i_highs = find_swings(macd, swing_lookback)
|
||||||
|
|
||||||
|
def recent_enough(idx: int) -> bool:
|
||||||
|
return idx >= max(0, len(closes) - recency_bars)
|
||||||
|
|
||||||
|
if len(p_lows) >= 2 and len(i_lows) >= 2:
|
||||||
|
p1, p2 = p_lows[-2], p_lows[-1]
|
||||||
|
i1, i2 = i_lows[-2], i_lows[-1]
|
||||||
|
if (
|
||||||
|
abs(p1["i"] - i1["i"]) < align_bars
|
||||||
|
and abs(p2["i"] - i2["i"]) < align_bars
|
||||||
|
and p2["v"] < p1["v"]
|
||||||
|
and i2["v"] > i1["v"]
|
||||||
|
and recent_enough(p2["i"])
|
||||||
|
):
|
||||||
|
return {"direction": "bull", "bar_index": p2["i"]}
|
||||||
|
|
||||||
|
if len(p_highs) >= 2 and len(i_highs) >= 2:
|
||||||
|
p1, p2 = p_highs[-2], p_highs[-1]
|
||||||
|
i1, i2 = i_highs[-2], i_highs[-1]
|
||||||
|
if (
|
||||||
|
abs(p1["i"] - i1["i"]) < align_bars
|
||||||
|
and abs(p2["i"] - i2["i"]) < align_bars
|
||||||
|
and p2["v"] > p1["v"]
|
||||||
|
and i2["v"] < i1["v"]
|
||||||
|
and recent_enough(p2["i"])
|
||||||
|
):
|
||||||
|
return {"direction": "bear", "bar_index": p2["i"]}
|
||||||
|
|
||||||
|
return {"direction": None}
|
||||||
|
|
||||||
|
|
||||||
|
def chart_candles_to_bars(candles: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for c in candles:
|
||||||
|
try:
|
||||||
|
t = c.get("time")
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
ms = int(t) * 1000 if int(t) < 10_000_000_000 else int(t)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"open_time_ms": ms,
|
||||||
|
"open": float(c["open"]),
|
||||||
|
"high": float(c["high"]),
|
||||||
|
"low": float(c["low"]),
|
||||||
|
"close": float(c["close"]),
|
||||||
|
"volume": float(c.get("volume") or 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_ohlcv_rows(rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
if not rows:
|
||||||
|
return []
|
||||||
|
first = rows[0]
|
||||||
|
if first.get("open_time_ms") is not None:
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
return chart_candles_to_bars(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def bars_to_closes(bars: Sequence[Mapping[str, Any]], *, exclude_open: bool = True) -> list[float]:
|
||||||
|
rows = list(bars)
|
||||||
|
if exclude_open and len(rows) > 1:
|
||||||
|
rows = rows[:-1]
|
||||||
|
out: list[float] = []
|
||||||
|
for b in rows:
|
||||||
|
try:
|
||||||
|
out.append(float(b["close"]))
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def bar_time_at(bars: Sequence[Mapping[str, Any]], index: int) -> int | None:
|
||||||
|
if index < 0 or index >= len(bars):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(bars[index]["open_time_ms"])
|
||||||
|
except (KeyError, TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_ohlcv_bars(bars: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||||
|
closed = list(bars)
|
||||||
|
if len(closed) > 1:
|
||||||
|
closed = closed[:-1]
|
||||||
|
closes = bars_to_closes(bars, exclude_open=True)
|
||||||
|
hit = detect_latest_macd_divergence(closes)
|
||||||
|
direction = hit.get("direction")
|
||||||
|
bar_index = hit.get("bar_index")
|
||||||
|
open_time_ms = None
|
||||||
|
if direction and bar_index is not None:
|
||||||
|
open_time_ms = bar_time_at(closed, int(bar_index))
|
||||||
|
bars_ago = None
|
||||||
|
if direction and bar_index is not None:
|
||||||
|
bars_ago = max(0, len(closed) - 1 - int(bar_index))
|
||||||
|
return {
|
||||||
|
"direction": direction,
|
||||||
|
"bar_index": bar_index,
|
||||||
|
"open_time_ms": open_time_ms,
|
||||||
|
"bars_ago": bars_ago,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_confluence(tf_hits: Mapping[str, Mapping[str, Any]]) -> dict[str, Any]:
|
||||||
|
dirs: dict[str, str] = {}
|
||||||
|
for tf in SCAN_TIMEFRAMES:
|
||||||
|
d = (tf_hits.get(tf) or {}).get("direction")
|
||||||
|
if d in ("bull", "bear"):
|
||||||
|
dirs[tf] = d
|
||||||
|
|
||||||
|
if not dirs:
|
||||||
|
return {
|
||||||
|
"confluence": 0,
|
||||||
|
"confluence_kind": "none",
|
||||||
|
"confluence_css": "none",
|
||||||
|
"is_split": False,
|
||||||
|
"split_detail": "",
|
||||||
|
"primary_direction": None,
|
||||||
|
"direction_label": "",
|
||||||
|
"timeframes_hit": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
unique = set(dirs.values())
|
||||||
|
if len(unique) > 1:
|
||||||
|
parts = []
|
||||||
|
for tf in SCAN_TIMEFRAMES:
|
||||||
|
if tf in dirs:
|
||||||
|
label = "底" if dirs[tf] == "bull" else "顶"
|
||||||
|
parts.append(f"{TF_SHORT.get(tf, tf)}{label}")
|
||||||
|
return {
|
||||||
|
"confluence": 0,
|
||||||
|
"confluence_kind": "分歧",
|
||||||
|
"confluence_css": "split",
|
||||||
|
"is_split": True,
|
||||||
|
"split_detail": " · ".join(parts),
|
||||||
|
"primary_direction": _latest_direction(tf_hits),
|
||||||
|
"direction_label": "分歧",
|
||||||
|
"timeframes_hit": list(dirs.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
direction = next(iter(unique))
|
||||||
|
count = len(dirs)
|
||||||
|
return {
|
||||||
|
"confluence": count,
|
||||||
|
"confluence_kind": f"{count}周期",
|
||||||
|
"confluence_css": f"c{count}",
|
||||||
|
"is_split": False,
|
||||||
|
"split_detail": "",
|
||||||
|
"primary_direction": direction,
|
||||||
|
"direction_label": "底背离" if direction == "bull" else "顶背离",
|
||||||
|
"timeframes_hit": list(dirs.keys()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_direction(tf_hits: Mapping[str, Mapping[str, Any]]) -> str | None:
|
||||||
|
best_tf = None
|
||||||
|
best_ms = -1
|
||||||
|
for tf in SCAN_TIMEFRAMES:
|
||||||
|
row = tf_hits.get(tf) or {}
|
||||||
|
d = row.get("direction")
|
||||||
|
ms = row.get("open_time_ms")
|
||||||
|
if d not in ("bull", "bear") or ms is None:
|
||||||
|
continue
|
||||||
|
if int(ms) > best_ms:
|
||||||
|
best_ms = int(ms)
|
||||||
|
best_tf = tf
|
||||||
|
if best_tf is None:
|
||||||
|
return None
|
||||||
|
return (tf_hits.get(best_tf) or {}).get("direction")
|
||||||
|
|
||||||
|
|
||||||
|
def freshness_label(timeframe: str, bars_ago: int | None) -> str:
|
||||||
|
if bars_ago is None:
|
||||||
|
return ""
|
||||||
|
n = int(bars_ago)
|
||||||
|
if timeframe == "1w":
|
||||||
|
return f"{n}周前" if n else "本周"
|
||||||
|
if n <= 0:
|
||||||
|
return "当根"
|
||||||
|
return f"{n}根K前"
|
||||||
|
|
||||||
|
|
||||||
|
def build_symbol_scan_row(
|
||||||
|
*,
|
||||||
|
rank: int,
|
||||||
|
symbol: str,
|
||||||
|
volume_label: str,
|
||||||
|
tf_hits: Mapping[str, Mapping[str, Any]],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
conf = compute_confluence(tf_hits)
|
||||||
|
tf_map = {tf: (tf_hits.get(tf) or {}).get("direction") for tf in SCAN_TIMEFRAMES}
|
||||||
|
return {
|
||||||
|
"rank": rank,
|
||||||
|
"symbol": symbol,
|
||||||
|
"volume_label": volume_label,
|
||||||
|
"direction": conf.get("primary_direction"),
|
||||||
|
"direction_label": conf.get("direction_label") or "",
|
||||||
|
"confluence": conf.get("confluence") or 0,
|
||||||
|
"confluence_kind": conf.get("confluence_kind") or "none",
|
||||||
|
"confluence_css": conf.get("confluence_css") or "none",
|
||||||
|
"is_split": bool(conf.get("is_split")),
|
||||||
|
"split_detail": conf.get("split_detail") or "",
|
||||||
|
"timeframes": tf_map,
|
||||||
|
"tf_detail": {
|
||||||
|
tf: {
|
||||||
|
"direction": (tf_hits.get(tf) or {}).get("direction"),
|
||||||
|
"open_time_ms": (tf_hits.get(tf) or {}).get("open_time_ms"),
|
||||||
|
"bars_ago": (tf_hits.get(tf) or {}).get("bars_ago"),
|
||||||
|
"freshness": freshness_label(tf, (tf_hits.get(tf) or {}).get("bars_ago")),
|
||||||
|
}
|
||||||
|
for tf in SCAN_TIMEFRAMES
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def filter_tab_items(items: Sequence[Mapping[str, Any]], tab: str) -> list[dict[str, Any]]:
|
||||||
|
tab = (tab or "").strip().lower()
|
||||||
|
if tab not in SCAN_TIMEFRAMES:
|
||||||
|
return [dict(x) for x in items]
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for row in items:
|
||||||
|
tf = (row.get("tf_detail") or {}).get(tab) or {}
|
||||||
|
if tf.get("direction") not in ("bull", "bear"):
|
||||||
|
continue
|
||||||
|
item = dict(row)
|
||||||
|
item["tab_timeframe"] = tab
|
||||||
|
item["tab_direction"] = tf.get("direction")
|
||||||
|
item["tab_direction_label"] = "底背离" if tf.get("direction") == "bull" else "顶背离"
|
||||||
|
item["tab_freshness"] = tf.get("freshness") or ""
|
||||||
|
item["tab_open_time_ms"] = tf.get("open_time_ms")
|
||||||
|
out.append(item)
|
||||||
|
out.sort(
|
||||||
|
key=lambda x: (
|
||||||
|
-1 if x.get("is_split") else int(x.get("confluence") or 0),
|
||||||
|
int(x.get("rank") or 999),
|
||||||
|
),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_scan_cache(path: Path | None = None) -> dict[str, Any]:
|
||||||
|
p = path or default_cache_path()
|
||||||
|
if not p.is_file():
|
||||||
|
return {"version": SCAN_CACHE_VERSION, "exchanges": {}}
|
||||||
|
try:
|
||||||
|
data = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return {"version": SCAN_CACHE_VERSION, "exchanges": {}}
|
||||||
|
if int(data.get("version") or 0) < SCAN_CACHE_VERSION:
|
||||||
|
return {"version": SCAN_CACHE_VERSION, "exchanges": {}}
|
||||||
|
data.setdefault("version", SCAN_CACHE_VERSION)
|
||||||
|
data.setdefault("exchanges", {})
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
return {"version": SCAN_CACHE_VERSION, "exchanges": {}}
|
||||||
|
|
||||||
|
|
||||||
|
def save_scan_cache(data: dict[str, Any], path: Path | None = None) -> None:
|
||||||
|
p = path or default_cache_path()
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = dict(data)
|
||||||
|
payload["version"] = SCAN_CACHE_VERSION
|
||||||
|
payload["updated_at"] = datetime.now(volume_rank_timezone()).isoformat(timespec="seconds")
|
||||||
|
p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def merge_exchange_scan(
|
||||||
|
cache: dict[str, Any],
|
||||||
|
exchange_key: str,
|
||||||
|
*,
|
||||||
|
rank_date: str | None,
|
||||||
|
items: list[dict[str, Any]],
|
||||||
|
error: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
ex_k = str(exchange_key or "").strip().lower()
|
||||||
|
exchanges = dict(cache.get("exchanges") or {})
|
||||||
|
exchanges[ex_k] = {
|
||||||
|
"rank_date": rank_date,
|
||||||
|
"items": items,
|
||||||
|
"error": error,
|
||||||
|
"scanned_at": datetime.now(volume_rank_timezone()).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
out = dict(cache)
|
||||||
|
out["exchanges"] = exchanges
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_scan(
|
||||||
|
cache: dict[str, Any],
|
||||||
|
exchange_key: str,
|
||||||
|
*,
|
||||||
|
tab: str = "4h",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
ex_k = str(exchange_key or "").strip().lower()
|
||||||
|
ex_data = (cache.get("exchanges") or {}).get(ex_k) or {}
|
||||||
|
all_items = list(ex_data.get("items") or [])
|
||||||
|
tab_key = (tab or "4h").strip().lower()
|
||||||
|
items = filter_tab_items(all_items, tab_key) if tab_key in SCAN_TIMEFRAMES else all_items
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"exchange_key": ex_k,
|
||||||
|
"tab": tab_key,
|
||||||
|
"rank_date": ex_data.get("rank_date"),
|
||||||
|
"updated_at": cache.get("updated_at"),
|
||||||
|
"scanned_at": ex_data.get("scanned_at"),
|
||||||
|
"items": items,
|
||||||
|
"item_count": len(items),
|
||||||
|
"error": ex_data.get("error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def scan_top_symbols(
|
||||||
|
rank_items: Sequence[Mapping[str, Any]],
|
||||||
|
fetch_bars: Callable[[str, str], Sequence[Mapping[str, Any]]],
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""对 Top N 币种扫描三周期背离。fetch_bars(symbol, timeframe) -> OHLCV rows。"""
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for row in rank_items:
|
||||||
|
symbol = str(row.get("symbol") or "").strip().upper()
|
||||||
|
if not symbol:
|
||||||
|
continue
|
||||||
|
tf_hits: dict[str, dict[str, Any]] = {}
|
||||||
|
for tf in SCAN_TIMEFRAMES:
|
||||||
|
try:
|
||||||
|
bars = fetch_bars(symbol, tf)
|
||||||
|
tf_hits[tf] = analyze_ohlcv_bars(bars)
|
||||||
|
except Exception:
|
||||||
|
tf_hits[tf] = {"direction": None}
|
||||||
|
out.append(
|
||||||
|
build_symbol_scan_row(
|
||||||
|
rank=int(row.get("rank") or 0),
|
||||||
|
symbol=symbol,
|
||||||
|
volume_label=str(row.get("volume_label") or row.get("volume_quote") or ""),
|
||||||
|
tf_hits=tf_hits,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def cache_is_stale(
|
||||||
|
cache: dict[str, Any],
|
||||||
|
exchange_key: str,
|
||||||
|
*,
|
||||||
|
rank_date: str | None,
|
||||||
|
max_age_sec: float = 3600.0,
|
||||||
|
) -> bool:
|
||||||
|
ex_k = str(exchange_key or "").strip().lower()
|
||||||
|
ex_data = (cache.get("exchanges") or {}).get(ex_k) or {}
|
||||||
|
if not ex_data.get("items") and not ex_data.get("error"):
|
||||||
|
return True
|
||||||
|
if rank_date and ex_data.get("rank_date") != rank_date:
|
||||||
|
return True
|
||||||
|
updated = cache.get("updated_at") or ex_data.get("scanned_at")
|
||||||
|
if not updated:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(str(updated))
|
||||||
|
age = (datetime.now(dt.tzinfo) - dt).total_seconds()
|
||||||
|
return age > max_age_sec
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
+195
-1
@@ -44,6 +44,17 @@ from lib.hub.hub_volume_rank_lib import (
|
|||||||
seconds_until_next_reset,
|
seconds_until_next_reset,
|
||||||
volume_rank_reset_hour,
|
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 (
|
from lib.hub.hub_symbol_archive_lib import (
|
||||||
ARCHIVE_DEFAULT_TIMEFRAME,
|
ARCHIVE_DEFAULT_TIMEFRAME,
|
||||||
ARCHIVE_QUOTES_MAX,
|
ARCHIVE_QUOTES_MAX,
|
||||||
@@ -179,6 +190,9 @@ _last_archive_sync: dict | None = None
|
|||||||
_volume_rank_stop: asyncio.Event | None = None
|
_volume_rank_stop: asyncio.Event | None = None
|
||||||
_volume_rank_task: asyncio.Task | None = None
|
_volume_rank_task: asyncio.Task | None = None
|
||||||
_volume_rank_cache: dict | 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_stop: asyncio.Event | None = None
|
||||||
_backup_task: asyncio.Task | None = None
|
_backup_task: asyncio.Task | None = None
|
||||||
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
|
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
|
||||||
@@ -486,6 +500,103 @@ def _refresh_volume_ranks(*, force: bool = False) -> dict:
|
|||||||
return out
|
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:
|
async def _volume_rank_loop() -> None:
|
||||||
global _volume_rank_stop
|
global _volume_rank_stop
|
||||||
stop = _volume_rank_stop
|
stop = _volume_rank_stop
|
||||||
@@ -493,6 +604,7 @@ async def _volume_rank_loop() -> None:
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_refresh_volume_ranks, force=False)
|
await asyncio.to_thread(_refresh_volume_ranks, force=False)
|
||||||
|
await asyncio.to_thread(_refresh_divergence_scans, force=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
while not stop.is_set():
|
while not stop.is_set():
|
||||||
@@ -506,6 +618,30 @@ async def _volume_rank_loop() -> None:
|
|||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
await asyncio.to_thread(_refresh_volume_ranks, force=True)
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -563,7 +699,7 @@ async def _backup_scheduler_loop() -> None:
|
|||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _hub_lifespan(_app: FastAPI):
|
async def _hub_lifespan(_app: FastAPI):
|
||||||
global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task
|
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)
|
set_supervisor_notify_hook(supervisor_store.bump)
|
||||||
await board_store.start(_run_board_aggregate)
|
await board_store.start(_run_board_aggregate)
|
||||||
await dashboard_store.start(_run_dashboard_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")
|
_archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync")
|
||||||
_volume_rank_stop = asyncio.Event()
|
_volume_rank_stop = asyncio.Event()
|
||||||
_volume_rank_task = asyncio.create_task(_volume_rank_loop(), name="hub-volume-rank")
|
_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_stop = asyncio.Event()
|
||||||
_backup_task = asyncio.create_task(_backup_scheduler_loop(), name="hub-backup-scheduler")
|
_backup_task = asyncio.create_task(_backup_scheduler_loop(), name="hub-backup-scheduler")
|
||||||
try:
|
try:
|
||||||
@@ -608,6 +746,16 @@ async def _hub_lifespan(_app: FastAPI):
|
|||||||
pass
|
pass
|
||||||
_volume_rank_task = None
|
_volume_rank_task = None
|
||||||
_volume_rank_stop = 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 chart_poll_store.stop()
|
||||||
await supervisor_store.stop()
|
await supervisor_store.stop()
|
||||||
await dashboard_store.stop()
|
await dashboard_store.stop()
|
||||||
@@ -1169,6 +1317,7 @@ def api_chart_meta():
|
|||||||
"exchanges": exchanges,
|
"exchanges": exchanges,
|
||||||
"volume_rank_top_n": TOP_N_DEFAULT,
|
"volume_rank_top_n": TOP_N_DEFAULT,
|
||||||
"volume_rank_reset_hour": volume_rank_reset_hour(),
|
"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
|
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")
|
@app.get("/api/chart/ohlcv")
|
||||||
def api_chart_ohlcv(
|
def api_chart_ohlcv(
|
||||||
exchange_key: str = "",
|
exchange_key: str = "",
|
||||||
|
|||||||
@@ -4066,6 +4066,39 @@ body.login-page {
|
|||||||
cursor: pointer;
|
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 {
|
.market-vol-rank-btn:hover {
|
||||||
border-color: rgba(0, 255, 157, 0.35);
|
border-color: rgba(0, 255, 157, 0.35);
|
||||||
background: rgba(0, 255, 157, 0.08);
|
background: rgba(0, 255, 157, 0.08);
|
||||||
@@ -4153,6 +4186,60 @@ body.login-page {
|
|||||||
color: var(--accent);
|
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 {
|
.market-vol-rank-no {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
|||||||
@@ -154,11 +154,11 @@
|
|||||||
const elSymbol = document.getElementById("market-symbol");
|
const elSymbol = document.getElementById("market-symbol");
|
||||||
const elVolRankMeta = document.getElementById("market-vol-rank-meta");
|
const elVolRankMeta = document.getElementById("market-vol-rank-meta");
|
||||||
const elVolRankList = document.getElementById("market-vol-rank-list");
|
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 elVolRankSheet = document.getElementById("market-vol-rank-sheet");
|
||||||
const elVolRankAnchor = document.getElementById("market-vol-rank-anchor");
|
const elVolRankAnchor = document.getElementById("market-vol-rank-anchor");
|
||||||
const elVolRankAnchorFs = document.getElementById("market-vol-rank-anchor-fs");
|
const elVolRankAnchorFs = document.getElementById("market-vol-rank-anchor-fs");
|
||||||
|
let activeScanTab = "top20";
|
||||||
|
let scanSheetOpen = false;
|
||||||
const elTf = document.getElementById("market-timeframe");
|
const elTf = document.getElementById("market-timeframe");
|
||||||
const elRefresh = document.getElementById("market-refresh");
|
const elRefresh = document.getElementById("market-refresh");
|
||||||
const elStatus = document.getElementById("market-status");
|
const elStatus = document.getElementById("market-status");
|
||||||
@@ -2965,6 +2965,19 @@
|
|||||||
void postChartUnwatch();
|
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) {
|
function mountVolRankSheet(forFullscreen) {
|
||||||
if (!elVolRankSheet) return;
|
if (!elVolRankSheet) return;
|
||||||
const anchor = forFullscreen ? elVolRankAnchorFs : elVolRankAnchor;
|
const anchor = forFullscreen ? elVolRankAnchorFs : elVolRankAnchor;
|
||||||
@@ -2972,40 +2985,142 @@
|
|||||||
anchor.appendChild(elVolRankSheet);
|
anchor.appendChild(elVolRankSheet);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setVolRankBtnActive(btn, on) {
|
function setScanSheetOpen(open, tab) {
|
||||||
if (!btn) return;
|
|
||||||
btn.classList.toggle("is-active", on);
|
|
||||||
btn.setAttribute("aria-expanded", on ? "true" : "false");
|
|
||||||
}
|
|
||||||
|
|
||||||
function setVolRankSheetOpen(open) {
|
|
||||||
const on = !!open;
|
const on = !!open;
|
||||||
|
scanSheetOpen = on;
|
||||||
|
if (tab) setActiveScanTab(tab);
|
||||||
if (elVolRankSheet) {
|
if (elVolRankSheet) {
|
||||||
elVolRankSheet.classList.toggle("hidden", !on);
|
elVolRankSheet.classList.toggle("hidden", !on);
|
||||||
elVolRankSheet.setAttribute("aria-hidden", on ? "false" : "true");
|
elVolRankSheet.setAttribute("aria-hidden", on ? "false" : "true");
|
||||||
}
|
}
|
||||||
setVolRankBtnActive(elVolRankBtn, on);
|
if (on) void loadScanPanel(false);
|
||||||
setVolRankBtnActive(elFsVolRankBtn, on);
|
}
|
||||||
if (on) void loadVolumeRank();
|
|
||||||
|
function loadScanPanel(forceRefresh) {
|
||||||
|
if (activeScanTab === "top20") {
|
||||||
|
void loadVolumeRank(forceRefresh);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void loadDivergenceScan(activeScanTab, forceRefresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindVolRankPanel() {
|
function bindVolRankPanel() {
|
||||||
function toggleVolRankSheet() {
|
allScanTabButtons().forEach(function (btn) {
|
||||||
const open = elVolRankSheet && elVolRankSheet.classList.contains("hidden");
|
btn.addEventListener("click", function () {
|
||||||
setVolRankSheetOpen(open);
|
const tab = btn.getAttribute("data-scan-tab") || "top20";
|
||||||
}
|
if (scanSheetOpen && activeScanTab === tab) {
|
||||||
if (elVolRankBtn) elVolRankBtn.addEventListener("click", toggleVolRankSheet);
|
setScanSheetOpen(false);
|
||||||
if (elFsVolRankBtn) elFsVolRankBtn.addEventListener("click", toggleVolRankSheet);
|
return;
|
||||||
|
}
|
||||||
|
setScanSheetOpen(true, tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
document.addEventListener("pointerdown", function (ev) {
|
document.addEventListener("pointerdown", function (ev) {
|
||||||
if (!elVolRankSheet || elVolRankSheet.classList.contains("hidden")) return;
|
if (!elVolRankSheet || elVolRankSheet.classList.contains("hidden")) return;
|
||||||
const t = ev.target;
|
const t = ev.target;
|
||||||
if (elVolRankSheet.contains(t)) return;
|
if (elVolRankSheet.contains(t)) return;
|
||||||
if (elVolRankBtn && elVolRankBtn.contains(t)) return;
|
if (t && t.closest && t.closest(".market-scan-tabs")) return;
|
||||||
if (elFsVolRankBtn && elFsVolRankBtn.contains(t)) return;
|
setScanSheetOpen(false);
|
||||||
setVolRankSheetOpen(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) {
|
function renderVolumeRank(data) {
|
||||||
if (!elVolRankMeta || !elVolRankList) return;
|
if (!elVolRankMeta || !elVolRankList) return;
|
||||||
elVolRankList.innerHTML = "";
|
elVolRankList.innerHTML = "";
|
||||||
@@ -3056,11 +3171,7 @@
|
|||||||
(row.volume_label || "") +
|
(row.volume_label || "") +
|
||||||
"</span>";
|
"</span>";
|
||||||
btn.addEventListener("click", function () {
|
btn.addEventListener("click", function () {
|
||||||
if (!row.symbol) return;
|
applyScanSymbolSelection(row.symbol, "top20");
|
||||||
if (elSymbol) elSymbol.value = row.symbol;
|
|
||||||
if (elFsSymbol) elFsSymbol.value = row.symbol;
|
|
||||||
setVolRankSheetOpen(false);
|
|
||||||
loadChart(false);
|
|
||||||
});
|
});
|
||||||
li.appendChild(btn);
|
li.appendChild(btn);
|
||||||
elVolRankList.appendChild(li);
|
elVolRankList.appendChild(li);
|
||||||
|
|||||||
@@ -258,16 +258,12 @@
|
|||||||
<span>币种</span>
|
<span>币种</span>
|
||||||
<div class="market-symbol-wrap">
|
<div class="market-symbol-wrap">
|
||||||
<input id="market-symbol" type="text" value="BTC/USDT" placeholder="BTC/USDT" autocomplete="off" />
|
<input id="market-symbol" type="text" value="BTC/USDT" placeholder="BTC/USDT" autocomplete="off" />
|
||||||
<button
|
<div class="market-scan-tabs" role="tablist" aria-label="成交额与背离筛选">
|
||||||
type="button"
|
<button type="button" class="market-scan-tab is-active" data-scan-tab="top20" aria-selected="true">Top20</button>
|
||||||
id="market-vol-rank-btn"
|
<button type="button" class="market-scan-tab" data-scan-tab="4h" aria-selected="false">4h背离</button>
|
||||||
class="market-vol-rank-btn"
|
<button type="button" class="market-scan-tab" data-scan-tab="1d" aria-selected="false">日线背离</button>
|
||||||
title="昨日成交额 Top20(每早8点更新)"
|
<button type="button" class="market-scan-tab" data-scan-tab="1w" aria-selected="false">周线背离</button>
|
||||||
aria-expanded="false"
|
</div>
|
||||||
aria-controls="market-vol-rank-sheet"
|
|
||||||
>
|
|
||||||
Top20
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="market-field">
|
<label class="market-field">
|
||||||
@@ -342,16 +338,12 @@
|
|||||||
<span>币种</span>
|
<span>币种</span>
|
||||||
<div class="market-symbol-wrap">
|
<div class="market-symbol-wrap">
|
||||||
<input id="market-fs-symbol" type="text" placeholder="BTC/USDT" autocomplete="off" />
|
<input id="market-fs-symbol" type="text" placeholder="BTC/USDT" autocomplete="off" />
|
||||||
<button
|
<div class="market-scan-tabs" role="tablist" aria-label="成交额与背离筛选">
|
||||||
type="button"
|
<button type="button" class="market-scan-tab is-active" data-scan-tab="top20" aria-selected="true">Top20</button>
|
||||||
id="market-fs-vol-rank-btn"
|
<button type="button" class="market-scan-tab" data-scan-tab="4h" aria-selected="false">4h背离</button>
|
||||||
class="market-vol-rank-btn"
|
<button type="button" class="market-scan-tab" data-scan-tab="1d" aria-selected="false">日线背离</button>
|
||||||
title="昨日成交额 Top20(每早8点更新)"
|
<button type="button" class="market-scan-tab" data-scan-tab="1w" aria-selected="false">周线背离</button>
|
||||||
aria-expanded="false"
|
</div>
|
||||||
aria-controls="market-vol-rank-sheet"
|
|
||||||
>
|
|
||||||
Top20
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
<label class="market-field market-fs-field">
|
<label class="market-field market-fs-field">
|
||||||
@@ -1148,7 +1140,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_draw.js?v=20260609-market-day-split"></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/plan.js?v=20260614-plan-refresh"></script>
|
||||||
<script src="/assets/calculator.js?v=3"></script>
|
<script src="/assets/calculator.js?v=3"></script>
|
||||||
<script src="/assets/trade_stats_calendar.js?v=3"></script>
|
<script src="/assets/trade_stats_calendar.js?v=3"></script>
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import unittest
|
||||||
|
|
||||||
|
from lib.hub.hub_divergence_scan_lib import (
|
||||||
|
analyze_ohlcv_bars,
|
||||||
|
build_symbol_scan_row,
|
||||||
|
compute_confluence,
|
||||||
|
detect_latest_macd_divergence,
|
||||||
|
filter_tab_items,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _synthetic_bull_div_closes(n: int = 120) -> list[float]:
|
||||||
|
"""价格双底 + MACD 抬高 → 底背离。"""
|
||||||
|
closes = [100.0] * n
|
||||||
|
# 下跌
|
||||||
|
for i in range(20, 40):
|
||||||
|
closes[i] = 100 - (i - 20) * 0.8
|
||||||
|
# 反弹
|
||||||
|
for i in range(40, 55):
|
||||||
|
closes[i] = closes[39] + (i - 40) * 0.5
|
||||||
|
# 再跌略破前低
|
||||||
|
for i in range(55, 75):
|
||||||
|
closes[i] = closes[54] - (i - 55) * 0.35
|
||||||
|
# 末尾企稳略抬
|
||||||
|
for i in range(75, n):
|
||||||
|
closes[i] = closes[74] + (i - 75) * 0.02
|
||||||
|
return closes
|
||||||
|
|
||||||
|
|
||||||
|
class TestHubDivergenceScanLib(unittest.TestCase):
|
||||||
|
def test_compute_confluence_three_same(self):
|
||||||
|
tf = {
|
||||||
|
"4h": {"direction": "bull"},
|
||||||
|
"1d": {"direction": "bull"},
|
||||||
|
"1w": {"direction": "bull"},
|
||||||
|
}
|
||||||
|
c = compute_confluence(tf)
|
||||||
|
self.assertEqual(c["confluence"], 3)
|
||||||
|
self.assertEqual(c["confluence_css"], "c3")
|
||||||
|
self.assertFalse(c["is_split"])
|
||||||
|
|
||||||
|
def test_compute_confluence_split(self):
|
||||||
|
tf = {
|
||||||
|
"4h": {"direction": "bull"},
|
||||||
|
"1d": {"direction": "bear"},
|
||||||
|
"1w": {"direction": None},
|
||||||
|
}
|
||||||
|
c = compute_confluence(tf)
|
||||||
|
self.assertTrue(c["is_split"])
|
||||||
|
self.assertEqual(c["confluence_kind"], "分歧")
|
||||||
|
self.assertEqual(c["confluence_css"], "split")
|
||||||
|
self.assertIn("4h底", c["split_detail"])
|
||||||
|
|
||||||
|
def test_filter_tab_items_only_matching_tf(self):
|
||||||
|
items = [
|
||||||
|
build_symbol_scan_row(
|
||||||
|
rank=1,
|
||||||
|
symbol="AAA/USDT",
|
||||||
|
volume_label="1M",
|
||||||
|
tf_hits={
|
||||||
|
"4h": {"direction": "bull", "bars_ago": 2, "open_time_ms": 1},
|
||||||
|
"1d": {"direction": None},
|
||||||
|
"1w": {"direction": None},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
build_symbol_scan_row(
|
||||||
|
rank=2,
|
||||||
|
symbol="BBB/USDT",
|
||||||
|
volume_label="2M",
|
||||||
|
tf_hits={
|
||||||
|
"4h": {"direction": None},
|
||||||
|
"1d": {"direction": "bear", "bars_ago": 1, "open_time_ms": 2},
|
||||||
|
"1w": {"direction": None},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
f4 = filter_tab_items(items, "4h")
|
||||||
|
self.assertEqual(len(f4), 1)
|
||||||
|
self.assertEqual(f4[0]["symbol"], "AAA/USDT")
|
||||||
|
f1d = filter_tab_items(items, "1d")
|
||||||
|
self.assertEqual(len(f1d), 1)
|
||||||
|
self.assertEqual(f1d[0]["symbol"], "BBB/USDT")
|
||||||
|
|
||||||
|
def test_detect_macd_divergence_may_hit_on_synthetic(self):
|
||||||
|
closes = _synthetic_bull_div_closes()
|
||||||
|
hit = detect_latest_macd_divergence(closes)
|
||||||
|
# 合成数据不保证必中,但函数应正常返回
|
||||||
|
self.assertIn(hit.get("direction"), (None, "bull", "bear"))
|
||||||
|
|
||||||
|
def test_analyze_ohlcv_bars_from_rows(self):
|
||||||
|
closes = [float(100 + i * 0.1) for i in range(80)]
|
||||||
|
bars = [
|
||||||
|
{"open_time_ms": i * 3600000, "close": c, "open": c, "high": c, "low": c}
|
||||||
|
for i, c in enumerate(closes)
|
||||||
|
]
|
||||||
|
out = analyze_ohlcv_bars(bars)
|
||||||
|
self.assertIn("direction", out)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user