Files
Chan/crypto_wyckoff/combos.py
T
jackyu66gitandCursor 18a7f485e6 feat(web): 增量自动刷新、结构区修复与默认指标/周期
自动刷新常态只拉 recent 尾部 K,每 1 分钟全量重算缠论;修复结构区缓存导入;默认指标/4h·1h·15m/近30天;同步 ECR-009 screener 相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 15:45:40 +08:00

249 lines
6.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)