From 18a7f485e6dbf2956a45225290d46509fdce6ce8 Mon Sep 17 00:00:00 2001 From: jackyu66git Date: Sat, 8 Aug 2026 15:45:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E5=A2=9E=E9=87=8F=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=88=B7=E6=96=B0=E3=80=81=E7=BB=93=E6=9E=84=E5=8C=BA?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=B8=8E=E9=BB=98=E8=AE=A4=E6=8C=87=E6=A0=87?= =?UTF-8?q?/=E5=91=A8=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自动刷新常态只拉 recent 尾部 K,每 1 分钟全量重算缠论;修复结构区缓存导入;默认指标/4h·1h·15m/近30天;同步 ECR-009 screener 相关改动。 Co-authored-by: Cursor --- crypto_wyckoff/annotate.py | 81 +- crypto_wyckoff/combos.py | 248 ++++++ crypto_wyckoff/domain_models.py | 1 + crypto_wyckoff/io.py | 122 ++- crypto_wyckoff/pipeline.py | 72 +- crypto_wyckoff/scheduler.py | 57 +- crypto_wyckoff/store.py | 142 ++- crypto_wyckoff/symbols_cn.py | 51 ++ docs/ECR/ECR-009-crypto-wyckoff-screener.md | 9 +- docs/STATE/CURRENT.md | 4 +- tests/test_crypto_wyckoff_combos.py | 31 + web/api/analyze.py | 77 ++ web/api/wyckoff_crypto.py | 138 ++- web/services/runtime/timeframes.py | 8 +- web/static/js/app/chart_sync.js | 87 +- web/static/js/app/chart_tv_finalize.js | 39 +- web/static/js/app/chart_view.js | 143 ++- web/static/js/app/macd_ui.js | 4 +- web/static/js/app/ui.js | 57 +- web/templates/index.html | 42 +- web/templates/wyckoff_crypto.html | 929 +++++++++++++++++++- web/tests/test_analyze_contract.py | 22 + web/tests/test_wyckoff_crypto_routes.py | 79 +- 23 files changed, 2133 insertions(+), 310 deletions(-) create mode 100644 crypto_wyckoff/combos.py create mode 100644 crypto_wyckoff/symbols_cn.py create mode 100644 tests/test_crypto_wyckoff_combos.py diff --git a/crypto_wyckoff/annotate.py b/crypto_wyckoff/annotate.py index 53fe695..0abb9b5 100644 --- a/crypto_wyckoff/annotate.py +++ b/crypto_wyckoff/annotate.py @@ -61,12 +61,18 @@ def _compress_phases(points: list[tuple[str, str]]) -> list[dict]: return segs -def annotate_frame(frame: OHLCVFrame, step: int | None = None) -> dict: +def annotate_frame( + frame: OHLCVFrame, + step: int | None = None, + *, + role: str | None = None, +) -> dict: """Pure annotation: phase bands + event markers + latest levels. - ``step`` defaults by timeframe to keep interactive charts snappy. + ``role`` is the D/W/M rule alias (1d/1w/1M). Defaults to frame.timeframe. + ``step`` defaults by role to keep interactive charts snappy. """ - tf = frame.timeframe + tf = role or frame.timeframe min_bars = _MIN_BARS.get(tf, 30) if step is None: step = {"1d": 2, "1w": 1, "1M": 1}.get(tf, 2) @@ -227,17 +233,21 @@ def annotate_symbol( freq: str, end_date: date | None = None, lookback: int = 180, + *, + combo_id: str | None = None, ) -> dict: """IO + annotate for one symbol (used by API). - For daily charts, phase bands come from **weekly** structure (Wyckoff - primary timeframe), while event markers / levels come from daily. + For the combo *low* chart, phase bands come from **mid** structure, + while event markers / levels come from the low TF. """ - from crypto_wyckoff.io import latest_daily_trade_date, load_frames_batch + from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo + from crypto_wyckoff.io import load_frame - if freq not in ("1d", "1w", "1M"): - raise ValueError(f"unsupported freq: {freq}") - ed = end_date or latest_daily_trade_date() + combo = get_combo(combo_id) + allowed = {combo["low"], combo["mid"], combo["high"]} + if freq not in allowed: + raise ValueError(f"freq {freq} not in combo {combo['id']} ({combo['label']})") empty = { "ts_code": ts_code, "freq": freq, @@ -247,25 +257,22 @@ def annotate_symbol( "zones": [], "bars": 0, "phase_source": freq, + "cycles": [], + "combo_id": combo["id"], } - if ed is None: - return empty + _ = end_date - if freq == "1d": - daily_frames = load_frames_batch("1d", ed, lookback, ts_codes=[ts_code]) - weekly_frames = load_frames_batch("1w", ed, max(60, lookback // 3), ts_codes=[ts_code]) - daily = daily_frames.get(ts_code) - weekly = weekly_frames.get(ts_code) - if daily is None: + if freq == combo["low"]: + low = load_frame(ts_code, combo["low"], lookback) + mid = load_frame(ts_code, combo["mid"], max(60, lookback // 3)) + if low is None: return empty - d_ann = annotate_frame(daily) - w_ann = annotate_frame(weekly) if weekly is not None else {"phases": []} - cycles = _cycle_segments(weekly) if weekly is not None else [] + d_ann = annotate_frame(low, role=ROLE_LOW) + w_ann = annotate_frame(mid, role=ROLE_MID) if mid is not None else {"phases": []} + cycles = _cycle_segments(mid, role=ROLE_MID) if mid is not None else [] levels = d_ann.get("levels") or {} - # Prefer weekly cycle on the latest levels for zone labeling if cycles: levels = {**levels, "cycle": cycles[-1].get("cycle") or levels.get("cycle")} - # latest non-None weekly phase for p in reversed(w_ann.get("phases") or []): if p.get("phase") not in (None, "None"): levels = {**levels, "phase": p["phase"]} @@ -273,29 +280,30 @@ def annotate_symbol( return { "ts_code": ts_code, "freq": freq, - "end_date": ed.isoformat(), + "end_date": low.trade_dates[-1].isoformat() if low.trade_dates else None, "phases": w_ann.get("phases") or [], "events": d_ann.get("events") or [], "levels": d_ann.get("levels") or {}, - "zones": _build_range_zones(daily, cycles, levels), + "zones": _build_range_zones(low, cycles, levels), "bars": d_ann.get("bars", 0), - "phase_source": "1w", + "phase_source": combo["mid"], "cycles": cycles, + "combo_id": combo["id"], } - frames = load_frames_batch(freq, ed, lookback, ts_codes=[ts_code]) - frame = frames.get(ts_code) + role = ROLE_MID if freq == combo["mid"] else ROLE_HIGH + frame = load_frame(ts_code, freq, lookback) if frame is None: return empty - out = annotate_frame(frame) + out = annotate_frame(frame, role=role) out["ts_code"] = ts_code out["freq"] = freq - out["end_date"] = ed.isoformat() + out["end_date"] = frame.trade_dates[-1].isoformat() if frame.trade_dates else None out["phase_source"] = freq - out["cycles"] = _cycle_segments(frame) + out["cycles"] = _cycle_segments(frame, role=ROLE_HIGH if role == ROLE_HIGH else ROLE_MID) out["zones"] = _build_range_zones(frame, out["cycles"], out.get("levels") or {}) - if freq == "1M": - # Monthly chart: cycle bands are more meaningful than phase + out["combo_id"] = combo["id"] + if role == ROLE_HIGH: if not any(p.get("phase") not in (None, "None") for p in out["phases"]): out["phases"] = [ {"start": c["start"], "end": c["end"], "phase": c["cycle"]} @@ -305,9 +313,14 @@ def annotate_symbol( return out -def _cycle_segments(frame: OHLCVFrame, step: int | None = None) -> list[dict]: +def _cycle_segments( + frame: OHLCVFrame, + step: int | None = None, + *, + role: str | None = None, +) -> list[dict]: """Walk-forward cycle labels compressed to segments.""" - tf = frame.timeframe + tf = role or frame.timeframe min_bars = _MIN_BARS.get(tf, 30) if step is None: step = {"1d": 3, "1w": 1, "1M": 1}.get(tf, 2) diff --git a/crypto_wyckoff/combos.py b/crypto_wyckoff/combos.py new file mode 100644 index 0000000..8348483 --- /dev/null +++ b/crypto_wyckoff/combos.py @@ -0,0 +1,248 @@ +"""Multi-timeframe combo presets for Crypto Wyckoff Screener. + +Roles (engine rule aliases stay D/W/M): + high → Cycle (rules as 1M) + mid → Phase (rules as 1w) + low → Event (rules as 1d) + +Actual bar TFs come from the combo (e.g. 8h/4h/1h). +""" + +from __future__ import annotations + +import json +import re +import threading +from copy import deepcopy +from pathlib import Path +from typing import Any + +from crypto_wyckoff.io import DATA_DIR, ensure_dirs + +ROLE_LOW = "1d" +ROLE_MID = "1w" +ROLE_HIGH = "1M" + +# Minutes for ordering / validation (provider labels) +_TF_MINUTES: dict[str, int] = { + "1m": 1, "2m": 2, "3m": 3, "4m": 4, "5m": 5, + "10m": 10, "15m": 15, "20m": 20, "25m": 25, "30m": 30, "45m": 45, + "1h": 60, "2h": 120, "3h": 180, "4h": 240, "5h": 300, + "6h": 360, "7h": 420, "8h": 480, "9h": 540, "10h": 600, + "11h": 660, "12h": 720, "16h": 960, "20h": 1200, + "1d": 1440, "2d": 2880, "3d": 4320, "4d": 5760, "5d": 7200, "6d": 8640, + "1w": 10080, "2w": 20160, "3w": 30240, + "1M": 43200, +} + +# TFs we allow in custom combos (provider-backed + local 1M) +ALLOWED_TFS: tuple[str, ...] = ( + "1h", "2h", "3h", "4h", "6h", "8h", "12h", + "1d", "2d", "3d", "1w", "1M", +) + +BUILTIN: list[dict[str, Any]] = [ + { + "id": "h8_4_1", + "label": "8h / 4h / 1h", + "high": "8h", + "mid": "4h", + "low": "1h", + "builtin": True, + }, + { + "id": "d_w_m", + "label": "1d / 1w / 1M", + "high": "1M", + "mid": "1w", + "low": "1d", + "builtin": True, + }, +] + +_COMBOS_FILE = DATA_DIR / "combos.json" +_lock = threading.Lock() +_cache: list[dict[str, Any]] | None = None + + +def tf_minutes(tf: str) -> int | None: + if tf in _TF_MINUTES: + return _TF_MINUTES[tf] + # tolerate provider typo "10" → skip + m = re.fullmatch(r"(\d+)([mhdwM])", tf) + if not m: + return None + n, u = int(m.group(1)), m.group(2) + mult = {"m": 1, "h": 60, "d": 1440, "w": 10080, "M": 43200}[u] + return n * mult + + +def combo_id_for(high: str, mid: str, low: str) -> str: + def _tok(t: str) -> str: + return t.replace("/", "_") + + return f"{_tok(high)}_{_tok(mid)}_{_tok(low)}" + + +def validate_combo(high: str, mid: str, low: str) -> str | None: + """Return error message or None if ok.""" + for tf in (high, mid, low): + if tf not in ALLOWED_TFS: + return f"不支持的周期: {tf}" + if len({high, mid, low}) < 3: + return "高/中/低周期必须互不相同" + hm, mm, lm = tf_minutes(high), tf_minutes(mid), tf_minutes(low) + if hm is None or mm is None or lm is None: + return "无法解析周期长度" + if not (hm > mm > lm): + return "须满足 高 > 中 > 低(例如 8h > 4h > 1h)" + return None + + +def _normalize(row: dict[str, Any]) -> dict[str, Any] | None: + high, mid, low = row.get("high"), row.get("mid"), row.get("low") + if not high or not mid or not low: + return None + err = validate_combo(str(high), str(mid), str(low)) + if err: + return None + cid = str(row.get("id") or combo_id_for(high, mid, low)) + label = str(row.get("label") or f"{high} / {mid} / {low}") + return { + "id": cid, + "label": label, + "high": str(high), + "mid": str(mid), + "low": str(low), + "builtin": bool(row.get("builtin", False)), + } + + +def _load_raw() -> list[dict[str, Any]]: + ensure_dirs() + if not _COMBOS_FILE.exists(): + return deepcopy(BUILTIN) + try: + data = json.loads(_COMBOS_FILE.read_text(encoding="utf-8")) + items = data.get("combos") if isinstance(data, dict) else data + if not isinstance(items, list): + return deepcopy(BUILTIN) + except (OSError, json.JSONDecodeError): + return deepcopy(BUILTIN) + + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for b in BUILTIN: + out.append(deepcopy(b)) + seen.add(b["id"]) + for row in items: + if not isinstance(row, dict): + continue + norm = _normalize(row) + if not norm or norm["id"] in seen: + continue + if norm["id"] in {b["id"] for b in BUILTIN}: + continue + norm["builtin"] = False + out.append(norm) + seen.add(norm["id"]) + return out + + +def _save(combos: list[dict[str, Any]]) -> None: + ensure_dirs() + custom = [c for c in combos if not c.get("builtin")] + payload = {"combos": custom} + tmp = _COMBOS_FILE.with_suffix(".tmp") + tmp.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + tmp.replace(_COMBOS_FILE) + + +def list_combos() -> list[dict[str, Any]]: + global _cache + with _lock: + if _cache is None: + _cache = _load_raw() + return deepcopy(_cache) + + +def get_combo(combo_id: str | None) -> dict[str, Any]: + combos = list_combos() + if combo_id: + for c in combos: + if c["id"] == combo_id: + return deepcopy(c) + return deepcopy(combos[0]) + + +def add_combo(high: str, mid: str, low: str, label: str | None = None) -> dict[str, Any]: + err = validate_combo(high, mid, low) + if err: + raise ValueError(err) + cid = combo_id_for(high, mid, low) + row = { + "id": cid, + "label": label or f"{high} / {mid} / {low}", + "high": high, + "mid": mid, + "low": low, + "builtin": False, + } + with _lock: + combos = _load_raw() + for c in combos: + if c["id"] == cid or (c["high"], c["mid"], c["low"]) == (high, mid, low): + _cache = combos + return deepcopy(c) + combos.append(row) + _save(combos) + _cache = combos + return deepcopy(row) + + +def delete_combo(combo_id: str) -> bool: + with _lock: + combos = _load_raw() + kept: list[dict[str, Any]] = [] + removed = False + for c in combos: + if c["id"] == combo_id: + if c.get("builtin"): + raise ValueError("内置组合不可删除") + removed = True + continue + kept.append(c) + if removed: + _save(kept) + _cache = kept + return removed + + +def all_tfs_for_combos(combos: list[dict[str, Any]] | None = None) -> list[str]: + """Unique TFs needed by active combos (stable order).""" + rows = combos if combos is not None else list_combos() + seen: list[str] = [] + for c in rows: + for k in ("low", "mid", "high"): + tf = c[k] + if tf not in seen: + seen.append(tf) + return seen + + +def lookback_for(tf: str) -> int: + defaults = { + "1h": 500, + "2h": 400, + "3h": 350, + "4h": 300, + "6h": 280, + "8h": 250, + "12h": 220, + "1d": 250, + "2d": 200, + "3d": 180, + "1w": 104, + "1M": 60, + } + return defaults.get(tf, 200) diff --git a/crypto_wyckoff/domain_models.py b/crypto_wyckoff/domain_models.py index a3e1956..e441447 100644 --- a/crypto_wyckoff/domain_models.py +++ b/crypto_wyckoff/domain_models.py @@ -116,6 +116,7 @@ class WyckoffScanRow: name: str = "" industry: str = "" engine_version: str = "v1.0.0" + combo_id: str = "d_w_m" m_cycle: str = WyckoffCycle.UNKNOWN.value cycle_confidence: float = 0.0 diff --git a/crypto_wyckoff/io.py b/crypto_wyckoff/io.py index a493193..5662a01 100644 --- a/crypto_wyckoff/io.py +++ b/crypto_wyckoff/io.py @@ -28,10 +28,22 @@ DATA_SERVICE_URL = os.environ.get( ).rstrip("/") # Continuous crypto: bar counts (not A-share weekend-padded calendar multipliers) -# Provider has 1d/1w but no 1M — monthly is resampled locally from daily UTC months. -LOOKBACK = {"1d": 250, "1w": 104, "1M": 60} -TF_PROVIDER = ("1d", "1w") +# Provider has many TFs; 1M is resampled locally from daily UTC months. +LOOKBACK = { + "1h": 500, + "2h": 400, + "4h": 300, + "6h": 280, + "8h": 250, + "12h": 220, + "1d": 250, + "1w": 104, + "1M": 60, +} +# Default D/W/M stack (kept for compat); combos may request more TFs from provider. +TF_PROVIDER = ("1h", "4h", "8h", "1d", "1w") TF_LIST = ("1d", "1w", "1M") +LOCAL_ONLY_TFS = frozenset({"1M"}) def ensure_dirs() -> None: @@ -125,7 +137,29 @@ def upsert_bars(symbol: str, tf: str, rows: list[dict]) -> int: conn.close() -def load_frame(symbol: str, tf: str, lookback: int | None = None) -> OHLCVFrame | None: +def is_intraday_tf(tf: str) -> bool: + """True for minute/hour TFs that need clock time on charts.""" + t = (tf or "").strip() + return t.endswith("m") or t.endswith("h") + + +def load_bars_with_ts( + symbol: str, tf: str, lookback: int | None = None +) -> list[dict]: + """Return OHLCV rows with UTC ms ts (for chart labels). + + ``datetime`` is wall-clock in Asia/Shanghai (UTC+8) for display. + """ + from zoneinfo import ZoneInfo + + tz_cn = ZoneInfo("Asia/Shanghai") + if lookback is None: + try: + from crypto_wyckoff.combos import lookback_for + + lookback = lookback_for(tf) + except Exception: + lookback = LOOKBACK.get(tf, 100) lookback = lookback or LOOKBACK.get(tf, 100) conn = _bars_conn() try: @@ -140,20 +174,40 @@ def load_frame(symbol: str, tf: str, lookback: int | None = None) -> OHLCVFrame rows = list(reversed(cur.fetchall())) finally: conn.close() + out = [] + for ts, o, h, l, c, v in rows: + dt_utc = datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc) + dt_cn = dt_utc.astimezone(tz_cn) + out.append( + { + "ts": int(ts), + "datetime": dt_cn.strftime("%Y-%m-%dT%H:%M:%S+08:00"), + "date": dt_cn.strftime("%Y-%m-%d"), + "open": o, + "high": h, + "low": l, + "close": c, + "volume": v, + } + ) + return out + + +def load_frame(symbol: str, tf: str, lookback: int | None = None) -> OHLCVFrame | None: + rows = load_bars_with_ts(symbol, tf, lookback) if not rows: return None - trade_dates: list[date] = [] - for ts, *_ in rows: - trade_dates.append(datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc).date()) return OHLCVFrame( ts_code=symbol, timeframe=tf, - trade_dates=trade_dates, - open=[r[1] for r in rows], - high=[r[2] for r in rows], - low=[r[3] for r in rows], - close=[r[4] for r in rows], - volume=[r[5] for r in rows], + trade_dates=[ + datetime.fromtimestamp(r["ts"] / 1000.0, tz=timezone.utc).date() for r in rows + ], + open=[r["open"] for r in rows], + high=[r["high"] for r in rows], + low=[r["low"] for r in rows], + close=[r["close"] for r in rows], + volume=[r["volume"] for r in rows], ) @@ -219,14 +273,18 @@ def rebuild_monthly_from_daily(symbol: str) -> int: def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict: - """Pull history for continuous crypto TFs; monthly derived from daily.""" - stats = {} - for tf in TF_PROVIDER: - if tf not in tfs and "1M" not in tfs: + """Pull history for requested TFs; monthly derived from daily when needed.""" + wanted = list(dict.fromkeys(tfs)) + stats: dict = {} + need_monthly = "1M" in wanted + if need_monthly and "1d" not in wanted: + wanted = ["1d", *wanted] + + for tf in wanted: + if tf in LOCAL_ONLY_TFS: continue need = LOOKBACK.get(tf, 100) - # need extra daily for monthly history - if tf == "1d": + if tf == "1d" and need_monthly: need = max(need, LOOKBACK["1M"] * 31) try: rows = fetch_candles(symbol, tf, limit=need) @@ -236,7 +294,8 @@ def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict: logger.warning("backfill %s %s failed: %s", symbol, tf, e) stats[tf] = 0 time.sleep(0.05) - if "1M" in tfs or True: + + if need_monthly: try: stats["1M"] = rebuild_monthly_from_daily(symbol) except Exception as e: @@ -247,8 +306,11 @@ def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict: def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool: """Update forming tip bars (limit=3). Returns True if any bar changed.""" + wanted = list(dict.fromkeys(tfs)) changed = False - for tf in TF_PROVIDER: + for tf in wanted: + if tf in LOCAL_ONLY_TFS: + continue try: rows = fetch_candles(symbol, tf, limit=3) if not rows: @@ -261,15 +323,15 @@ def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool: except Exception as e: logger.debug("tip %s %s: %s", symbol, tf, e) time.sleep(0.02) - # Always rebuild current month tip from daily - before_m = _tip_fingerprint(symbol, "1M") - try: - rebuild_monthly_from_daily(symbol) - except Exception as e: - logger.debug("monthly tip %s: %s", symbol, e) - after_m = _tip_fingerprint(symbol, "1M") - if before_m != after_m: - changed = True + if "1M" in wanted: + before_m = _tip_fingerprint(symbol, "1M") + try: + rebuild_monthly_from_daily(symbol) + except Exception as e: + logger.debug("monthly tip %s: %s", symbol, e) + after_m = _tip_fingerprint(symbol, "1M") + if before_m != after_m: + changed = True return changed diff --git a/crypto_wyckoff/pipeline.py b/crypto_wyckoff/pipeline.py index 2646703..41f9968 100644 --- a/crypto_wyckoff/pipeline.py +++ b/crypto_wyckoff/pipeline.py @@ -1,4 +1,4 @@ -"""Scan pipeline: load local frames → engines → store.""" +"""Scan pipeline: load local frames → engines → store (per TF combo).""" from __future__ import annotations @@ -6,25 +6,27 @@ import json import logging from datetime import date, datetime, timezone +from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo, lookback_for from crypto_wyckoff.cycle import CycleEngine from crypto_wyckoff.decision import DecisionEngine from crypto_wyckoff.domain_models import WyckoffScanRow from crypto_wyckoff.event import EventEngine from crypto_wyckoff.features import FeatureEngine -from crypto_wyckoff.io import LOOKBACK, TF_LIST, load_frame +from crypto_wyckoff.io import load_frame from crypto_wyckoff.phase import PhaseEngine from crypto_wyckoff.plan import PlanEngine from crypto_wyckoff.signal import SignalEngine from crypto_wyckoff.store import upsert_row +from crypto_wyckoff.symbols_cn import display_name_cn from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION logger = logging.getLogger(__name__) def analyze_symbol( - daily_frame, - weekly_frame, - monthly_frame, + low_frame, + mid_frame, + high_frame, *, feature_eng: FeatureEngine, cycle_eng: CycleEngine, @@ -34,18 +36,22 @@ def analyze_symbol( decision_eng: DecisionEngine, plan_eng: PlanEngine, ) -> dict: - f_d = feature_eng.run(daily_frame, "1d") - f_w = feature_eng.run(weekly_frame, "1w") - f_m = feature_eng.run(monthly_frame, "1M") + """Run engines with D/W/M *role* aliases so existing rules match. - c_m = cycle_eng.run(f_m, "1M") - c_w = cycle_eng.run(f_w, "1w") + Frames may be any TF combo (e.g. 1h/4h/8h); rules still see 1d/1w/1M roles. + """ + f_d = feature_eng.run(low_frame, ROLE_LOW) + f_w = feature_eng.run(mid_frame, ROLE_MID) + f_m = feature_eng.run(high_frame, ROLE_HIGH) - p_w = phase_eng.run(c_w, f_w, "1w") - p_d = phase_eng.run(c_w, f_d, "1d") + c_m = cycle_eng.run(f_m, ROLE_HIGH) + c_w = cycle_eng.run(f_w, ROLE_MID) - e_w = event_eng.run(c_w, p_w, f_w, "1w") - e_d = event_eng.run(c_w, p_d, f_d, "1d") + p_w = phase_eng.run(c_w, f_w, ROLE_MID) + p_d = phase_eng.run(c_w, f_d, ROLE_LOW) + + e_w = event_eng.run(c_w, p_w, f_w, ROLE_MID) + e_d = event_eng.run(c_w, p_d, f_d, ROLE_LOW) s_d = signal_eng.run(e_d, p_d) decision = decision_eng.run(c_m, c_w, p_w, e_w, e_d, s_d) @@ -59,7 +65,14 @@ def analyze_symbol( } -def _to_row(trade_date: date, symbol: str, result: dict) -> WyckoffScanRow: +def _to_row( + trade_date: date, + symbol: str, + result: dict, + *, + combo_id: str, + combo_label: str, +) -> WyckoffScanRow: d = result["decision"] p = result["plan"] c_m, c_w, p_w = result["c_m"], result["c_w"], result["p_w"] @@ -67,6 +80,8 @@ def _to_row(trade_date: date, symbol: str, result: dict) -> WyckoffScanRow: f_d, f_w, f_m = result["f_d"], result["f_w"], result["f_m"] snapshot = { + "combo_id": combo_id, + "combo_label": combo_label, "daily": {k: f_d.payload.get(k) for k in ( "ma20", "ma60", "ma120", "atr", "adx", "volume_ratio", "range_high", "range_low", "swing_high", "swing_low", "close", @@ -82,7 +97,7 @@ def _to_row(trade_date: date, symbol: str, result: dict) -> WyckoffScanRow: return WyckoffScanRow( trade_date=trade_date, ts_code=symbol, - name=symbol, + name=display_name_cn(symbol), industry="crypto", engine_version=WYCKOFF_ENGINE_VERSION, m_cycle=c_m.payload.get("cycle", "Unknown"), @@ -120,6 +135,7 @@ def _to_row(trade_date: date, symbol: str, result: dict) -> WyckoffScanRow: feature_snapshot_json=json.dumps(snapshot, ensure_ascii=False), markers_json=json.dumps(markers, ensure_ascii=False), scanned_at=datetime.now(timezone.utc), + combo_id=combo_id, ) @@ -141,17 +157,25 @@ def _engines(): return _ENGINES -def analyze_and_store(symbol: str, trade_date: date | None = None) -> WyckoffScanRow | None: +def analyze_and_store( + symbol: str, + trade_date: date | None = None, + *, + combo_id: str | None = None, +) -> WyckoffScanRow | None: eng = _engines() - daily = load_frame(symbol, "1d", LOOKBACK["1d"]) - weekly = load_frame(symbol, "1w", LOOKBACK["1w"]) - monthly = load_frame(symbol, "1M", LOOKBACK["1M"]) - if daily is None or len(daily) < 40: + combo = get_combo(combo_id) + low_tf, mid_tf, high_tf = combo["low"], combo["mid"], combo["high"] + + low = load_frame(symbol, low_tf, lookback_for(low_tf)) + mid = load_frame(symbol, mid_tf, lookback_for(mid_tf)) + high = load_frame(symbol, high_tf, lookback_for(high_tf)) + if low is None or len(low) < 40: return None - result = analyze_symbol(daily, weekly, monthly, **eng) + result = analyze_symbol(low, mid, high, **eng) td = trade_date or ( - daily.trade_dates[-1] if daily.trade_dates else datetime.now(timezone.utc).date() + low.trade_dates[-1] if low.trade_dates else datetime.now(timezone.utc).date() ) - row = _to_row(td, symbol, result) + row = _to_row(td, symbol, result, combo_id=combo["id"], combo_label=combo["label"]) upsert_row(row) return row diff --git a/crypto_wyckoff/scheduler.py b/crypto_wyckoff/scheduler.py index 40cd45a..95b72b5 100644 --- a/crypto_wyckoff/scheduler.py +++ b/crypto_wyckoff/scheduler.py @@ -1,73 +1,70 @@ -"""Background 60s tip-update + rescan scheduler.""" +"""Background tip + scan scheduler for crypto wyckoff (all enabled combos).""" from __future__ import annotations import logging import threading -import time from datetime import datetime, timezone -from typing import Any +from crypto_wyckoff.combos import all_tfs_for_combos, list_combos 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] = { +_thread: threading.Thread | None = None +_stop = threading.Event() +_status: dict = { "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, + "backfill_done": False, } -_stop = threading.Event() -_thread: threading.Thread | None = None - - -def get_status() -> dict[str, Any]: - with _lock: - return dict(_status) +_status_lock = threading.Lock() def _set(**kwargs): - with _lock: + with _status_lock: _status.update(kwargs) +def get_status() -> dict: + with _status_lock: + return dict(_status) + + 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).""" + """One cycle: refresh symbols, tip-update, analyze each combo.""" symbols = fetch_symbols_from_provider() if max_symbols: symbols = symbols[:max_symbols] + combos = list_combos() + tfs = all_tfs_for_combos(combos) _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) + # Prefer low-TF of first combo for "enough history" gate + low0 = combos[0]["low"] if combos else "1d" + if bar_count(sym, low0) < 40: + backfill_symbol(sym, tfs) + tip_changed = tip_update_symbol(sym, tfs) 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 force_rescan or tip_changed: + for combo in combos: + row = analyze_and_store(sym, combo_id=combo["id"]) if row: scanned += 1 except Exception as e: @@ -90,11 +87,12 @@ def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict "scanned": scanned, "changed_tips": changed_n, "errors": errors, + "combos": [c["id"] for c in combos], + "tfs": tfs, } 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: @@ -102,7 +100,8 @@ def _loop(interval: int, max_symbols: int | None): _set(last_error=str(e), running=False) while not _stop.wait(interval): try: - run_tick(max_symbols=max_symbols, force_rescan=True) + # Tip-driven: only force full rescan when tips change is handled inside + run_tick(max_symbols=max_symbols, force_rescan=False) except Exception as e: logger.exception("tick failed: %s", e) _set(last_error=str(e), running=False) diff --git a/crypto_wyckoff/store.py b/crypto_wyckoff/store.py index 54415e1..540d3ec 100644 --- a/crypto_wyckoff/store.py +++ b/crypto_wyckoff/store.py @@ -1,8 +1,7 @@ -"""SQLite persistence for crypto wyckoff scan rows.""" +"""SQLite persistence for crypto wyckoff scan rows (per combo).""" from __future__ import annotations -import json import sqlite3 from datetime import datetime from typing import Any @@ -11,7 +10,7 @@ from crypto_wyckoff.domain_models import WyckoffScanRow from crypto_wyckoff.io import SCAN_DB, ensure_dirs _COLS = [ - "trade_date", "ts_code", "name", "industry", "engine_version", + "trade_date", "combo_id", "ts_code", "name", "industry", "engine_version", "m_cycle", "cycle_confidence", "trend_score", "w_cycle", "w_phase", "w_current_event", "w_recent_events_json", "phase_confidence", "structure_score", @@ -22,40 +21,80 @@ _COLS = [ "feature_snapshot_json", "markers_json", "scanned_at", ] +_CREATE_SQL = """ +CREATE TABLE IF NOT EXISTS wyckoff_scan ( + trade_date TEXT NOT NULL, + combo_id TEXT NOT NULL DEFAULT 'd_w_m', + ts_code TEXT NOT NULL, + name TEXT DEFAULT '', + industry TEXT DEFAULT '', + engine_version TEXT, + m_cycle TEXT, cycle_confidence REAL, trend_score REAL, + w_cycle TEXT, w_phase TEXT, w_current_event TEXT, w_recent_events_json TEXT, + phase_confidence REAL, structure_score REAL, + d_current_event TEXT, d_recent_events_json TEXT, event_confidence REAL, entry_score REAL, + entry REAL, stop REAL, target1 REAL, target2 REAL, rr REAL, + alignment REAL, stars INTEGER, decision_signal TEXT, signal_confidence REAL, + overall_confidence REAL, overall_score REAL, risk TEXT, reasons_json TEXT, + feature_snapshot_json TEXT, markers_json TEXT, scanned_at TEXT, + PRIMARY KEY (trade_date, combo_id, ts_code) +) +""" + + +def _migrate(c: sqlite3.Connection) -> None: + cur = c.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='wyckoff_scan'" + ) + if not cur.fetchone(): + c.execute(_CREATE_SQL) + c.execute( + "CREATE INDEX IF NOT EXISTS idx_cw_score " + "ON wyckoff_scan(trade_date, combo_id, overall_score DESC)" + ) + return + + cols = {r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan)")} + if "combo_id" in cols: + c.execute( + "CREATE INDEX IF NOT EXISTS idx_cw_score " + "ON wyckoff_scan(trade_date, combo_id, overall_score DESC)" + ) + return + + # Legacy PK (trade_date, ts_code) → add combo_id via table rebuild + c.execute("ALTER TABLE wyckoff_scan RENAME TO wyckoff_scan_old") + c.execute(_CREATE_SQL) + old_cols = [r[1] for r in c.execute("PRAGMA table_info(wyckoff_scan_old)")] + shared = [col for col in _COLS if col != "combo_id" and col in old_cols] + col_sql = ",".join(shared) + c.execute( + f""" + INSERT INTO wyckoff_scan (combo_id, {col_sql}) + SELECT 'd_w_m', {col_sql} FROM wyckoff_scan_old + """ + ) + c.execute("DROP TABLE wyckoff_scan_old") + c.execute( + "CREATE INDEX IF NOT EXISTS idx_cw_score " + "ON wyckoff_scan(trade_date, combo_id, overall_score DESC)" + ) + def _conn() -> sqlite3.Connection: ensure_dirs() c = sqlite3.connect(str(SCAN_DB), timeout=60) c.row_factory = sqlite3.Row - c.execute( - """ - CREATE TABLE IF NOT EXISTS wyckoff_scan ( - trade_date TEXT NOT NULL, - ts_code TEXT NOT NULL, - name TEXT DEFAULT '', - industry TEXT DEFAULT '', - engine_version TEXT, - m_cycle TEXT, cycle_confidence REAL, trend_score REAL, - w_cycle TEXT, w_phase TEXT, w_current_event TEXT, w_recent_events_json TEXT, - phase_confidence REAL, structure_score REAL, - d_current_event TEXT, d_recent_events_json TEXT, event_confidence REAL, entry_score REAL, - entry REAL, stop REAL, target1 REAL, target2 REAL, rr REAL, - alignment REAL, stars INTEGER, decision_signal TEXT, signal_confidence REAL, - overall_confidence REAL, overall_score REAL, risk TEXT, reasons_json TEXT, - feature_snapshot_json TEXT, markers_json TEXT, scanned_at TEXT, - PRIMARY KEY (trade_date, ts_code) - ) - """ - ) - c.execute( - "CREATE INDEX IF NOT EXISTS idx_cw_score ON wyckoff_scan(trade_date, overall_score DESC)" - ) + _migrate(c) + c.commit() return c def upsert_row(row: WyckoffScanRow) -> None: + combo_id = getattr(row, "combo_id", None) or "d_w_m" vals = ( row.trade_date.isoformat() if hasattr(row.trade_date, "isoformat") else str(row.trade_date), + combo_id, row.ts_code, row.name, row.industry, row.engine_version, row.m_cycle, row.cycle_confidence, row.trend_score, row.w_cycle, row.w_phase, row.w_current_event, row.w_recent_events_json, @@ -71,11 +110,15 @@ def upsert_row(row: WyckoffScanRow) -> None: try: placeholders = ",".join("?" * len(_COLS)) col_sql = ",".join(_COLS) - updates = ",".join(f"{c}=excluded.{c}" for c in _COLS if c not in ("trade_date", "ts_code")) + updates = ",".join( + f"{col}=excluded.{col}" + for col in _COLS + if col not in ("trade_date", "combo_id", "ts_code") + ) c.execute( f""" INSERT INTO wyckoff_scan ({col_sql}) VALUES ({placeholders}) - ON CONFLICT(trade_date, ts_code) DO UPDATE SET {updates} + ON CONFLICT(trade_date, combo_id, ts_code) DO UPDATE SET {updates} """, vals, ) @@ -84,23 +127,35 @@ def upsert_row(row: WyckoffScanRow) -> None: c.close() -def latest_trade_date() -> str | None: +def latest_trade_date(combo_id: str | None = None) -> str | None: c = _conn() try: - cur = c.execute("SELECT MAX(trade_date) FROM wyckoff_scan") + if combo_id: + cur = c.execute( + "SELECT MAX(trade_date) FROM wyckoff_scan WHERE combo_id=?", + (combo_id,), + ) + else: + cur = c.execute("SELECT MAX(trade_date) FROM wyckoff_scan") row = cur.fetchone() return row[0] if row and row[0] else None finally: c.close() -def count_for_date(trade_date: str | None = None) -> int: - td = trade_date or latest_trade_date() +def count_for_date(trade_date: str | None = None, combo_id: str | None = None) -> int: + td = trade_date or latest_trade_date(combo_id) if not td: return 0 c = _conn() try: - cur = c.execute("SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=?", (td,)) + if combo_id: + cur = c.execute( + "SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=? AND combo_id=?", + (td, combo_id), + ) + else: + cur = c.execute("SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=?", (td,)) return int(cur.fetchone()[0]) finally: c.close() @@ -109,6 +164,7 @@ def count_for_date(trade_date: str | None = None) -> int: def query_scan( *, trade_date: str | None = None, + combo_id: str | None = None, m_cycle: str | None = None, w_phase: str | None = None, d_event: str | None = None, @@ -119,14 +175,15 @@ def query_scan( limit: int = 100, offset: int = 0, ) -> list[dict[str, Any]]: - td = trade_date or latest_trade_date() + cid = combo_id or "d_w_m" + td = trade_date or latest_trade_date(cid) if not td: return [] sort_col = sort if sort in { "overall_score", "alignment", "entry_score", "trend_score", "structure_score", "stars" } else "overall_score" - clauses = ["trade_date=?"] - args: list[Any] = [td] + clauses = ["trade_date=?", "combo_id=?"] + args: list[Any] = [td, cid] if m_cycle: clauses.append("m_cycle=?") args.append(m_cycle) @@ -158,15 +215,20 @@ def query_scan( c.close() -def get_symbol(ts_code: str, trade_date: str | None = None) -> dict[str, Any] | None: - td = trade_date or latest_trade_date() +def get_symbol( + ts_code: str, + trade_date: str | None = None, + combo_id: str | None = None, +) -> dict[str, Any] | None: + cid = combo_id or "d_w_m" + td = trade_date or latest_trade_date(cid) if not td: return None c = _conn() try: cur = c.execute( - "SELECT * FROM wyckoff_scan WHERE trade_date=? AND ts_code=?", - (td, ts_code), + "SELECT * FROM wyckoff_scan WHERE trade_date=? AND combo_id=? AND ts_code=?", + (td, cid, ts_code), ) row = cur.fetchone() return dict(row) if row else None diff --git a/crypto_wyckoff/symbols_cn.py b/crypto_wyckoff/symbols_cn.py new file mode 100644 index 0000000..58a81fd --- /dev/null +++ b/crypto_wyckoff/symbols_cn.py @@ -0,0 +1,51 @@ +"""Crypto symbol → Chinese display name for screener UI.""" + +from __future__ import annotations + +# Base asset → 中文名(覆盖 provider 当前币对;未知则回退 base) +_BASE_CN: dict[str, str] = { + "BTC": "比特币", + "ETH": "以太坊", + "SOL": "索拉纳", + "XAU": "黄金", + "XAG": "白银", + "SAGA": "Saga", + "CL": "原油", + "ZEC": "大零币", + "XRP": "瑞波币", + "DOGE": "狗狗币", + "BNB": "币安币", + "SUI": "Sui", + "BILL": "Bill", + "BZ": "BZ", + "LAB": "Lab", + "TON": "通联币", + "CRCL": "Circle", + "SNDK": "SNDK", + "1000PEPE": "千倍佩佩", + "PEPE": "佩佩", + "CHIP": "CHIP", + "WIF": "狗帽子", +} + + +def base_asset(symbol: str) -> str: + """BTC/USDT:USDT → BTC;1000PEPE/USDT:USDT → 1000PEPE.""" + s = (symbol or "").strip() + if not s: + return "" + head = s.split(":")[0] + return head.split("/")[0].upper() if "/" in head else head.upper() + + +def display_name_cn(symbol: str) -> str: + base = base_asset(symbol) + if not base: + return symbol or "" + return _BASE_CN.get(base, base) + + +def symbol_name_map(symbols: list[str] | None = None) -> dict[str, str]: + if not symbols: + return {f"{k}/USDT:USDT": v for k, v in _BASE_CN.items()} + return {s: display_name_cn(s) for s in symbols} diff --git a/docs/ECR/ECR-009-crypto-wyckoff-screener.md b/docs/ECR/ECR-009-crypto-wyckoff-screener.md index ee64669..aeaae86 100644 --- a/docs/ECR/ECR-009-crypto-wyckoff-screener.md +++ b/docs/ECR/ECR-009-crypto-wyckoff-screener.md @@ -1,18 +1,20 @@ # ECR-009 **Title:** Crypto Wyckoff Screener 独立页(D/W/M) -**Status:** Approved(计划执行) +**Status:** Implementing **Date:** 2026-08-07 **Change Level:** L2 ## Change -新增 `crypto_wyckoff/` 包(移植 A_Share_DP 引擎)+ `/wyckoff_crypto` 页 + `/api/wyckoff_crypto/*`;本地缓存全量币对日/周/月 K 线;60s tip 更新。 +新增 `crypto_wyckoff/` 包(移植 A_Share_DP 引擎)+ `/wyckoff_crypto` 页 + `/api/wyckoff_crypto/*`;本地缓存 K 线;60s tip 更新。 + +周期组合:内置 `8h/4h/1h`(默认)与 `1d/1w/1M`;UI 下拉切换;可添加自定义高/中/低组合(规则引擎仍按 D/W/M 角色映射)。 ## Forbidden - 改缠论算法、主站叠层、`/api/analyze`、`config/`/`strategies/` -- 小周期;自动下单 +- 自动下单 ## Acceptance @@ -20,3 +22,4 @@ - [ ] 本地 `data/crypto_wyckoff/` 有 K 线与 scan - [ ] 调度可跑 tip 更新 - [ ] Decision 门闩单测通过 +- [ ] 下拉可选 `8h/4h/1h`,可添加新组合 diff --git a/docs/STATE/CURRENT.md b/docs/STATE/CURRENT.md index 2b58465..a28eade 100644 --- a/docs/STATE/CURRENT.md +++ b/docs/STATE/CURRENT.md @@ -22,6 +22,6 @@ ## Notes -- ECR-009:打开 http://localhost:8128/wyckoff_crypto ;可用 `CRYPTO_WYCKOFF_MAX_SYMBOLS` 限流 -- 月线由日线 UTC 聚合(provider 无 1M) +- ECR-009:打开 http://localhost:8128/wyckoff_crypto ;默认组合 `8h/4h/1h`,可下拉切 `1d/1w/1M` 或「添加组合」 +- 可用 `CRYPTO_WYCKOFF_MAX_SYMBOLS` 限流;月线仍由日线 UTC 聚合 - 未请求新 system tag diff --git a/tests/test_crypto_wyckoff_combos.py b/tests/test_crypto_wyckoff_combos.py new file mode 100644 index 0000000..fa2f80e --- /dev/null +++ b/tests/test_crypto_wyckoff_combos.py @@ -0,0 +1,31 @@ +"""Unit tests for TF combo validation.""" + +from __future__ import annotations + +import pytest + +from crypto_wyckoff.combos import ( + add_combo, + delete_combo, + get_combo, + list_combos, + validate_combo, +) + + +def test_builtin_default_is_h8_4_1(): + c = get_combo(None) + assert c["id"] == "h8_4_1" + assert (c["high"], c["mid"], c["low"]) == ("8h", "4h", "1h") + + +def test_validate_order(): + assert validate_combo("8h", "4h", "1h") is None + assert validate_combo("1h", "4h", "8h") is not None + assert validate_combo("8h", "8h", "1h") is not None + + +def test_list_includes_dwm(): + ids = {c["id"] for c in list_combos()} + assert "h8_4_1" in ids + assert "d_w_m" in ids diff --git a/web/api/analyze.py b/web/api/analyze.py index fdb6fa7..5c3931a 100644 --- a/web/api/analyze.py +++ b/web/api/analyze.py @@ -2,6 +2,9 @@ from flask import Blueprint, jsonify, request from services.runtime import * # noqa: F403 from services import runtime as R +# import * 不会带出下划线私有名;结构区缓存需显式导入 +from services.runtime.state import _zone_cache +from services.runtime.timeframes import _zone_cache_ttl bp = Blueprint("analyze", __name__) @@ -785,3 +788,77 @@ def analyze(): return jsonify(result) + +def _serialize_kl_tail(df, limit: int): + """只序列化最近 limit 根,供自动刷新增量合并。""" + if df is None or getattr(df, "empty", True): + return [] + tail = df.tail(limit) + clean = clean_dataframe_for_json(tail) + records = clean.to_dict("records") + for row in records: + d = row.get("date") + if hasattr(d, "isoformat"): + try: + row["date"] = d.isoformat() + except Exception: + row["date"] = str(d) + # timestamp 统一成 int ms,便于前端按 key 合并 + ts = row.get("timestamp") + if ts is not None: + try: + row["timestamp"] = int(ts) + except (TypeError, ValueError): + pass + elif hasattr(d, "timestamp"): + try: + row["timestamp"] = int(d.timestamp() * 1000) + except Exception: + pass + return records + + +@bp.route("/api/klines/recent") +def klines_recent(): + """轻量拉取最近 N 根 K 线(不做缠论/威科夫),供主站自动刷新增量。""" + symbol = (request.args.get("symbol") or "").strip() + if not symbol: + return jsonify({"error": "交易对不能为空"}), 400 + + timeframe = request.args.get("timeframe", "5m") + try: + limit = int(request.args.get("limit", 2)) + except (TypeError, ValueError): + limit = 2 + limit = max(1, min(limit, 20)) + + element_timeframe = request.args.get("element_timeframe") or None + sub_sub_timeframe = request.args.get("sub_sub_timeframe") or None + + # 只取尾部:不传 start/end,避免全量窗口回拉 + df = get_kl_data(symbol, timeframe, limit=limit) + if df is None: + return jsonify({"error": "获取数据失败"}), 502 + if len(df) == 0: + return jsonify({"error": "没有数据"}), 404 + + result = { + "partial": True, + "symbol": symbol, + "timeframe": timeframe, + "limit": limit, + "kline_data": _serialize_kl_tail(df, limit), + } + + if element_timeframe: + edf = get_kl_data(symbol, element_timeframe, limit=limit) + result["element_timeframe"] = element_timeframe + result["element_kline_data"] = _serialize_kl_tail(edf, limit) if edf is not None else [] + + if sub_sub_timeframe: + sdf = get_kl_data(symbol, sub_sub_timeframe, limit=limit) + result["sub_sub_timeframe"] = sub_sub_timeframe + result["sub_sub_kline_data"] = _serialize_kl_tail(sdf, limit) if sdf is not None else [] + + return jsonify(result) + diff --git a/web/api/wyckoff_crypto.py b/web/api/wyckoff_crypto.py index e061ab2..8ee3e1c 100644 --- a/web/api/wyckoff_crypto.py +++ b/web/api/wyckoff_crypto.py @@ -7,9 +7,17 @@ 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__) @@ -32,6 +40,18 @@ def ensure_scheduler() -> None: _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() @@ -41,24 +61,65 @@ def page(): @bp.route("/api/wyckoff_crypto/meta") def meta(): ensure_scheduler() - latest = wyckoff_store.latest_trade_date() + 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), + "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": "UTC", - "timeframes": ["1d", "1w", "1M"], + "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() @@ -68,8 +129,10 @@ def 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"), @@ -77,16 +140,19 @@ def scan(): 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=min(int(request.args.get("limit") or 100), 500), - offset=int(request.args.get("offset") or 0), + limit=_safe_int(request.args.get("limit"), 100, lo=1, hi=500), + offset=_safe_int(request.args.get("offset"), 0, lo=0), ) - return jsonify({"rows": rows, "count": len(rows)}) + 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() - row = wyckoff_store.get_symbol(symbol, request.args.get("trade_date")) + 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) @@ -96,8 +162,9 @@ def symbol_detail(symbol: str): def manual_tick(): """Manual one-shot tick (debug). Optional JSON/query max_symbols.""" ensure_scheduler() - max_sym = request.args.get("max_symbols") or (request.json or {}).get("max_symbols") - max_symbols = int(max_sym) if max_sym else None + 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: @@ -109,6 +176,57 @@ def manual_tick(): 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 diff --git a/web/services/runtime/timeframes.py b/web/services/runtime/timeframes.py index df52024..deb6a1d 100644 --- a/web/services/runtime/timeframes.py +++ b/web/services/runtime/timeframes.py @@ -94,19 +94,19 @@ def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys): def compute_timeframe_defaults(labels_ordered): """ 根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。 - 默认偏好:主 4h、次 2h、次次 1h(威科夫与结构在小时级更可读)。 + 默认偏好:主 4h、次 1h、次次 15m。 labels_ordered: OrderedDict 或按插入顺序排列的 dict。 """ if not labels_ordered: labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy() timeframe_keys = list(labels_ordered.keys()) - preferred_main = next((tf for tf in ['4h', '2h', '1h'] if tf in labels_ordered), None) + preferred_main = next((tf for tf in ['4h', '1h', '15m'] if tf in labels_ordered), None) default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m') if default_main not in labels_ordered and timeframe_keys: default_main = timeframe_keys[0] - default_element = _prefer_smaller(['2h', '1h'], labels_ordered, default_main, timeframe_keys) - default_sub_sub = _prefer_smaller(['1h'], labels_ordered, default_element, timeframe_keys) + default_element = _prefer_smaller(['1h', '15m'], labels_ordered, default_main, timeframe_keys) + default_sub_sub = _prefer_smaller(['15m', '5m'], labels_ordered, default_element, timeframe_keys) return default_main, default_element, default_sub_sub, timeframe_keys diff --git a/web/static/js/app/chart_sync.js b/web/static/js/app/chart_sync.js index 23c9c62..cf80957 100644 --- a/web/static/js/app/chart_sync.js +++ b/web/static/js/app/chart_sync.js @@ -9,11 +9,26 @@ function updateTradingViewData() { return; } - // 保存当前的可视范围 + // 优先用请求前冻结的视窗;否则现场拍(自动刷新短间隔 delta≈0,两种都稳) + const frozen = window._preserveViewOnRefresh; + const oldBarCount = window._preserveViewBarCount || 0; + let savedScrollPosition = null; if (tvWidget.mainChart) { - tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange(); - tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange(); + const ts = tvWidget.mainChart.timeScale(); + if (frozen) { + tvWidget.state.visibleRange = frozen.visibleRange; + tvWidget.state.logicalRange = frozen.logicalRange; + savedScrollPosition = (typeof frozen.scrollPosition === 'number') ? frozen.scrollPosition : null; + } else { + tvWidget.state.visibleRange = ts.getVisibleRange(); + tvWidget.state.logicalRange = ts.getVisibleLogicalRange(); + try { + savedScrollPosition = ts.scrollPosition ? ts.scrollPosition() : null; + } catch (e) {} + } } + window._preserveViewOnRefresh = null; + window._preserveViewBarCount = 0; // 检查是否显示原始K线 const showOriginalKline = $('#showOriginalKline').is(':checked'); @@ -71,6 +86,9 @@ function updateTradingViewData() { }; }); } + + const newBarCount = candles.length; + const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0; // 更新主系列数据(根据klineType) const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); @@ -270,23 +288,54 @@ function updateTradingViewData() { // 更新EMA52显示 updateEMA52Display(currentData); - // 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐 + // 与自动刷新一致:增量更新绝不碰 barSpacing(缩放本来就留在图表实例上)。 + // 一写 barSpacing,LWC 会按右边缘重锚 → 放大往右、缩小往左。 + // 这里只在 setData 之后把位置扳回刷新前的 logical / time 窗口。 if (tvWidget.mainChart) { - if (tvWidget.state.visibleRange) { - console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange); - tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); - if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); - if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); - if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); - if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange); - } else if (tvWidget.state.logicalRange) { - console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange); - tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); - if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); - if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); - if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); - if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange); - } + const charts = [ + tvWidget.mainChart, + tvWidget.volumeChart, + tvWidget.atrChart, + tvWidget.macdChart, + tvWidget.chanMacdChart + ].filter(Boolean); + + const vr = tvWidget.state.visibleRange; + const lr = tvWidget.state.logicalRange; + const savedScroll = savedScrollPosition; + + const applyPosition = function (tag) { + let ok = false; + if (lr && lr.from !== undefined && lr.to !== undefined) { + charts.forEach(c => { + try { + c.timeScale().setVisibleLogicalRange({ from: lr.from, to: lr.to }); + ok = true; + } catch (e) {} + }); + if (ok) console.log('🔄 恢复位置 logical' + (tag || '') + ':', lr); + } + if (!ok && vr && vr.from !== undefined && vr.to !== undefined) { + charts.forEach(c => { + try { + c.timeScale().setVisibleRange(vr); + ok = true; + } catch (e) {} + }); + if (ok) console.log('🔄 恢复位置 time' + (tag || '') + ':', vr); + } + if (!ok && typeof savedScroll === 'number') { + const pos = savedScroll + (barDelta || 0); + charts.forEach(c => { + try { c.timeScale().scrollToPosition(pos, false); } catch (e) {} + }); + console.log('🔄 恢复位置 scroll' + (tag || '') + ':', pos); + } + }; + + applyPosition(''); + setTimeout(function () { applyPosition('@0'); }, 0); + setTimeout(function () { applyPosition('@50'); }, 50); } console.log('增量更新图表完成'); diff --git a/web/static/js/app/chart_tv_finalize.js b/web/static/js/app/chart_tv_finalize.js index 7186bb1..cdaac8f 100644 --- a/web/static/js/app/chart_tv_finalize.js +++ b/web/static/js/app/chart_tv_finalize.js @@ -24,15 +24,13 @@ function chartTvFinalize(ctx) { var chanMacdChart = ctx.chanMacdChart; var createChartOptions = ctx.createChartOptions; // 同步所有图表的时间轴配置 + const hasPendingRestoreView = !!window._pendingRestoreView; + const pendingView = window._pendingRestoreView; const syncTimeScaleSettings = () => { - // 获取主图表的时间轴设置 - const mainTimeScale = mainChart.timeScale(); const baseOptions = { timeVisible: true, secondsVisible: false, borderColor: '#ddd', - barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10, - rightOffset: 12, lockVisibleTimeRangeOnResize: true, // 关键:确保所有图表边缘行为完全一致 fixLeftEdge: false, @@ -41,6 +39,12 @@ function chartTvFinalize(ctx) { ticksVisible: true, minimumHeight: 0, }; + // 有待恢复视图时不要先写 barSpacing/rightOffset(会钉右缘导致图往右偏), + // 交给后面 setVisibleRange 一次锁定位置+缩放。 + if (!pendingView) { + baseOptions.barSpacing = symbolConfig.type === 'a_stock' ? 6 : 10; + baseOptions.rightOffset = 12; + } console.log('🔧 同步时间轴设置:', baseOptions); @@ -59,8 +63,12 @@ function chartTvFinalize(ctx) { // 仅在没有待恢复视图时,设置默认可见范围 const totalBars = candles ? candles.length : 0; const visibleBarsCount = 200; - const hasPendingRestoreView = !!window._pendingRestoreView; - if (!hasPendingRestoreView) { + const allChartsNow = [mainChart, volumeChart, atrChart] + .concat(showMacd && macdChart ? [macdChart] : []) + .concat(showMacd && chanMacdChart ? [chanMacdChart] : []); + if (hasPendingRestoreView && pendingView) { + restoreChartViewState(allChartsNow, pendingView, { preferTime: true }); + } else { // 显示最近 200 根K线而非全部挤压(避免K线过多时重叠) if (totalBars > visibleBarsCount) { const rangeFrom = totalBars - visibleBarsCount; @@ -71,8 +79,12 @@ function chartTvFinalize(ctx) { } } - // 立即同步其他图表到主图表的范围 + // 立即同步其他图表到主图表的范围(无 pending 时) setTimeout(() => { + if (window._pendingRestoreView) { + restoreChartViewState(allChartsNow, window._pendingRestoreView, { preferTime: true }); + return; + } const logRange = mainChart.timeScale().getVisibleLogicalRange(); if (logRange) { console.log('🔧 同步可见范围:', logRange); @@ -120,11 +132,10 @@ function chartTvFinalize(ctx) { } const defaultMAs = [ - { type: 'EMA', length: 13, color: '#800080', name: 'EMA13', visible: true }, // 紫色 - { type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: true }, // 橙色 - { type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: false }, // 黑色 - { type: 'EMA', length: 104, color: '#1E90FF', name: 'EMA104', visible: false }, // 蓝色 - { type: 'EMA', length: 156, color: '#F700FF', name: 'EMA156', visible: false } // 粉色 + { type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: false }, // 橙色 + { type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: true }, // 黑色 · 默认开 + { type: 'SMA', length: 30, color: '#1E90FF', name: 'MA30', visible: true }, // 蓝色 · 默认开 + { type: 'SMA', length: 250, color: '#800080', name: 'MA250', visible: true } // 紫色 · 默认开 ]; defaultMAs.forEach(ma => { @@ -191,9 +202,9 @@ function chartTvFinalize(ctx) { window._pendingRestoreView = null; if (pending) { - // 恢复刷新前的缩放和位置(优先可见范围/逻辑范围,最后回退到滚动位置) + // 恢复刷新前的缩放和位置(时间范围优先,避免数据滑动后逻辑索引错位) console.log('📌 恢复图表视图:', JSON.stringify(pending)); - restoreChartViewState(allCharts, pending); + restoreChartViewState(allCharts, pending, { preferTime: true }); } else { // 无保存视图,正常同步主图到子图 const visibleRange = mainChart.timeScale().getVisibleRange(); diff --git a/web/static/js/app/chart_view.js b/web/static/js/app/chart_view.js index abac451..569201f 100644 --- a/web/static/js/app/chart_view.js +++ b/web/static/js/app/chart_view.js @@ -1,4 +1,46 @@ /* chart_view.js — split from chart.js */ + +/** 用尾部 N 根合并进已有 K 线(同 timestamp 覆盖,更新则追加) */ +function mergeKlineTail(existing, incoming) { + if (!Array.isArray(incoming) || !incoming.length) { + return Array.isArray(existing) ? existing : []; + } + if (!Array.isArray(existing) || !existing.length) { + return incoming.slice(); + } + const out = existing.slice(); + const barTs = (row) => { + if (row && row.timestamp != null && row.timestamp !== '') { + const n = Number(row.timestamp); + if (!Number.isNaN(n)) return n; + } + const t = row && row.date != null ? new Date(row.date).getTime() : NaN; + return Number.isNaN(t) ? null : t; + }; + for (let i = 0; i < incoming.length; i++) { + const row = incoming[i]; + const ts = barTs(row); + if (ts == null) continue; + let idx = -1; + const scanFrom = Math.max(0, out.length - 8); + for (let j = out.length - 1; j >= scanFrom; j--) { + if (barTs(out[j]) === ts) { + idx = j; + break; + } + } + if (idx >= 0) { + out[idx] = Object.assign({}, out[idx], row); + } else { + const lastTs = barTs(out[out.length - 1]); + if (lastTs == null || ts > lastTs) { + out.push(row); + } + } + } + return out; +} + function updateChart(options) { options = options || {}; // 只显示旋转加载图标 @@ -47,9 +89,79 @@ function updateChart(options) { if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) { try { window._analyzeXhr.abort(); } catch (e) {} } + + // 请求发出前冻结视窗(与自动刷新同一套;避免等响应时/setData 后 logical 索引漂移) + try { + if (tvWidget && tvWidget.mainChart) { + window._preserveViewOnRefresh = captureChartViewState(tvWidget.mainChart); + const prev = currentData && ( + ($('#subSubPeriodKline').is(':checked') && currentData.sub_sub_kline_data) || + ($('#elementPeriodKline').is(':checked') && currentData.element_kline_data) || + currentData.kline_data + ); + window._preserveViewBarCount = Array.isArray(prev) ? prev.length : 0; + console.log('📌 刷新前冻结视窗 bars=', window._preserveViewBarCount, window._preserveViewOnRefresh); + } + } catch (e) { + window._preserveViewOnRefresh = null; + window._preserveViewBarCount = 0; + } + + const requestId = ++lastRequestId; + const chartsReady = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart); + const hasBaseline = !!(currentData && Array.isArray(currentData.kline_data) && currentData.kline_data.length); + // 自动刷新常态:只拉最近 2 根;fullAnalyze(约每 1 分钟)走全量 analyze 更新缠论 + const useRecentTail = !!(options.fromAutoRefresh && !options.fullAnalyze && chartsReady && hasBaseline); + + if (useRecentTail) { + console.log('自动刷新 → /api/klines/recent limit=2'); + window._analyzeXhr = $.ajax({ + url: '/api/klines/recent', + data: { + symbol: symbol, + timeframe: timeframe, + limit: 2, + element_timeframe: elementTimeframe || undefined, + sub_sub_timeframe: subSubTimeframe || undefined + }, + success: function(partial) { + $('#refreshLoadingSpinner').hide(); + if (requestId !== lastRequestId) return; + if (!partial || !Array.isArray(partial.kline_data)) { + console.warn('recent 响应无效,回退全量 analyze'); + updateChart({ incremental: true, reason: 'recent-fallback' }); + return; + } + currentData.kline_data = mergeKlineTail(currentData.kline_data, partial.kline_data); + if (Array.isArray(partial.element_kline_data)) { + currentData.element_kline_data = mergeKlineTail( + currentData.element_kline_data, partial.element_kline_data + ); + if (partial.element_timeframe) { + currentData.element_timeframe = partial.element_timeframe; + } + } + if (Array.isArray(partial.sub_sub_kline_data)) { + currentData.sub_sub_kline_data = mergeKlineTail( + currentData.sub_sub_kline_data, partial.sub_sub_kline_data + ); + if (partial.sub_sub_timeframe) { + currentData.sub_sub_timeframe = partial.sub_sub_timeframe; + } + } + refreshChart(currentData, { incremental: true, skipTables: true }); + }, + error: function(jqXHR, textStatus, errorThrown) { + $('#refreshLoadingSpinner').hide(); + if (textStatus === 'abort') return; + console.warn('recent 失败,回退全量 analyze:', errorThrown); + updateChart({ incremental: true, reason: 'recent-error-fallback' }); + } + }); + return; + } - // 发送请求 - const requestId = ++lastRequestId; // 标记本次请求 + // 手动 / 首拉:全量 analyze window._analyzeXhr = $.ajax({ url: '/api/analyze', data: { @@ -81,15 +193,21 @@ function updateChart(options) { delete currentData.original_macd; } currentData = data; + window._lastFullAnalyzeAt = Date.now(); if (typeof renderWyckoffCycleSummary === 'function') { renderWyckoffCycleSummary(); } - refreshChart(data, { - incremental: options.incremental !== undefined - ? !!options.incremental - : !!options.fromAutoRefresh - }); + // 有图则增量;笔/段/中枢/结构区只在全量 init 绘制,fullAnalyze 必须重建 + const ready = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart); + const structureZonesOn = $('#showMainStructureZone').is(':checked'); + let wantIncremental = options.incremental !== undefined + ? !!options.incremental + : (ready || !!options.fromAutoRefresh); + if (structureZonesOn || options.fullAnalyze) { + wantIncremental = false; + } + refreshChart(data, { incremental: wantIncremental }); }, error: function(jqXHR, textStatus, errorThrown) { // 隐藏加载图标 @@ -121,24 +239,21 @@ function captureChartViewState(chart) { } function restoreChartViewState(charts, viewState) { + // 全量重建备用:先缩放,再位置;不要在位置前写 rightOffset(会右边缘锚定) if (!viewState || !Array.isArray(charts) || charts.length === 0) return; const validCharts = charts.filter(c => c && c.timeScale); if (validCharts.length === 0) return; validCharts.forEach(c => { try { - const optionsPatch = {}; - if (typeof viewState.barSpacing === 'number') optionsPatch.barSpacing = viewState.barSpacing; - if (typeof viewState.rightOffset === 'number') optionsPatch.rightOffset = viewState.rightOffset; - if (Object.keys(optionsPatch).length) { - c.timeScale().applyOptions(optionsPatch); + if (typeof viewState.barSpacing === 'number') { + c.timeScale().applyOptions({ barSpacing: viewState.barSpacing }); } } catch (e) {} }); let restored = false; - // 优先按逻辑范围恢复(对新数据更稳健) if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) { validCharts.forEach(c => { try { @@ -148,7 +263,6 @@ function restoreChartViewState(charts, viewState) { }); } - // 逻辑范围失败时,回退到时间可见范围 if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) { validCharts.forEach(c => { try { @@ -158,7 +272,6 @@ function restoreChartViewState(charts, viewState) { }); } - // 最后回退到滚动位置 if (!restored && typeof viewState.scrollPosition === 'number') { validCharts.forEach(c => { try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {} diff --git a/web/static/js/app/macd_ui.js b/web/static/js/app/macd_ui.js index 5d9bfe2..6f94d4b 100644 --- a/web/static/js/app/macd_ui.js +++ b/web/static/js/app/macd_ui.js @@ -85,9 +85,9 @@ $(document).on('change', '#showMainBiZs', function() { $(document).on('change', '#showMainStructureZone', function() { const on = $('#showMainStructureZone').is(':checked'); console.log('结构区切换为:', on); - // 勾选后才向服务器请求多周期结构区数据;取消勾选仅重绘,不重复拉取 + // 勾选后才向服务器请求多周期结构区数据;结构区叠层只在全量 init 里绘制,必须 incremental:false if (on) { - updateChart(); + updateChart({ incremental: false }); } else { updateChartDisplay(); } diff --git a/web/static/js/app/ui.js b/web/static/js/app/ui.js index 9c3d294..3e5d3aa 100644 --- a/web/static/js/app/ui.js +++ b/web/static/js/app/ui.js @@ -272,10 +272,10 @@ function loadSymbols() { }); } -// 设置默认时间范围(需覆盖威科夫 lookback;1 天在 4h/1h 上几乎检不出区间) +// 设置默认时间范围:最近 1 个月 function setDefaultTimeRange() { const now = new Date(); - const daysBack = 14; + const daysBack = 30; const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000)); // 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm @@ -493,6 +493,9 @@ $(document).ready(function() { let autoRefreshTimer = null; let nextRefreshTime = null; let autoRefreshTick = 0; +/** 自动刷新时,缠论全量重算间隔(毫秒);时间戳见 window._lastFullAnalyzeAt */ +const AUTO_FULL_ANALYZE_MS = 60 * 1000; + // 初始化自动刷新功能 function initAutoRefresh() { // 监听自动刷新勾选框变化 @@ -519,10 +522,10 @@ function startAutoRefresh() { stopAutoRefresh(); // 获取刷新频率(分钟) - const interval = parseFloat($('#refreshInterval').val()) || 5; + const interval = parseFloat($('#refreshInterval').val()) || (5 / 60); const intervalMs = interval * 60 * 1000; - console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒)`); + console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒);缠论全量每 ${AUTO_FULL_ANALYZE_MS / 1000}s`); // 计算下次刷新时间 nextRefreshTime = new Date(Date.now() + intervalMs); @@ -531,16 +534,36 @@ function startAutoRefresh() { // 启动定时器 autoRefreshTick = 0; autoRefreshTimer = setInterval(function() { - // 更新结束时间为当前时间 + // 刷新前先钉住当前缩放/位置(updateEndTime / 请求返回前都可能被改写) + if (tvWidget && tvWidget.mainChart && typeof captureChartViewState === 'function') { + try { + window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); + } catch (e) { + window._pendingRestoreView = null; + } + } + + // 更新结束时间显示(仅 UI) updateEndTimeToNow(); - // 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏) autoRefreshTick += 1; - const fullRebuild = (autoRefreshTick % 6) === 0; - updateChart({ - fromAutoRefresh: true, - incremental: !fullRebuild - }); + const now = Date.now(); + const lastFull = window._lastFullAnalyzeAt || 0; + const needFullAnalyze = !lastFull || (now - lastFull >= AUTO_FULL_ANALYZE_MS); + // 常态:/api/klines/recent 合并尾部 K;满 1 分钟:全量 /api/analyze 刷新缠论 + if (needFullAnalyze) { + console.log('自动刷新 → 全量缠论 analyze(距上次', lastFull ? Math.round((now - lastFull) / 1000) + 's' : '首次', ')'); + updateChart({ + fromAutoRefresh: true, + fullAnalyze: true, + incremental: true + }); + } else { + updateChart({ + fromAutoRefresh: true, + incremental: true + }); + } // 更新下次刷新时间 nextRefreshTime = new Date(Date.now() + intervalMs); @@ -784,7 +807,8 @@ function refreshChart(data, options) { // 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts if (preferIncremental && chartsReady) { try { - if (tvWidget.mainChart) { + // 若定时器已捕获则保留;否则此刻再捕获一次 + if (!window._pendingRestoreView && tvWidget.mainChart) { try { window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); } catch (e) { @@ -792,7 +816,10 @@ function refreshChart(data, options) { } } updateTradingViewData(); - updateTables(data); + // recent-tail 刷新结构未变,跳过表格重绘以提速 + if (!options.skipTables) { + updateTables(data); + } if (currentData && currentData.ema52_dict) { updateEMA52Display(currentData); } @@ -804,7 +831,7 @@ function refreshChart(data, options) { // 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window // tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上 - if (tvWidget && tvWidget.mainChart) { + if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) { try { window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView)); @@ -812,6 +839,8 @@ function refreshChart(data, options) { console.warn('保存图表视图失败:', e); window._pendingRestoreView = null; } + } else if (window._pendingRestoreView) { + console.log('📌 使用已保存图表视图:', JSON.stringify(window._pendingRestoreView)); } initTradingView($('#symbol').val(), $('#timeframe').val()); diff --git a/web/templates/index.html b/web/templates/index.html index 15972bd..128bba3 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -982,7 +982,7 @@
-
@@ -1028,14 +1028,14 @@
@@ -1383,24 +1383,24 @@
- - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + +
diff --git a/web/templates/wyckoff_crypto.html b/web/templates/wyckoff_crypto.html index 279a23e..7c3eb34 100644 --- a/web/templates/wyckoff_crypto.html +++ b/web/templates/wyckoff_crypto.html @@ -17,13 +17,49 @@ header a{color:var(--accent);font-size:12px;text-decoration:none} .filters select,.filters input{background:#0d1117;border:1px solid var(--border);color:var(--text);border-radius:4px;padding:4px 6px;font-size:11px;min-width:100px} .filters button{background:var(--accent);border:0;color:#fff;border-radius:4px;padding:6px 12px;font-size:11px;cursor:pointer} .filters button.secondary{background:transparent;border:1px solid var(--border);color:var(--muted)} -.main{flex:1;display:grid;grid-template-columns:1fr 340px;min-height:0} -@media(max-width:900px){.main{grid-template-columns:1fr}} -.pane{min-height:0;overflow:auto;border-right:1px solid var(--border)} -.pane:last-child{border-right:0;background:var(--panel)} -.pane h2{font-size:10px;color:var(--muted);letter-spacing:1px;padding:8px 10px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--panel)} -table{width:100%;border-collapse:collapse;font-size:11px} -th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:600;position:sticky;top:28px;background:var(--panel)} +.combo-add{display:none;gap:8px;flex-wrap:wrap;align-items:flex-end;padding:8px 14px;border-bottom:1px solid var(--border);background:#0d1117} +.combo-add.open{display:flex} +.combo-add .err{color:var(--red);font-size:11px;align-self:center} +.main{flex:1;display:flex;min-height:0;overflow:hidden} +.pane{min-height:0;min-width:120px} +.pane#paneList{ + flex:0 0 var(--pane-left, 360px);width:var(--pane-left, 360px); + background:var(--panel);display:flex;flex-direction:column;overflow:hidden +} +.pane.chart-pane{ + flex:1 1 auto;min-width:200px;display:flex;flex-direction:column; + overflow:hidden;background:#0d1117 +} +.pane#paneDetail{ + flex:0 0 var(--pane-right, 300px);width:var(--pane-right, 300px); + background:var(--panel);display:flex;flex-direction:column;overflow:hidden +} +.splitter{flex:0 0 5px;cursor:col-resize;background:var(--border);position:relative;z-index:2} +.splitter:hover,.splitter.active{background:var(--accent)} +.splitter::after{content:'';position:absolute;inset:-2px -3px} +@media(max-width:900px){ + .main{flex-direction:column;overflow:auto} + .splitter{display:none} + .pane#paneList,.pane#paneDetail,.pane.chart-pane{flex:none;width:100%!important;min-height:280px} +} +.pane h2{ + font-size:10px;color:var(--muted);letter-spacing:1px;padding:8px 10px; + border-bottom:1px solid var(--border);background:var(--panel); + display:flex;align-items:center;justify-content:space-between;gap:8px;z-index:1;flex:0 0 auto +} +.chart-pane h2{background:#0d1117} +.chart-tools{display:flex;gap:4px} +.chart-tools button{background:transparent;border:1px solid var(--border);color:var(--muted);border-radius:3px;padding:2px 8px;font-size:10px;cursor:pointer} +.chart-tools button.on{border-color:var(--accent);color:var(--accent)} +#chartWrap{flex:1;min-height:200px;position:relative;background:#0d1117} +#chartCanvas{width:100%;height:100%;display:block} +#chartHint{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--muted);font-size:12px;pointer-events:none} +#chartTip{position:absolute;left:10px;top:8px;font-size:11px;color:var(--muted);background:rgba(13,17,23,.85);padding:4px 8px;border-radius:4px;pointer-events:none;max-width:78%} +#chartLegend{position:absolute;right:10px;top:8px;font-size:10px;color:var(--muted);background:rgba(13,17,23,.85);padding:4px 8px;border-radius:4px;pointer-events:none;line-height:1.55;text-align:right} +.table-wrap{overflow:auto;flex:1;min-height:0} +table{width:max-content;min-width:100%;border-collapse:collapse;font-size:11px} +th,td{white-space:nowrap} +th{text-align:left;padding:6px 8px;color:var(--muted);font-weight:600;position:sticky;top:0;background:var(--panel);z-index:1} td{padding:6px 8px;border-top:1px solid rgba(255,255,255,.04);cursor:pointer} tr:hover td{background:rgba(88,166,255,.08)} tr.active td{background:rgba(88,166,255,.16)} @@ -31,7 +67,7 @@ tr.active td{background:rgba(88,166,255,.16)} .tag.StrongBuy,.tag.Buy{background:rgba(63,185,80,.15);color:var(--green)} .tag.Sell,.tag.Avoid{background:rgba(248,81,73,.15);color:var(--red)} .tag.Watch{background:rgba(210,153,29,.15);color:var(--orange)} -#detail{padding:12px;font-size:12px;line-height:1.55;color:var(--muted)} +#detail{flex:1;overflow:auto;padding:12px;font-size:12px;line-height:1.55;color:var(--muted)} #detail h3{color:var(--text);font-size:13px;margin-bottom:8px} #detail .kv{margin:4px 0} #detail .kv b{color:var(--text);display:inline-block;min-width:88px} @@ -47,20 +83,25 @@ tr.active td{background:rgba(88,166,255,.16)}
← 主站 - · D/W/M · UTC · 每分钟 tip + · 周期组合 · UTC+8 · tip
+ + + -
-
-
-

SCAN

+
+ + + + + + + +
+
+
+

扫描

+
- - + +
SymbolDecisionM CycleW PhaseD EventScoreAlignSymbol决策高周期中阶段低事件得分一致
- +
+
-
-

DETAIL

+
+
+

+ K 线 + +

+
+ +
+
+
选择左侧标的查看 K 线
+
+
+
+
+

详情

点选左侧一行
@@ -101,27 +167,608 @@ tr.active td{background:rgba(88,166,255,.16)} var meta = null; var rows = []; var active = null; + var combo = {id:'h8_4_1', label:'8h / 4h / 1h', high:'8h', mid:'4h', low:'1h'}; + var chartFreq = '1h'; + var chartSymbol = ''; + var chartBars = []; + var chartHover = -1; + var overlay = {phases:[], events:[], levels:{}, zones:[], cycles:[]}; + var planLevels = []; + var allowedTfs = []; + var chartReqSeq = 0; - function fillSelect(id, values){ + var PHASE_COLOR = { + A:'rgba(88,166,255,.14)', B:'rgba(139,148,158,.12)', C:'rgba(210,153,29,.18)', + D:'rgba(63,185,80,.16)', E:'rgba(63,185,80,.28)', None:'rgba(0,0,0,0)', + Accumulation:'rgba(88,166,255,.14)', ReAccumulation:'rgba(88,166,255,.10)', + Markup:'rgba(63,185,80,.18)', Distribution:'rgba(248,81,73,.14)', + ReDistribution:'rgba(248,81,73,.10)', Markdown:'rgba(248,81,73,.20)', Unknown:'rgba(0,0,0,0)', + Range:'rgba(210,153,29,.12)' + }; + var ZONE_FILL = { + Accumulation:'rgba(88,166,255,.22)', ReAccumulation:'rgba(88,166,255,.16)', + Distribution:'rgba(248,81,73,.18)', ReDistribution:'rgba(248,81,73,.14)', + Range:'rgba(210,153,29,.14)' + }; + var ZONE_EDGE = { + Accumulation:'#58a6ff', ReAccumulation:'#79b8ff', + Distribution:'#f85149', ReDistribution:'#ff7b72', Range:'#d2991d' + }; + var ZONE_LABEL = { + Accumulation:'吸筹区间', ReAccumulation:'再吸筹区间', + Distribution:'派发区间', ReDistribution:'再派发区间', Range:'震荡区间' + }; + var PHASE_EDGE = { + A:'#58a6ff', B:'#8b949e', C:'#d2991d', D:'#3fb950', E:'#2ea043', None:'transparent', + Accumulation:'#58a6ff', ReAccumulation:'#79b8ff', Markup:'#3fb950', + Distribution:'#f85149', ReDistribution:'#ff7b72', Markdown:'#da3633', Unknown:'transparent' + }; + var EVENT_COLOR = { + Spring:'#d2991d', Test:'#d2991d', LPS:'#3fb950', SOS:'#3fb950', Jump:'#3fb950', + Backup:'#58a6ff', SC:'#f85149', SOW:'#f85149', UTAD:'#f85149', BC:'#f85149', + AR:'#58a6ff', ST:'#8b949e', PS:'#f85149', LPSY:'#f85149' + }; + var ZH = { + Accumulation:'吸筹', ReAccumulation:'再吸筹', Markup:'拉升', + Distribution:'派发', ReDistribution:'再派发', Markdown:'下跌', Unknown:'未知', Range:'震荡', + A:'阶段甲', B:'阶段乙', C:'阶段丙', D:'阶段丁', E:'阶段戊', None:'无', + PS:'初步供应', SC:'卖出高潮', AR:'自动反弹', ST:'二次测试', + Spring:'弹簧', Test:'回测', SOS:'强势信号', LPS:'最后支撑', + Jump:'跳跃', Backup:'回踩', BC:'买入高潮', UTAD:'上冲失败', + SOW:'弱势信号', LPSY:'最后供应', + StrongBuy:'强烈买入', Buy:'买入', Watch:'观察', Avoid:'回避', Sell:'卖出', + Low:'低', Medium:'中', High:'高' + }; + function zh(v){ return (v==null||v===''||v==='-') ? '-' : (ZH[v]||v); } + var symbolNames = {}; + var DEFAULT_SYMBOL = 'BTC/USDT:USDT'; + function shortSymbol(code){ + var s = String(code||''); + var head = s.split(':')[0]; + return head || s; + } + function nameOf(row){ + return shortSymbol(row && row.ts_code); + } + + function fillSelect(id, values, reset){ var el = document.getElementById(id); + if (reset){ + var keepFirst = el.options.length && el.options[0].value===''; + el.innerHTML = keepFirst ? '' : ''; + } values.forEach(function(v){ var o = document.createElement('option'); - o.value = v; o.textContent = v; el.appendChild(o); + o.value = v; o.textContent = zh(v); el.appendChild(o); + }); + } + function esc(s){ + return String(s==null?'':s).replace(/[&<>"']/g,function(c){ + return ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]); + }); + } + function comboId(){ return document.getElementById('fCombo').value || combo.id; } + function rebuildChartTools(){ + var tools = document.getElementById('chartTools'); + var tfs = [combo.low, combo.mid, combo.high]; + if (tfs.indexOf(chartFreq)<0) chartFreq = combo.low; + tools.innerHTML = ''; + tfs.forEach(function(tf){ + var b = document.createElement('button'); + b.type = 'button'; + b.setAttribute('data-freq', tf); + b.textContent = tf; + if (tf===chartFreq) b.className = 'on'; + b.onclick = function(){ setFreq(tf); }; + tools.appendChild(b); + }); + } + function fillComboSelect(combos){ + var el = document.getElementById('fCombo'); + var cur = el.value || localStorage.getItem('cw_combo') || ''; + el.innerHTML = ''; + (combos||[]).forEach(function(c){ + var o = document.createElement('option'); + o.value = c.id; + o.textContent = c.label + (c.builtin?'':' ✎'); + el.appendChild(o); + }); + if (cur && Array.prototype.some.call(el.options, function(o){return o.value===cur;})) el.value = cur; + else if (el.options.length) el.selectedIndex = 0; + } + function fillAddTfSelects(){ + ['addHigh','addMid','addLow'].forEach(function(id){ + var el = document.getElementById(id); + el.innerHTML = ''; + allowedTfs.forEach(function(tf){ + var o = document.createElement('option'); + o.value = tf; o.textContent = tf; el.appendChild(o); + }); + }); + document.getElementById('addHigh').value = '8h'; + document.getElementById('addMid').value = '4h'; + document.getElementById('addLow').value = '1h'; + } + + function isIntradayTf(tf){ + var t = String(tf||''); + return /m$/.test(t) || /h$/.test(t); + } + function barTs(b){ + if (b && b.ts != null) return Number(b.ts); + var raw = b && (b.datetime || b.date || b.trade_date || b.trade_time); + var n = Date.parse(raw); + return isFinite(n) ? n : 0; + } + function barDate(b){ + // date key for overlay matching (YYYY-MM-DD) + if (b && b.date) return String(b.date).slice(0,10); + var raw = String((b && (b.datetime || b.trade_date || b.trade_time)) || ''); + return raw.slice(0,10); + } + function barLabel(b){ + var raw = String((b && (b.datetime || b.date || b.trade_date || b.trade_time)) || ''); + if (isIntradayTf(chartFreq)){ + // already Asia/Shanghai (+08:00) from API + var m = raw.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/); + if (m) return m[2]+'-'+m[3]+' '+m[4]+':'+m[5]; + if (b && b.ts){ + var d = new Date(Number(b.ts) + 8*3600*1000); // fallback UTC+8 + var pad = function(n){ return n<10?'0'+n:''+n; }; + return pad(d.getUTCMonth()+1)+'-'+pad(d.getUTCDate())+' '+pad(d.getUTCHours())+':'+pad(d.getUTCMinutes()); + } + } + return raw.slice(0,10); + } + function barAxisLabel(b){ + var raw = String((b && (b.datetime || b.date || b.trade_date || b.trade_time)) || ''); + if (isIntradayTf(chartFreq)){ + var m = raw.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/); + if (m) return m[3]+' '+m[4]+':'+m[5]; // DD HH:MM (UTC+8) + return barLabel(b); + } + return raw.slice(5,10); // MM-DD + } + function setHint(msg){ + var el = document.getElementById('chartHint'); + el.textContent = msg || ''; + el.style.display = msg ? 'flex' : 'none'; + } + function dateIndex(dateStr){ + var d = String(dateStr).slice(0,10); + var best = -1; + for (var i=0;i=1e9) return (n/1e9).toFixed(2)+'B'; + if (n>=1e6) return (n/1e6).toFixed(2)+'M'; + if (n>=1e3) return (n/1e3).toFixed(1)+'K'; + return String(Math.round(n)); + } + function fmtPx(p){ + var n = Number(p); + if (!isFinite(n)) return '-'; + if (n >= 1000) return n.toFixed(1); + if (n >= 1) return n.toFixed(2); + return n.toFixed(4); + } + function emaSeries(values, period){ + var out = new Array(values.length).fill(null); + if (!values.length) return out; + var k = 2/(period+1), prev=null, sum=0, n=0; + for (var i=0;i=0;j--) if (dif[j]!=null) return dif[j]; + return 0; + }); + var firstValid = dif.findIndex(function(v){return v!=null;}); + var dea = new Array(closes.length).fill(null); + if (firstValid>=0){ + var slice = difFilled.slice(firstValid); + var deaSlice = emaSeries(slice, signal); + for (var i=0;i label.length*8) ctx.fillText(label, x0+4, priceTop+12); + }); + + var usedZones = {}; + (overlay.zones||[]).forEach(function(z){ + var kind = z.kind||'Range'; + var i0=dateIndex(z.start), i1=dateIndex(z.end); + if (i0<0||i1<0) return; + var zHi=Number(z.high), zLo=Number(z.low); + if (!isFinite(zHi)||!isFinite(zLo)||zHi<=zLo) return; + usedZones[kind]=1; + var x0=padL+slot*i0, x1=padL+slot*(i1+1); + var y0=yPrice(zHi), y1=yPrice(zLo); + var isCurrent = !!z.current; + ctx.fillStyle = ZONE_FILL[kind]||ZONE_FILL.Range; + ctx.fillRect(x0, y0, Math.max(1,x1-x0), Math.max(1,y1-y0)); + ctx.strokeStyle = ZONE_EDGE[kind]||ZONE_EDGE.Range; + ctx.lineWidth = isCurrent?1.6:1; + ctx.setLineDash(isCurrent?[]:[4,3]); + ctx.strokeRect(x0+0.5, y0+0.5, Math.max(1,x1-x0-1), Math.max(1,y1-y0-1)); + ctx.setLineDash([]); + var zLabel = ZONE_LABEL[kind]||'区间'; + if (isCurrent || (x1-x0)>50){ + ctx.fillStyle = ZONE_EDGE[kind]||ZONE_EDGE.Range; + ctx.font = 'bold 11px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(zLabel, x0+6, Math.max(priceTop+14, y0+14)); + ctx.font = '9px sans-serif'; + ctx.fillText(fmtPx(zHi)+' ~ '+fmtPx(zLo), x0+6, Math.max(priceTop+26, y0+26)); + } + }); + + var usedCycles = {}; + (overlay.cycles||[]).forEach(function(seg){ + var i0=dateIndex(seg.start), i1=dateIndex(seg.end); + if (i0<0||i1<0) return; + var cy = seg.cycle||'Unknown'; + if (cy==='Unknown') return; + usedCycles[cy]=1; + var x0=padL+slot*i0, x1=padL+slot*(i1+1); + ctx.fillStyle = PHASE_COLOR[cy]||'rgba(139,148,158,.2)'; + ctx.fillRect(x0, priceTop+priceH-10, Math.max(1,x1-x0), 10); + if (x1-x0 > 36){ + ctx.fillStyle = PHASE_EDGE[cy]||'#8b949e'; + ctx.font = '9px sans-serif'; + ctx.textAlign = 'left'; + ctx.fillText(zh(cy), x0+3, priceTop+priceH-2); + } + }); + + ctx.strokeStyle = 'rgba(48,54,61,.7)'; + ctx.lineWidth = 1; + ctx.fillStyle = '#8b949e'; + ctx.font = '10px sans-serif'; + ctx.textAlign = 'right'; + for (var gi=0; gi<=4; gi++){ + var gp = lo+(hi-lo)*gi/4; + var gy = yPrice(gp); + ctx.beginPath(); ctx.moveTo(padL,gy); ctx.lineTo(W-padR,gy); ctx.stroke(); + ctx.fillText(fmtPx(gp), padL-6, gy+3); + } + + drawLevel(ctx,yPrice,lv.range_high,'rgba(210,153,29,.85)','区间上沿',padL,W,padR,[4,3]); + drawLevel(ctx,yPrice,lv.range_low,'rgba(210,153,29,.85)','区间下沿',padL,W,padR,[4,3]); + drawLevel(ctx,yPrice,lv.ma20,'rgba(88,166,255,.55)','均线20',padL,W,padR,[2,2]); + planLevels.forEach(function(m){ + var map = {entry:['入场','#58a6ff'], stop:['止损','#f85149'], target1:['目标一','#3fb950'], target2:['目标二','#3fb950']}; + var conf = map[m.type]; if(!conf) return; + drawLevel(ctx,yPrice,m.price,conf[1],conf[0],padL,W,padR,[6,3]); + }); + + bars.forEach(function(b,i){ + var o=Number(b.open), h=Number(b.high), l=Number(b.low), c=Number(b.close); + var x=xOf(i); + var up=c>=o; + var color=up?'#3fb950':'#f85149'; + ctx.strokeStyle=color; ctx.fillStyle=color; + ctx.beginPath(); ctx.moveTo(x, yPrice(h)); ctx.lineTo(x, yPrice(l)); ctx.stroke(); + var y1=yPrice(Math.max(o,c)), y2=yPrice(Math.min(o,c)); + ctx.fillRect(x-bodyW/2, y1, bodyW, Math.max(1, y2-y1)); + }); + + (overlay.events||[]).forEach(function(ev){ + var i = dateIndex(ev.date); + if (i<0) return; + var x=xOf(i); + var y=yPrice(Number(ev.high||ev.price))-14; + var color=EVENT_COLOR[ev.event]||'#58a6ff'; + ctx.fillStyle=color; + ctx.beginPath(); + ctx.moveTo(x, y+10); ctx.lineTo(x-5, y); ctx.lineTo(x+5, y); ctx.closePath(); + ctx.fill(); + ctx.font='bold 10px sans-serif'; + ctx.textAlign='center'; + ctx.fillText(zh(ev.event), x, Math.max(priceTop+10, y-2)); + }); + + ctx.fillStyle='#8b949e'; + ctx.font='10px sans-serif'; + ctx.textAlign='left'; + ctx.fillText('成交量', padL+2, volTop+10); + ctx.strokeStyle='rgba(48,54,61,.8)'; + ctx.beginPath(); ctx.moveTo(padL, volTop); ctx.lineTo(W-padR, volTop); ctx.stroke(); + bars.forEach(function(b,i){ + var up = Number(b.close)>=Number(b.open); + ctx.fillStyle = up?'rgba(63,185,80,.55)':'rgba(248,81,73,.55)'; + var y = yVol(vols[i]); + ctx.fillRect(xOf(i)-bodyW/2, y, bodyW, Math.max(1, volTop+volH-y)); + }); + ctx.fillStyle='#8b949e'; + ctx.textAlign='right'; + ctx.fillText(fmtVol(maxVol), padL-6, volTop+12); + + ctx.fillStyle='#8b949e'; + ctx.textAlign='left'; + ctx.fillText('MACD', padL+2, macdTop+10); + ctx.strokeStyle='rgba(48,54,61,.8)'; + ctx.beginPath(); ctx.moveTo(padL, macdTop); ctx.lineTo(W-padR, macdTop); ctx.stroke(); + ctx.strokeStyle='rgba(139,148,158,.35)'; + ctx.beginPath(); ctx.moveTo(padL, yMacd(0)); ctx.lineTo(W-padR, yMacd(0)); ctx.stroke(); + bars.forEach(function(_,i){ + var h=macd.hist[i]; if(h==null) return; + ctx.fillStyle=h>=0?'rgba(63,185,80,.55)':'rgba(248,81,73,.55)'; + var y0=yMacd(0), y1=yMacd(h); + var top=Math.min(y0,y1), bot=Math.max(y0,y1); + ctx.fillRect(xOf(i)-bodyW/2, top, bodyW, Math.max(1, bot-top)); + }); + function strokeLine(series, color){ + ctx.strokeStyle=color; ctx.lineWidth=1.2; ctx.beginPath(); + var started=false; + series.forEach(function(v,i){ + if (v==null||!isFinite(v)){ started=false; return; } + var x=xOf(i), y=yMacd(v); + if (!started){ ctx.moveTo(x,y); started=true; } else ctx.lineTo(x,y); + }); + ctx.stroke(); + } + strokeLine(macd.dif, '#e3b341'); + strokeLine(macd.dea, '#58a6ff'); + + ctx.fillStyle='#8b949e'; + ctx.textAlign='center'; + var step = Math.max(1, Math.floor(bars.length/5)); + for (var di=0; di=0 && chartHover=seg.start && dHover<=seg.end) phaseAt = zh(seg.phase); + }); + var difV=macd.dif[hiIdx], deaV=macd.dea[hiIdx], histV=macd.hist[hiIdx]; + tip.textContent = + (active?active.ts_code:'')+' · '+barLabel(hb)+ + ' 开'+fmtPx(hb.open)+' 高'+fmtPx(hb.high)+' 低'+fmtPx(hb.low)+' 收'+fmtPx(hb.close)+ + ' 量'+fmtVol(hb.volume)+(phaseAt?' · '+phaseAt:'')+ + (difV!=null?' DIF'+difV.toFixed(3)+' DEA'+(deaV||0).toFixed(3):''); + + var parts = []; + var zoneBits = Object.keys(usedZones).map(function(k){ return ZONE_LABEL[k]||zh(k); }); + var phaseBits = Object.keys(usedPhases).map(zh); + var cycleBits = Object.keys(usedCycles).map(zh); + var src = '阶段('+ (overlay.phase_source||'') +')'; + if (zoneBits.length) parts.push(zoneBits.join('/')); + if (phaseBits.length) parts.push(src+':'+phaseBits.join('/')); + if (cycleBits.length) parts.push('周期:'+cycleBits.join('/')); + legend.textContent = parts.join(' · '); + + if (chartHover>=0 && chartHover 0; + var req = ++chartReqSeq; + chartSymbol = symbol || ''; + if (!sameView){ + setHint('加载 K 线中…'); + chartBars = []; + overlay = {phases:[], events:[], levels:{}, zones:[], cycles:[]}; + chartHover = -1; + drawChart(); + } + var q = new URLSearchParams({symbol:symbol, tf:chartFreq, limit:'180', combo_id:comboId()}); + var oq = new URLSearchParams({symbol:symbol, tf:chartFreq, bars:'180', combo_id:comboId()}); + return Promise.all([ + fetch('/api/wyckoff_crypto/klines?'+q).then(function(r){return r.json();}), + fetch('/api/wyckoff_crypto/overlay?'+oq).then(function(r){return r.ok?r.json():{};}) + ]).then(function(pair){ + if (req !== chartReqSeq) return; // stale + var data = pair[0], ov = pair[1]||{}; + var items = Array.isArray(data.items) ? data.items.slice() : []; + items.sort(function(a,b){ return barTs(a) - barTs(b); }); + chartBars = items.slice(-160); + overlay = { + phases: ov.phases||[], + events: ov.events||[], + levels: ov.levels||{}, + zones: ov.zones||[], + cycles: ov.cycles||[], + phase_source: ov.phase_source||chartFreq + }; + if (!chartBars.length){ + setHint('暂无 K 线(本地 bars 尚未回填该组合周期)'); + document.getElementById('chartTip').textContent=''; + document.getElementById('chartLegend').textContent=''; + return; + } + setHint(''); + drawChart(); + }).catch(function(){ + if (req !== chartReqSeq) return; + if (!sameView) setHint('K 线加载失败'); }); } + function setFreq(freq){ + chartFreq = freq; + document.querySelectorAll('#chartTools button').forEach(function(b){ + b.classList.toggle('on', b.getAttribute('data-freq')===freq); + }); + if (active) loadChart(active.ts_code); + } + + function applyCombo(c){ + if (!c) return; + var changed = !combo || combo.id !== c.id; + combo = c; + localStorage.setItem('cw_combo', c.id); + if (changed){ + chartFreq = c.low; + rebuildChartTools(); + } else if (!document.getElementById('chartTools').children.length){ + rebuildChartTools(); + } + } + function loadMeta(){ - return fetch('/api/wyckoff_crypto/meta').then(function(r){return r.json()}).then(function(m){ + return fetch('/api/wyckoff_crypto/meta?combo_id='+encodeURIComponent(comboId()||'')).then(function(r){return r.json()}).then(function(m){ meta = m; - if (!document.getElementById('fDecision').options.length || document.getElementById('fDecision').options.length===1){ + allowedTfs = m.allowed_tfs || []; + symbolNames = m.symbol_names || {}; + if (m.default_symbol) DEFAULT_SYMBOL = m.default_symbol; + fillComboSelect(m.combos||[]); + if (m.combo) applyCombo(m.combo); + if (document.getElementById('fDecision').options.length===1){ fillSelect('fDecision', m.decision_signals||[]); fillSelect('fCycle', m.cycles||[]); fillSelect('fPhase', m.phases||[]); fillSelect('fEvent', m.events||[]); } + fillAddTfSelects(); var st = m.status||{}; document.getElementById('metaLine').textContent = - 'engine '+m.engine_version+' · date '+(m.latest_trade_date||'-')+ + 'engine '+m.engine_version+' · '+combo.label+ + ' · date '+(m.latest_trade_date||'-')+ ' · rows '+m.scan_count+ ' · tick '+(st.last_tick_at||'pending')+ (st.running?' · RUNNING':'')+ @@ -131,6 +778,7 @@ tr.active td{background:rgba(88,166,255,.16)} function qs(){ var p = new URLSearchParams(); + p.set('combo_id', comboId()); var d=document.getElementById('fDecision').value; if(d) p.set('decision_signal',d); var c=document.getElementById('fCycle').value; if(c) p.set('m_cycle',c); var ph=document.getElementById('fPhase').value; if(ph) p.set('w_phase',ph); @@ -141,33 +789,79 @@ tr.active td{background:rgba(88,166,255,.16)} return p.toString(); } - function loadScan(){ + function restorePaneWidths(){ + var left = parseInt(localStorage.getItem('cw_pane_left')||'360', 10); + var right = parseInt(localStorage.getItem('cw_pane_right')||'300', 10); + if (!isFinite(left) || left < 160) left = 360; + if (!isFinite(right) || right < 160) right = 300; + applyPaneWidths(left, right); + } + + function loadScan(opts){ + opts = opts || {}; + var quiet = !!opts.quiet; + var listWrap = document.querySelector('#paneList .table-wrap'); + var detailEl = document.getElementById('detail'); + var listScroll = listWrap ? listWrap.scrollTop : 0; + var detailScroll = detailEl ? detailEl.scrollTop : 0; + var keepCode = active ? active.ts_code : ''; return fetch('/api/wyckoff_crypto/scan?'+qs()).then(function(r){return r.json()}).then(function(data){ rows = data.rows||[]; document.getElementById('rowCount').textContent = '('+rows.length+')'; document.getElementById('emptyHint').style.display = rows.length? 'none':'block'; var tb = document.getElementById('tbody'); tb.innerHTML = ''; - rows.forEach(function(row, i){ + rows.forEach(function(row){ var tr = document.createElement('tr'); if (active && active.ts_code===row.ts_code) tr.className='active'; tr.innerHTML = - ''+row.ts_code+''+ - ''+row.decision_signal+''+ - ''+row.m_cycle+''+ - ''+row.w_phase+''+ - ''+row.d_current_event+''+ + ''+esc(nameOf(row))+''+ + ''+esc(zh(row.decision_signal))+''+ + ''+esc(zh(row.m_cycle))+''+ + ''+esc(zh(row.w_phase))+''+ + ''+esc(zh(row.d_current_event))+''+ ''+(row.overall_score!=null?Number(row.overall_score).toFixed(1):'-')+''+ ''+(row.alignment!=null?Number(row.alignment).toFixed(0):'-')+''+ ''+(row.stars||'')+''; - tr.onclick = function(){ showDetail(row); Array.prototype.forEach.call(tb.querySelectorAll('tr'),function(x){x.className='';}); tr.className='active'; }; + tr.onclick = function(){ + showDetail(row); + Array.prototype.forEach.call(tb.querySelectorAll('tr'),function(x){x.className='';}); + tr.className='active'; + }; tb.appendChild(tr); }); + if (rows.length){ + var prefer = null; + if (keepCode) prefer = rows.find(function(r){ return r.ts_code===keepCode; }); + if (!prefer) prefer = rows.find(function(r){ return r.ts_code===DEFAULT_SYMBOL; }) || rows[0]; + Array.prototype.forEach.call(tb.querySelectorAll('tr'),function(tr, idx){ + tr.className = (rows[idx] && rows[idx].ts_code===prefer.ts_code) ? 'active' : ''; + }); + var same = keepCode && prefer.ts_code === keepCode && chartSymbol === keepCode && chartBars.length > 0; + if (same){ + renderDetail(prefer, {keepScroll: true, detailScroll: detailScroll}); + // 手动刷新:软更新 K 线;自动刷新:不重拉,避免图面跳动 + if (!quiet) loadChart(prefer.ts_code, {soft: true}); + } else { + showDetail(prefer); + } + } + if (listWrap) listWrap.scrollTop = listScroll; + restorePaneWidths(); + // 栏宽恢复后按当前数据重绘(计划线等可能已变) + if (quiet && chartBars.length) drawChart(); }); } - function showDetail(row){ + function renderDetail(row, opts){ + opts = opts || {}; active = row; + planLevels = []; + if (row.entry!=null) planLevels.push({type:'entry', price:row.entry}); + if (row.stop!=null) planLevels.push({type:'stop', price:row.stop}); + if (row.target1!=null) planLevels.push({type:'target1', price:row.target1}); + if (row.target2!=null) planLevels.push({type:'target2', price:row.target2}); + var reasons=[]; try{ reasons = JSON.parse(row.reasons_json||'[]'); }catch(e){} var plan = []; @@ -176,34 +870,175 @@ tr.active td{background:rgba(88,166,255,.16)} if(row.target1!=null) plan.push('t1 '+row.target1); if(row.target2!=null) plan.push('t2 '+row.target2); if(row.rr!=null) plan.push('rr '+row.rr); - document.getElementById('detail').innerHTML = - '

'+row.ts_code+'

'+ - '
Decision '+row.decision_signal+' · risk '+row.risk+'
'+ - '
Month '+row.m_cycle+'
'+ - '
Week '+row.w_cycle+' / '+row.w_phase+' / '+row.w_current_event+'
'+ - '
Day '+row.d_current_event+'
'+ - '
Scores overall '+fmt(row.overall_score)+' align '+fmt(row.alignment)+' entry '+fmt(row.entry_score)+'
'+ - '
Plan '+(plan.join(' · ')||'无(非 tradable)')+'
'+ - '
Updated '+(row.scanned_at||'')+'
'+ - '
    '+reasons.map(function(x){return '
  • '+x+'
  • ';}).join('')+'
'; + var detailEl = document.getElementById('detail'); + detailEl.innerHTML = + '

'+esc(nameOf(row))+' '+esc(row.ts_code)+'

'+ + '
组合 '+esc(combo.label)+'
'+ + '
决策 '+esc(zh(row.decision_signal))+' · 风险 '+esc(zh(row.risk))+'
'+ + '
高 '+esc(combo.high)+' '+esc(zh(row.m_cycle))+'
'+ + '
中 '+esc(combo.mid)+' '+esc(zh(row.w_cycle))+' / '+esc(zh(row.w_phase))+' / '+esc(zh(row.w_current_event))+'
'+ + '
低 '+esc(combo.low)+' '+esc(zh(row.d_current_event))+'
'+ + '
得分 综合 '+fmt(row.overall_score)+' 一致 '+fmt(row.alignment)+' 入场 '+fmt(row.entry_score)+'
'+ + '
计划 '+(plan.join(' · ')||'无(非可交易)')+'
'+ + '
更新 '+esc(row.scanned_at||'')+'
'+ + '
    '+reasons.map(function(x){return '
  • '+esc(x)+'
  • ';}).join('')+'
'; + if (opts.keepScroll) detailEl.scrollTop = opts.detailScroll || 0; + } + + function showDetail(row){ + renderDetail(row); + loadChart(row.ts_code); } function fmt(v){ return v==null?'-':Number(v).toFixed(1); } - function refresh(){ return loadMeta().then(loadScan); } + function refresh(opts){ return loadMeta().then(function(){ return loadScan(opts||{}); }); } document.getElementById('btnRefresh').onclick = function(){ refresh(); }; document.getElementById('btnTick').onclick = function(){ fetch('/api/wyckoff_crypto/tick', {method:'POST'}).then(function(){ document.getElementById('metaLine').textContent = '手动 tick 已启动…'; - setTimeout(refresh, 3000); + setTimeout(refresh, 5000); }); }; + document.getElementById('fCombo').addEventListener('change', function(){ + localStorage.setItem('cw_combo', comboId()); + active = null; + refresh(); + }); + document.getElementById('btnAddCombo').onclick = function(){ + document.getElementById('comboAdd').classList.toggle('open'); + document.getElementById('comboErr').textContent = ''; + }; + document.getElementById('btnCancelCombo').onclick = function(){ + document.getElementById('comboAdd').classList.remove('open'); + }; + document.getElementById('btnSaveCombo').onclick = function(){ + var body = { + high: document.getElementById('addHigh').value, + mid: document.getElementById('addMid').value, + low: document.getElementById('addLow').value, + label: document.getElementById('addLabel').value || undefined + }; + fetch('/api/wyckoff_crypto/combos', { + method:'POST', + headers:{'Content-Type':'application/json'}, + body: JSON.stringify(body) + }).then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); }) + .then(function(res){ + if (!res.ok){ + document.getElementById('comboErr').textContent = res.d.error || '保存失败'; + return; + } + document.getElementById('comboAdd').classList.remove('open'); + fillComboSelect(res.d.combos||[]); + document.getElementById('fCombo').value = res.d.combo.id; + localStorage.setItem('cw_combo', res.d.combo.id); + refresh(); + }); + }; + document.getElementById('btnDelCombo').onclick = function(){ + var id = comboId(); + if (!id) return; + if (!confirm('删除组合 '+id+' ?内置组合不可删。')) return; + fetch('/api/wyckoff_crypto/combos/'+encodeURIComponent(id), {method:'DELETE'}) + .then(function(r){ return r.json().then(function(d){ return {ok:r.ok, d:d}; }); }) + .then(function(res){ + if (!res.ok){ alert(res.d.error||'删除失败'); return; } + localStorage.removeItem('cw_combo'); + fillComboSelect(res.d.combos||[]); + refresh(); + }); + }; ['fDecision','fCycle','fPhase','fEvent','fMinScore','fSort'].forEach(function(id){ document.getElementById(id).addEventListener('change', loadScan); }); + var canvas = document.getElementById('chartCanvas'); + canvas.addEventListener('mousemove', function(ev){ + if (!chartBars.length) return; + var rect = canvas.getBoundingClientRect(); + var x = ev.clientX - rect.left; + var padL=58, padR=12; + var plotW = Math.max(10, rect.width-padL-padR); + var slot = plotW/chartBars.length; + var idx = Math.floor((x-padL)/slot); + chartHover = (idx>=0 && idx'+(saved||'h8_4_1')+''; + })(); + rebuildChartTools(); refresh(); - setInterval(refresh, 30000); + setInterval(function(){ refresh({quiet: true}); }, 30000); })(); diff --git a/web/tests/test_analyze_contract.py b/web/tests/test_analyze_contract.py index 402072c..7e6ff69 100644 --- a/web/tests/test_analyze_contract.py +++ b/web/tests/test_analyze_contract.py @@ -64,11 +64,33 @@ def test_analyze_route_registered(): rules = {r.rule for r in app.url_map.iter_rules()} assert "/api/analyze" in rules + assert "/api/klines/recent" in rules assert "/api/chart_metadata" in rules assert "/" in rules assert "/chan_tv" in rules +def test_klines_recent_returns_tail_only(): + from app import app + + df = make_ohlcv(n=30) + # analyze 蓝图 star-import 后绑定在 api.analyze 命名空间 + with patch("api.analyze.get_kl_data", return_value=df): + client = app.test_client() + resp = client.get( + "/api/klines/recent", + query_string={"symbol": "BTC/USDT:USDT", "timeframe": "5m", "limit": 2}, + ) + assert resp.status_code == 200 + body = resp.get_json() + assert body.get("partial") is True + assert body.get("limit") == 2 + assert isinstance(body.get("kline_data"), list) + assert len(body["kline_data"]) == 2 + assert "bi_list" not in body + assert "wyckoff" not in body + + def test_contract_keys_stable(): assert "bi_list" in CONTRACT_KEYS and "seg_list" in CONTRACT_KEYS for k in ("kline_data", "macd", "zs_list", "bsp_list", "chan_macd"): diff --git a/web/tests/test_wyckoff_crypto_routes.py b/web/tests/test_wyckoff_crypto_routes.py index 2a1ca05..44614bd 100644 --- a/web/tests/test_wyckoff_crypto_routes.py +++ b/web/tests/test_wyckoff_crypto_routes.py @@ -31,6 +31,8 @@ def test_wyckoff_crypto_page_ok(client): resp = client.get("/wyckoff_crypto") assert resp.status_code == 200 assert b"Crypto Wyckoff Screener" in resp.data + assert b"fCombo" in resp.data + assert b"chartCanvas" in resp.data def test_wyckoff_crypto_meta_ok(client): @@ -38,11 +40,84 @@ def test_wyckoff_crypto_meta_ok(client): assert resp.status_code == 200 data = resp.get_json() assert "engine_version" in data - assert data.get("timeframes") == ["1d", "1w", "1M"] + assert data.get("combo", {}).get("id") == "h8_4_1" + assert data["combo"]["low"] == "1h" + ids = {c["id"] for c in data.get("combos") or []} + assert "h8_4_1" in ids and "d_w_m" in ids def test_wyckoff_crypto_scan_ok(client): - resp = client.get("/api/wyckoff_crypto/scan?limit=5") + resp = client.get("/api/wyckoff_crypto/scan?limit=5&combo_id=h8_4_1") assert resp.status_code == 200 data = resp.get_json() assert "rows" in data + assert data.get("combo", {}).get("id") == "h8_4_1" + + +def test_wyckoff_crypto_klines_bad_request(client): + resp = client.get("/api/wyckoff_crypto/klines") + assert resp.status_code == 400 + + +def test_wyckoff_crypto_klines_ok(client): + resp = client.get( + "/api/wyckoff_crypto/klines?symbol=BTC/USDT:USDT&tf=1h&limit=10&combo_id=h8_4_1" + ) + assert resp.status_code == 200 + data = resp.get_json() + assert "items" in data + assert data.get("tf") == "1h" + assert data.get("intraday") is True + if data["items"]: + assert "datetime" in data["items"][0] + assert "ts" in data["items"][0] + assert "T" in data["items"][0]["datetime"] + assert "+08:00" in data["items"][0]["datetime"] + + +def test_wyckoff_crypto_klines_bad_limit_ok(client): + resp = client.get( + "/api/wyckoff_crypto/klines?symbol=BTC/USDT:USDT&tf=1h&limit=abc&combo_id=h8_4_1" + ) + assert resp.status_code == 200 + + +def test_wyckoff_crypto_overlay_ok(client): + resp = client.get( + "/api/wyckoff_crypto/overlay?symbol=BTC/USDT:USDT&tf=1h&bars=60&combo_id=h8_4_1" + ) + assert resp.status_code == 200 + data = resp.get_json() + assert "phases" in data + assert "events" in data + + +def test_combos_add_and_list(client, tmp_path, monkeypatch): + from crypto_wyckoff import combos as cm + + monkeypatch.setattr(cm, "_COMBOS_FILE", tmp_path / "combos.json") + monkeypatch.setattr(cm, "_cache", None) + + resp = client.get("/api/wyckoff_crypto/combos") + assert resp.status_code == 200 + assert len(resp.get_json()["combos"]) >= 2 + + bad = client.post( + "/api/wyckoff_crypto/combos", + json={"high": "1h", "mid": "4h", "low": "8h"}, + ) + assert bad.status_code == 400 + + ok = client.post( + "/api/wyckoff_crypto/combos", + json={"high": "12h", "mid": "4h", "low": "1h", "label": "12h/4h/1h"}, + ) + assert ok.status_code == 200 + cid = ok.get_json()["combo"]["id"] + assert cid == "12h_4h_1h" + + deleted = client.delete(f"/api/wyckoff_crypto/combos/{cid}") + assert deleted.status_code == 200 + + builtin = client.delete("/api/wyckoff_crypto/combos/h8_4_1") + assert builtin.status_code == 400