移植 A_Share_DP 引擎;本地缓存与 60s tip;月线由日线 UTC 聚合;不碰主站 analyze/缠论叠层。 Co-authored-by: Cursor <cursoragent@cursor.com>
129 lines
3.7 KiB
Python
129 lines
3.7 KiB
Python
"""Background 60s tip-update + rescan scheduler."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import threading
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from crypto_wyckoff.io import (
|
|
TF_LIST,
|
|
backfill_symbol,
|
|
bar_count,
|
|
fetch_symbols_from_provider,
|
|
tip_update_symbol,
|
|
)
|
|
from crypto_wyckoff.pipeline import analyze_and_store
|
|
from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_lock = threading.Lock()
|
|
_status: dict[str, Any] = {
|
|
"running": False,
|
|
"last_tick_at": None,
|
|
"last_error": None,
|
|
"symbols_total": 0,
|
|
"symbols_scanned": 0,
|
|
"backfill_done": False,
|
|
"engine_version": WYCKOFF_ENGINE_VERSION,
|
|
"tick_interval_sec": 60,
|
|
}
|
|
_stop = threading.Event()
|
|
_thread: threading.Thread | None = None
|
|
|
|
|
|
def get_status() -> dict[str, Any]:
|
|
with _lock:
|
|
return dict(_status)
|
|
|
|
|
|
def _set(**kwargs):
|
|
with _lock:
|
|
_status.update(kwargs)
|
|
|
|
|
|
def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict:
|
|
"""One cycle: refresh symbols, tip-update, analyze changed (or all if force)."""
|
|
symbols = fetch_symbols_from_provider()
|
|
if max_symbols:
|
|
symbols = symbols[:max_symbols]
|
|
_set(symbols_total=len(symbols), running=True, last_error=None)
|
|
scanned = 0
|
|
errors = 0
|
|
changed_n = 0
|
|
|
|
# Lazy backfill: ensure min bars
|
|
for i, sym in enumerate(symbols):
|
|
try:
|
|
if bar_count(sym, "1d") < 40:
|
|
backfill_symbol(sym, TF_LIST)
|
|
tip_changed = tip_update_symbol(sym, TF_LIST)
|
|
if tip_changed:
|
|
changed_n += 1
|
|
if force_rescan or tip_changed or bar_count(sym, "1d") >= 40:
|
|
# Always rescan on first pass after backfill; tip change triggers update
|
|
if force_rescan or tip_changed or True:
|
|
# Tip every minute: always re-analyze to refresh forming-bar features
|
|
row = analyze_and_store(sym)
|
|
if row:
|
|
scanned += 1
|
|
except Exception as e:
|
|
errors += 1
|
|
if errors <= 5:
|
|
logger.warning("tick %s: %s", sym, e)
|
|
_set(last_error=str(e))
|
|
if (i + 1) % 25 == 0:
|
|
_set(symbols_scanned=scanned)
|
|
logger.info("wyckoff tick progress %s/%s scanned=%s", i + 1, len(symbols), scanned)
|
|
|
|
_set(
|
|
running=False,
|
|
symbols_scanned=scanned,
|
|
last_tick_at=datetime.now(timezone.utc).isoformat(),
|
|
backfill_done=True,
|
|
)
|
|
return {
|
|
"symbols": len(symbols),
|
|
"scanned": scanned,
|
|
"changed_tips": changed_n,
|
|
"errors": errors,
|
|
}
|
|
|
|
|
|
def _loop(interval: int, max_symbols: int | None):
|
|
# First tick: force full rescan after tip/backfill
|
|
try:
|
|
run_tick(max_symbols=max_symbols, force_rescan=True)
|
|
except Exception as e:
|
|
logger.exception("initial tick failed: %s", e)
|
|
_set(last_error=str(e), running=False)
|
|
while not _stop.wait(interval):
|
|
try:
|
|
run_tick(max_symbols=max_symbols, force_rescan=True)
|
|
except Exception as e:
|
|
logger.exception("tick failed: %s", e)
|
|
_set(last_error=str(e), running=False)
|
|
|
|
|
|
def start_scheduler(interval_sec: int = 60, max_symbols: int | None = None) -> None:
|
|
global _thread
|
|
if _thread and _thread.is_alive():
|
|
return
|
|
_stop.clear()
|
|
_set(tick_interval_sec=interval_sec)
|
|
_thread = threading.Thread(
|
|
target=_loop,
|
|
args=(interval_sec, max_symbols),
|
|
name="crypto-wyckoff-scheduler",
|
|
daemon=True,
|
|
)
|
|
_thread.start()
|
|
logger.info("crypto wyckoff scheduler started interval=%ss", interval_sec)
|
|
|
|
|
|
def stop_scheduler() -> None:
|
|
_stop.set()
|