feat(web): 增量自动刷新、结构区修复与默认指标/周期

自动刷新常态只拉 recent 尾部 K,每 1 分钟全量重算缠论;修复结构区缓存导入;默认指标/4h·1h·15m/近30天;同步 ECR-009 screener 相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-08 15:45:40 +08:00
co-authored by Cursor
parent 0f6eb92a1f
commit 18a7f485e6
23 changed files with 2133 additions and 310 deletions
+47 -34
View File
@@ -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)