diff --git a/lib/hub/hub_backup_lib.py b/lib/hub/hub_backup_lib.py index f095217..d2b89bf 100644 --- a/lib/hub/hub_backup_lib.py +++ b/lib/hub/hub_backup_lib.py @@ -38,6 +38,7 @@ HUB_DATA_FILES = ( "hub_entry_plans.db", "hub_macro_calendar.db", "hub_volume_rank.json", + "hub_divergence_scan.json", ) DEFAULT_BACKUP_SETTINGS = { diff --git a/lib/hub/hub_divergence_scan_lib.py b/lib/hub/hub_divergence_scan_lib.py new file mode 100644 index 0000000..0c488ff --- /dev/null +++ b/lib/hub/hub_divergence_scan_lib.py @@ -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 diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index 5e6c240..31b5182 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -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 = "", diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 837b951..ac94e2b 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -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; diff --git a/manual_trading_hub/static/chart.js b/manual_trading_hub/static/chart.js index 263249f..5f3998e 100644 --- a/manual_trading_hub/static/chart.js +++ b/manual_trading_hub/static/chart.js @@ -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 = + '' + + (confLabel || "—") + + '' + + (row.rank || "") + + '' + + (row.symbol || "") + + '' + + dirLabel + + (fresh ? " · " + fresh : "") + + ""; + 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 || "") + ""; 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); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index d80ffa8..e345f70 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -258,16 +258,12 @@ 币种