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:
dekun
2026-07-06 19:58:30 +08:00
parent 81f7a7c451
commit e62a3f3351
7 changed files with 999 additions and 48 deletions
+195 -1
View File
@@ -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 = "",