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 = "",
|
||||
|
||||
Reference in New Issue
Block a user