"""Crypto Wyckoff Screener API + page (independent of /api/analyze).""" from __future__ import annotations import os import threading from flask import Blueprint, jsonify, render_template, request from crypto_wyckoff.combos import ( ALLOWED_TFS, add_combo, delete_combo, get_combo, list_combos, ) from crypto_wyckoff.domain_models import DecisionSignal, WyckoffCycle, WyckoffEvent, WyckoffPhase from crypto_wyckoff.scheduler import get_status, run_tick, start_scheduler from crypto_wyckoff import store as wyckoff_store from crypto_wyckoff.symbols_cn import display_name_cn, symbol_name_map from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION bp = Blueprint("wyckoff_crypto", __name__) _scheduler_started = False _sched_lock = threading.Lock() def ensure_scheduler() -> None: global _scheduler_started with _sched_lock: if _scheduler_started: return if os.environ.get("CRYPTO_WYCKOFF_DISABLE", "").lower() in ("1", "true", "yes"): return interval = int(os.environ.get("CRYPTO_WYCKOFF_INTERVAL", "60")) max_sym = os.environ.get("CRYPTO_WYCKOFF_MAX_SYMBOLS") max_symbols = int(max_sym) if max_sym else None start_scheduler(interval_sec=interval, max_symbols=max_symbols) _scheduler_started = True def _safe_int(raw, default: int, *, lo: int | None = None, hi: int | None = None) -> int: try: v = int(raw) except (TypeError, ValueError): v = default if lo is not None: v = max(lo, v) if hi is not None: v = min(hi, v) return v @bp.route("/wyckoff_crypto") def page(): ensure_scheduler() return render_template("wyckoff_crypto.html") @bp.route("/api/wyckoff_crypto/meta") def meta(): ensure_scheduler() combo_id = request.args.get("combo_id") combo = get_combo(combo_id) latest = wyckoff_store.latest_trade_date(combo["id"]) return jsonify( { "architecture_version": ARCHITECTURE_VERSION, "engine_version": WYCKOFF_ENGINE_VERSION, "latest_trade_date": latest, "scan_count": wyckoff_store.count_for_date(latest, combo["id"]), "cycles": [c.value for c in WyckoffCycle], "phases": [p.value for p in WyckoffPhase], "events": [e.value for e in WyckoffEvent], "decision_signals": [s.value for s in DecisionSignal], "timezone": "Asia/Shanghai", "utc_offset": "+08:00", "timeframes": [combo["low"], combo["mid"], combo["high"]], "combo": combo, "combos": list_combos(), "allowed_tfs": list(ALLOWED_TFS), "symbol_names": symbol_name_map(), "default_symbol": "BTC/USDT:USDT", "status": get_status(), } ) @bp.route("/api/wyckoff_crypto/combos", methods=["GET"]) def combos_list(): ensure_scheduler() return jsonify({"combos": list_combos(), "allowed_tfs": list(ALLOWED_TFS)}) @bp.route("/api/wyckoff_crypto/combos", methods=["POST"]) def combos_add(): ensure_scheduler() body = request.get_json(silent=True) or {} high = (body.get("high") or request.args.get("high") or "").strip() mid = (body.get("mid") or request.args.get("mid") or "").strip() low = (body.get("low") or request.args.get("low") or "").strip() label = (body.get("label") or request.args.get("label") or "").strip() or None try: row = add_combo(high, mid, low, label=label) except ValueError as e: return jsonify({"error": str(e)}), 400 return jsonify({"ok": True, "combo": row, "combos": list_combos()}) @bp.route("/api/wyckoff_crypto/combos/", methods=["DELETE"]) def combos_delete(combo_id: str): ensure_scheduler() try: removed = delete_combo(combo_id) except ValueError as e: return jsonify({"error": str(e)}), 400 if not removed: return jsonify({"error": "not_found"}), 404 return jsonify({"ok": True, "combos": list_combos()}) @bp.route("/api/wyckoff_crypto/status") def status(): ensure_scheduler() return jsonify(get_status()) @bp.route("/api/wyckoff_crypto/scan") def scan(): ensure_scheduler() combo = get_combo(request.args.get("combo_id")) rows = wyckoff_store.query_scan( trade_date=request.args.get("trade_date"), combo_id=combo["id"], m_cycle=request.args.get("m_cycle"), w_phase=request.args.get("w_phase"), d_event=request.args.get("d_event"), decision_signal=request.args.get("decision_signal"), min_overall_score=_float_or_none(request.args.get("min_overall_score")), min_alignment=_float_or_none(request.args.get("min_alignment")), sort=request.args.get("sort") or "overall_score", limit=_safe_int(request.args.get("limit"), 100, lo=1, hi=500), offset=_safe_int(request.args.get("offset"), 0, lo=0), ) for row in rows: row["name"] = display_name_cn(row.get("ts_code") or "") return jsonify({"rows": rows, "count": len(rows), "combo": combo}) @bp.route("/api/wyckoff_crypto/symbol/") def symbol_detail(symbol: str): ensure_scheduler() combo = get_combo(request.args.get("combo_id")) row = wyckoff_store.get_symbol(symbol, request.args.get("trade_date"), combo["id"]) if not row: return jsonify({"error": "not_found"}), 404 return jsonify(row) @bp.route("/api/wyckoff_crypto/tick", methods=["POST"]) def manual_tick(): """Manual one-shot tick (debug). Optional JSON/query max_symbols.""" ensure_scheduler() body = request.get_json(silent=True) or {} max_sym = request.args.get("max_symbols") or body.get("max_symbols") max_symbols = int(max_sym) if max_sym not in (None, "") else None def _job(): try: run_tick(max_symbols=max_symbols, force_rescan=True) except Exception: pass threading.Thread(target=_job, daemon=True).start() return jsonify({"ok": True, "started": True}) @bp.route("/api/wyckoff_crypto/klines") def klines(): """Local cached OHLCV for chart (combo TFs).""" ensure_scheduler() from crypto_wyckoff.io import is_intraday_tf, load_bars_with_ts symbol = request.args.get("symbol") or "" combo = get_combo(request.args.get("combo_id")) allowed = {combo["low"], combo["mid"], combo["high"]} tf = request.args.get("tf") or combo["low"] limit = _safe_int(request.args.get("limit"), 180, lo=1, hi=500) if not symbol or tf not in allowed: return jsonify({"error": "bad_request", "allowed": sorted(allowed)}), 400 items = load_bars_with_ts(symbol, tf, lookback=limit) return jsonify({ "items": items, "symbol": symbol, "tf": tf, "count": len(items), "intraday": is_intraday_tf(tf), "combo": combo, }) @bp.route("/api/wyckoff_crypto/overlay") def overlay(): """Phase/event overlay for chart.""" ensure_scheduler() from crypto_wyckoff.annotate import annotate_symbol symbol = request.args.get("symbol") or "" combo = get_combo(request.args.get("combo_id")) allowed = {combo["low"], combo["mid"], combo["high"]} tf = request.args.get("tf") or combo["low"] bars = _safe_int(request.args.get("bars"), 180, lo=20, hi=400) if not symbol or tf not in allowed: return jsonify({"error": "bad_request", "allowed": sorted(allowed)}), 400 try: data = annotate_symbol(symbol, freq=tf, lookback=bars, combo_id=combo["id"]) except Exception: return jsonify({ "error": "overlay_failed", "phases": [], "events": [], "levels": {}, "zones": [], "combo_id": combo["id"], }), 500 return jsonify(data) def _float_or_none(v): if v in (None, ""): return None try: return float(v) except (TypeError, ValueError): return None