feat(web): 增量自动刷新、结构区修复与默认指标/周期
自动刷新常态只拉 recent 尾部 K,每 1 分钟全量重算缠论;修复结构区缓存导入;默认指标/4h·1h·15m/近30天;同步 ECR-009 screener 相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user