"""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()