自动刷新常态只拉 recent 尾部 K,每 1 分钟全量重算缠论;修复结构区缓存导入;默认指标/4h·1h·15m/近30天;同步 ECR-009 screener 相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
237 lines
7.8 KiB
Python
237 lines
7.8 KiB
Python
"""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/<combo_id>", 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/<path: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
|