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