feat(web): 增量自动刷新、结构区修复与默认指标/周期
自动刷新常态只拉 recent 尾部 K,每 1 分钟全量重算缠论;修复结构区缓存导入;默认指标/4h·1h·15m/近30天;同步 ECR-009 screener 相关改动。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+47
-34
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
+92
-30
@@ -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
|
||||
|
||||
|
||||
|
||||
+48
-24
@@ -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
|
||||
|
||||
+28
-29
@@ -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)
|
||||
|
||||
+102
-40
@@ -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
|
||||
|
||||
@@ -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}
|
||||
@@ -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`,可添加新组合
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
+128
-10
@@ -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/<combo_id>", 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/<path: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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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('增量更新图表完成');
|
||||
|
||||
@@ -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();
|
||||
|
||||
+128
-15
@@ -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) {}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+43
-14
@@ -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());
|
||||
|
||||
+21
-21
@@ -982,7 +982,7 @@
|
||||
<input type="datetime-local" id="end_time" class="form-control">
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<button class="btn btn-primary w-100" onclick="updateChart()" style="padding: 8px 6px; font-size: 14px;">
|
||||
<button class="btn btn-primary w-100" onclick="updateEndTimeToNow(); updateChart({ incremental: true })" style="padding: 8px 6px; font-size: 14px;">
|
||||
分析
|
||||
</button>
|
||||
</div>
|
||||
@@ -1028,14 +1028,14 @@
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<label for="refreshInterval" class="form-label me-2 mb-0">自动刷新:</label>
|
||||
<select id="refreshInterval" class="form-select form-select-sm me-2" style="width: 80px;">
|
||||
<option value="0.0833">5秒</option>
|
||||
<option value="0.0833" selected>5秒</option>
|
||||
<option value="0.1667">10秒</option>
|
||||
<option value="0.25">15秒</option>
|
||||
<option value="0.5">30秒</option>
|
||||
<option value="1">1分钟</option>
|
||||
<option value="2">2分钟</option>
|
||||
<option value="3">3分钟</option>
|
||||
<option value="5" selected>5分钟</option>
|
||||
<option value="5">5分钟</option>
|
||||
<option value="10">10分钟</option>
|
||||
</select>
|
||||
<div class="form-check form-check-inline me-2">
|
||||
@@ -1383,24 +1383,24 @@
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/state.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_lifecycle.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260807f"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/state.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260808j"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260809c"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_lifecycle.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260809d"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260809d"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}?v=20260808i"></script>
|
||||
<script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260808i"></script>
|
||||
|
||||
<!-- 均线配置弹窗 -->
|
||||
<div id="maConfigModal" class="ma-config-modal">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user