28 changed files with 401 additions and 2456 deletions
+34 -47
View File
@@ -61,18 +61,12 @@ def _compress_phases(points: list[tuple[str, str]]) -> list[dict]:
return segs return segs
def annotate_frame( def annotate_frame(frame: OHLCVFrame, step: int | None = None) -> dict:
frame: OHLCVFrame,
step: int | None = None,
*,
role: str | None = None,
) -> dict:
"""Pure annotation: phase bands + event markers + latest levels. """Pure annotation: phase bands + event markers + latest levels.
``role`` is the D/W/M rule alias (1d/1w/1M). Defaults to frame.timeframe. ``step`` defaults by timeframe to keep interactive charts snappy.
``step`` defaults by role to keep interactive charts snappy.
""" """
tf = role or frame.timeframe tf = frame.timeframe
min_bars = _MIN_BARS.get(tf, 30) min_bars = _MIN_BARS.get(tf, 30)
if step is None: if step is None:
step = {"1d": 2, "1w": 1, "1M": 1}.get(tf, 2) step = {"1d": 2, "1w": 1, "1M": 1}.get(tf, 2)
@@ -233,21 +227,17 @@ def annotate_symbol(
freq: str, freq: str,
end_date: date | None = None, end_date: date | None = None,
lookback: int = 180, lookback: int = 180,
*,
combo_id: str | None = None,
) -> dict: ) -> dict:
"""IO + annotate for one symbol (used by API). """IO + annotate for one symbol (used by API).
For the combo *low* chart, phase bands come from **mid** structure, For daily charts, phase bands come from **weekly** structure (Wyckoff
while event markers / levels come from the low TF. primary timeframe), while event markers / levels come from daily.
""" """
from crypto_wyckoff.combos import ROLE_HIGH, ROLE_LOW, ROLE_MID, get_combo from crypto_wyckoff.io import latest_daily_trade_date, load_frames_batch
from crypto_wyckoff.io import load_frame
combo = get_combo(combo_id) if freq not in ("1d", "1w", "1M"):
allowed = {combo["low"], combo["mid"], combo["high"]} raise ValueError(f"unsupported freq: {freq}")
if freq not in allowed: ed = end_date or latest_daily_trade_date()
raise ValueError(f"freq {freq} not in combo {combo['id']} ({combo['label']})")
empty = { empty = {
"ts_code": ts_code, "ts_code": ts_code,
"freq": freq, "freq": freq,
@@ -257,22 +247,25 @@ def annotate_symbol(
"zones": [], "zones": [],
"bars": 0, "bars": 0,
"phase_source": freq, "phase_source": freq,
"cycles": [],
"combo_id": combo["id"],
} }
_ = end_date if ed is None:
return empty
if freq == combo["low"]: if freq == "1d":
low = load_frame(ts_code, combo["low"], lookback) daily_frames = load_frames_batch("1d", ed, lookback, ts_codes=[ts_code])
mid = load_frame(ts_code, combo["mid"], max(60, lookback // 3)) weekly_frames = load_frames_batch("1w", ed, max(60, lookback // 3), ts_codes=[ts_code])
if low is None: daily = daily_frames.get(ts_code)
weekly = weekly_frames.get(ts_code)
if daily is None:
return empty return empty
d_ann = annotate_frame(low, role=ROLE_LOW) d_ann = annotate_frame(daily)
w_ann = annotate_frame(mid, role=ROLE_MID) if mid is not None else {"phases": []} w_ann = annotate_frame(weekly) if weekly is not None else {"phases": []}
cycles = _cycle_segments(mid, role=ROLE_MID) if mid is not None else [] cycles = _cycle_segments(weekly) if weekly is not None else []
levels = d_ann.get("levels") or {} levels = d_ann.get("levels") or {}
# Prefer weekly cycle on the latest levels for zone labeling
if cycles: if cycles:
levels = {**levels, "cycle": cycles[-1].get("cycle") or levels.get("cycle")} 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 []): for p in reversed(w_ann.get("phases") or []):
if p.get("phase") not in (None, "None"): if p.get("phase") not in (None, "None"):
levels = {**levels, "phase": p["phase"]} levels = {**levels, "phase": p["phase"]}
@@ -280,30 +273,29 @@ def annotate_symbol(
return { return {
"ts_code": ts_code, "ts_code": ts_code,
"freq": freq, "freq": freq,
"end_date": low.trade_dates[-1].isoformat() if low.trade_dates else None, "end_date": ed.isoformat(),
"phases": w_ann.get("phases") or [], "phases": w_ann.get("phases") or [],
"events": d_ann.get("events") or [], "events": d_ann.get("events") or [],
"levels": d_ann.get("levels") or {}, "levels": d_ann.get("levels") or {},
"zones": _build_range_zones(low, cycles, levels), "zones": _build_range_zones(daily, cycles, levels),
"bars": d_ann.get("bars", 0), "bars": d_ann.get("bars", 0),
"phase_source": combo["mid"], "phase_source": "1w",
"cycles": cycles, "cycles": cycles,
"combo_id": combo["id"],
} }
role = ROLE_MID if freq == combo["mid"] else ROLE_HIGH frames = load_frames_batch(freq, ed, lookback, ts_codes=[ts_code])
frame = load_frame(ts_code, freq, lookback) frame = frames.get(ts_code)
if frame is None: if frame is None:
return empty return empty
out = annotate_frame(frame, role=role) out = annotate_frame(frame)
out["ts_code"] = ts_code out["ts_code"] = ts_code
out["freq"] = freq out["freq"] = freq
out["end_date"] = frame.trade_dates[-1].isoformat() if frame.trade_dates else None out["end_date"] = ed.isoformat()
out["phase_source"] = freq out["phase_source"] = freq
out["cycles"] = _cycle_segments(frame, role=ROLE_HIGH if role == ROLE_HIGH else ROLE_MID) out["cycles"] = _cycle_segments(frame)
out["zones"] = _build_range_zones(frame, out["cycles"], out.get("levels") or {}) out["zones"] = _build_range_zones(frame, out["cycles"], out.get("levels") or {})
out["combo_id"] = combo["id"] if freq == "1M":
if role == ROLE_HIGH: # Monthly chart: cycle bands are more meaningful than phase
if not any(p.get("phase") not in (None, "None") for p in out["phases"]): if not any(p.get("phase") not in (None, "None") for p in out["phases"]):
out["phases"] = [ out["phases"] = [
{"start": c["start"], "end": c["end"], "phase": c["cycle"]} {"start": c["start"], "end": c["end"], "phase": c["cycle"]}
@@ -313,14 +305,9 @@ def annotate_symbol(
return out return out
def _cycle_segments( def _cycle_segments(frame: OHLCVFrame, step: int | None = None) -> list[dict]:
frame: OHLCVFrame,
step: int | None = None,
*,
role: str | None = None,
) -> list[dict]:
"""Walk-forward cycle labels compressed to segments.""" """Walk-forward cycle labels compressed to segments."""
tf = role or frame.timeframe tf = frame.timeframe
min_bars = _MIN_BARS.get(tf, 30) min_bars = _MIN_BARS.get(tf, 30)
if step is None: if step is None:
step = {"1d": 3, "1w": 1, "1M": 1}.get(tf, 2) step = {"1d": 3, "1w": 1, "1M": 1}.get(tf, 2)
-248
View File
@@ -1,248 +0,0 @@
"""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)
-1
View File
@@ -116,7 +116,6 @@ class WyckoffScanRow:
name: str = "" name: str = ""
industry: str = "" industry: str = ""
engine_version: str = "v1.0.0" engine_version: str = "v1.0.0"
combo_id: str = "d_w_m"
m_cycle: str = WyckoffCycle.UNKNOWN.value m_cycle: str = WyckoffCycle.UNKNOWN.value
cycle_confidence: float = 0.0 cycle_confidence: float = 0.0
+30 -92
View File
@@ -28,22 +28,10 @@ DATA_SERVICE_URL = os.environ.get(
).rstrip("/") ).rstrip("/")
# Continuous crypto: bar counts (not A-share weekend-padded calendar multipliers) # Continuous crypto: bar counts (not A-share weekend-padded calendar multipliers)
# Provider has many TFs; 1M is resampled locally from daily UTC months. # Provider has 1d/1w but no 1M — monthly is resampled locally from daily UTC months.
LOOKBACK = { LOOKBACK = {"1d": 250, "1w": 104, "1M": 60}
"1h": 500, TF_PROVIDER = ("1d", "1w")
"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") TF_LIST = ("1d", "1w", "1M")
LOCAL_ONLY_TFS = frozenset({"1M"})
def ensure_dirs() -> None: def ensure_dirs() -> None:
@@ -137,29 +125,7 @@ def upsert_bars(symbol: str, tf: str, rows: list[dict]) -> int:
conn.close() conn.close()
def is_intraday_tf(tf: str) -> bool: def load_frame(symbol: str, tf: str, lookback: int | None = None) -> OHLCVFrame | None:
"""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) lookback = lookback or LOOKBACK.get(tf, 100)
conn = _bars_conn() conn = _bars_conn()
try: try:
@@ -174,40 +140,20 @@ def load_bars_with_ts(
rows = list(reversed(cur.fetchall())) rows = list(reversed(cur.fetchall()))
finally: finally:
conn.close() 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: if not rows:
return None return None
trade_dates: list[date] = []
for ts, *_ in rows:
trade_dates.append(datetime.fromtimestamp(ts / 1000.0, tz=timezone.utc).date())
return OHLCVFrame( return OHLCVFrame(
ts_code=symbol, ts_code=symbol,
timeframe=tf, timeframe=tf,
trade_dates=[ trade_dates=trade_dates,
datetime.fromtimestamp(r["ts"] / 1000.0, tz=timezone.utc).date() for r in rows open=[r[1] for r in rows],
], high=[r[2] for r in rows],
open=[r["open"] for r in rows], low=[r[3] for r in rows],
high=[r["high"] for r in rows], close=[r[4] for r in rows],
low=[r["low"] for r in rows], volume=[r[5] for r in rows],
close=[r["close"] for r in rows],
volume=[r["volume"] for r in rows],
) )
@@ -273,18 +219,14 @@ def rebuild_monthly_from_daily(symbol: str) -> int:
def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict: def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict:
"""Pull history for requested TFs; monthly derived from daily when needed.""" """Pull history for continuous crypto TFs; monthly derived from daily."""
wanted = list(dict.fromkeys(tfs)) stats = {}
stats: dict = {} for tf in TF_PROVIDER:
need_monthly = "1M" in wanted if tf not in tfs and "1M" not in tfs:
if need_monthly and "1d" not in wanted:
wanted = ["1d", *wanted]
for tf in wanted:
if tf in LOCAL_ONLY_TFS:
continue continue
need = LOOKBACK.get(tf, 100) need = LOOKBACK.get(tf, 100)
if tf == "1d" and need_monthly: # need extra daily for monthly history
if tf == "1d":
need = max(need, LOOKBACK["1M"] * 31) need = max(need, LOOKBACK["1M"] * 31)
try: try:
rows = fetch_candles(symbol, tf, limit=need) rows = fetch_candles(symbol, tf, limit=need)
@@ -294,8 +236,7 @@ def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict:
logger.warning("backfill %s %s failed: %s", symbol, tf, e) logger.warning("backfill %s %s failed: %s", symbol, tf, e)
stats[tf] = 0 stats[tf] = 0
time.sleep(0.05) time.sleep(0.05)
if "1M" in tfs or True:
if need_monthly:
try: try:
stats["1M"] = rebuild_monthly_from_daily(symbol) stats["1M"] = rebuild_monthly_from_daily(symbol)
except Exception as e: except Exception as e:
@@ -306,11 +247,8 @@ def backfill_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> dict:
def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool: def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool:
"""Update forming tip bars (limit=3). Returns True if any bar changed.""" """Update forming tip bars (limit=3). Returns True if any bar changed."""
wanted = list(dict.fromkeys(tfs))
changed = False changed = False
for tf in wanted: for tf in TF_PROVIDER:
if tf in LOCAL_ONLY_TFS:
continue
try: try:
rows = fetch_candles(symbol, tf, limit=3) rows = fetch_candles(symbol, tf, limit=3)
if not rows: if not rows:
@@ -323,15 +261,15 @@ def tip_update_symbol(symbol: str, tfs: Iterable[str] = TF_LIST) -> bool:
except Exception as e: except Exception as e:
logger.debug("tip %s %s: %s", symbol, tf, e) logger.debug("tip %s %s: %s", symbol, tf, e)
time.sleep(0.02) time.sleep(0.02)
if "1M" in wanted: # Always rebuild current month tip from daily
before_m = _tip_fingerprint(symbol, "1M") before_m = _tip_fingerprint(symbol, "1M")
try: try:
rebuild_monthly_from_daily(symbol) rebuild_monthly_from_daily(symbol)
except Exception as e: except Exception as e:
logger.debug("monthly tip %s: %s", symbol, e) logger.debug("monthly tip %s: %s", symbol, e)
after_m = _tip_fingerprint(symbol, "1M") after_m = _tip_fingerprint(symbol, "1M")
if before_m != after_m: if before_m != after_m:
changed = True changed = True
return changed return changed
+24 -48
View File
@@ -1,4 +1,4 @@
"""Scan pipeline: load local frames → engines → store (per TF combo).""" """Scan pipeline: load local frames → engines → store."""
from __future__ import annotations from __future__ import annotations
@@ -6,27 +6,25 @@ import json
import logging import logging
from datetime import date, datetime, timezone 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.cycle import CycleEngine
from crypto_wyckoff.decision import DecisionEngine from crypto_wyckoff.decision import DecisionEngine
from crypto_wyckoff.domain_models import WyckoffScanRow from crypto_wyckoff.domain_models import WyckoffScanRow
from crypto_wyckoff.event import EventEngine from crypto_wyckoff.event import EventEngine
from crypto_wyckoff.features import FeatureEngine from crypto_wyckoff.features import FeatureEngine
from crypto_wyckoff.io import load_frame from crypto_wyckoff.io import LOOKBACK, TF_LIST, load_frame
from crypto_wyckoff.phase import PhaseEngine from crypto_wyckoff.phase import PhaseEngine
from crypto_wyckoff.plan import PlanEngine from crypto_wyckoff.plan import PlanEngine
from crypto_wyckoff.signal import SignalEngine from crypto_wyckoff.signal import SignalEngine
from crypto_wyckoff.store import upsert_row from crypto_wyckoff.store import upsert_row
from crypto_wyckoff.symbols_cn import display_name_cn
from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def analyze_symbol( def analyze_symbol(
low_frame, daily_frame,
mid_frame, weekly_frame,
high_frame, monthly_frame,
*, *,
feature_eng: FeatureEngine, feature_eng: FeatureEngine,
cycle_eng: CycleEngine, cycle_eng: CycleEngine,
@@ -36,22 +34,18 @@ def analyze_symbol(
decision_eng: DecisionEngine, decision_eng: DecisionEngine,
plan_eng: PlanEngine, plan_eng: PlanEngine,
) -> dict: ) -> dict:
"""Run engines with D/W/M *role* aliases so existing rules match. f_d = feature_eng.run(daily_frame, "1d")
f_w = feature_eng.run(weekly_frame, "1w")
f_m = feature_eng.run(monthly_frame, "1M")
Frames may be any TF combo (e.g. 1h/4h/8h); rules still see 1d/1w/1M roles. c_m = cycle_eng.run(f_m, "1M")
""" c_w = cycle_eng.run(f_w, "1w")
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)
c_m = cycle_eng.run(f_m, ROLE_HIGH) p_w = phase_eng.run(c_w, f_w, "1w")
c_w = cycle_eng.run(f_w, ROLE_MID) p_d = phase_eng.run(c_w, f_d, "1d")
p_w = phase_eng.run(c_w, f_w, ROLE_MID) e_w = event_eng.run(c_w, p_w, f_w, "1w")
p_d = phase_eng.run(c_w, f_d, ROLE_LOW) e_d = event_eng.run(c_w, p_d, f_d, "1d")
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) 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) decision = decision_eng.run(c_m, c_w, p_w, e_w, e_d, s_d)
@@ -65,14 +59,7 @@ def analyze_symbol(
} }
def _to_row( def _to_row(trade_date: date, symbol: str, result: dict) -> WyckoffScanRow:
trade_date: date,
symbol: str,
result: dict,
*,
combo_id: str,
combo_label: str,
) -> WyckoffScanRow:
d = result["decision"] d = result["decision"]
p = result["plan"] p = result["plan"]
c_m, c_w, p_w = result["c_m"], result["c_w"], result["p_w"] c_m, c_w, p_w = result["c_m"], result["c_w"], result["p_w"]
@@ -80,8 +67,6 @@ def _to_row(
f_d, f_w, f_m = result["f_d"], result["f_w"], result["f_m"] f_d, f_w, f_m = result["f_d"], result["f_w"], result["f_m"]
snapshot = { snapshot = {
"combo_id": combo_id,
"combo_label": combo_label,
"daily": {k: f_d.payload.get(k) for k in ( "daily": {k: f_d.payload.get(k) for k in (
"ma20", "ma60", "ma120", "atr", "adx", "volume_ratio", "ma20", "ma60", "ma120", "atr", "adx", "volume_ratio",
"range_high", "range_low", "swing_high", "swing_low", "close", "range_high", "range_low", "swing_high", "swing_low", "close",
@@ -97,7 +82,7 @@ def _to_row(
return WyckoffScanRow( return WyckoffScanRow(
trade_date=trade_date, trade_date=trade_date,
ts_code=symbol, ts_code=symbol,
name=display_name_cn(symbol), name=symbol,
industry="crypto", industry="crypto",
engine_version=WYCKOFF_ENGINE_VERSION, engine_version=WYCKOFF_ENGINE_VERSION,
m_cycle=c_m.payload.get("cycle", "Unknown"), m_cycle=c_m.payload.get("cycle", "Unknown"),
@@ -135,7 +120,6 @@ def _to_row(
feature_snapshot_json=json.dumps(snapshot, ensure_ascii=False), feature_snapshot_json=json.dumps(snapshot, ensure_ascii=False),
markers_json=json.dumps(markers, ensure_ascii=False), markers_json=json.dumps(markers, ensure_ascii=False),
scanned_at=datetime.now(timezone.utc), scanned_at=datetime.now(timezone.utc),
combo_id=combo_id,
) )
@@ -157,25 +141,17 @@ def _engines():
return _ENGINES return _ENGINES
def analyze_and_store( def analyze_and_store(symbol: str, trade_date: date | None = None) -> WyckoffScanRow | None:
symbol: str,
trade_date: date | None = None,
*,
combo_id: str | None = None,
) -> WyckoffScanRow | None:
eng = _engines() eng = _engines()
combo = get_combo(combo_id) daily = load_frame(symbol, "1d", LOOKBACK["1d"])
low_tf, mid_tf, high_tf = combo["low"], combo["mid"], combo["high"] weekly = load_frame(symbol, "1w", LOOKBACK["1w"])
monthly = load_frame(symbol, "1M", LOOKBACK["1M"])
low = load_frame(symbol, low_tf, lookback_for(low_tf)) if daily is None or len(daily) < 40:
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 return None
result = analyze_symbol(low, mid, high, **eng) result = analyze_symbol(daily, weekly, monthly, **eng)
td = trade_date or ( td = trade_date or (
low.trade_dates[-1] if low.trade_dates else datetime.now(timezone.utc).date() daily.trade_dates[-1] if daily.trade_dates else datetime.now(timezone.utc).date()
) )
row = _to_row(td, symbol, result, combo_id=combo["id"], combo_label=combo["label"]) row = _to_row(td, symbol, result)
upsert_row(row) upsert_row(row)
return row return row
+30 -29
View File
@@ -1,70 +1,73 @@
"""Background tip + scan scheduler for crypto wyckoff (all enabled combos).""" """Background 60s tip-update + rescan scheduler."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import threading import threading
import time
from datetime import datetime, timezone 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 ( from crypto_wyckoff.io import (
TF_LIST,
backfill_symbol, backfill_symbol,
bar_count, bar_count,
fetch_symbols_from_provider, fetch_symbols_from_provider,
tip_update_symbol, tip_update_symbol,
) )
from crypto_wyckoff.pipeline import analyze_and_store from crypto_wyckoff.pipeline import analyze_and_store
from crypto_wyckoff.version import WYCKOFF_ENGINE_VERSION
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_thread: threading.Thread | None = None _lock = threading.Lock()
_stop = threading.Event() _status: dict[str, Any] = {
_status: dict = {
"running": False, "running": False,
"last_tick_at": None, "last_tick_at": None,
"last_error": None, "last_error": None,
"symbols_total": 0, "symbols_total": 0,
"symbols_scanned": 0, "symbols_scanned": 0,
"tick_interval_sec": 60,
"backfill_done": False, "backfill_done": False,
"engine_version": WYCKOFF_ENGINE_VERSION,
"tick_interval_sec": 60,
} }
_status_lock = threading.Lock() _stop = threading.Event()
_thread: threading.Thread | None = None
def _set(**kwargs): def get_status() -> dict[str, Any]:
with _status_lock: with _lock:
_status.update(kwargs)
def get_status() -> dict:
with _status_lock:
return dict(_status) return dict(_status)
def _set(**kwargs):
with _lock:
_status.update(kwargs)
def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict: def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict:
"""One cycle: refresh symbols, tip-update, analyze each combo.""" """One cycle: refresh symbols, tip-update, analyze changed (or all if force)."""
symbols = fetch_symbols_from_provider() symbols = fetch_symbols_from_provider()
if max_symbols: if max_symbols:
symbols = symbols[: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) _set(symbols_total=len(symbols), running=True, last_error=None)
scanned = 0 scanned = 0
errors = 0 errors = 0
changed_n = 0 changed_n = 0
# Lazy backfill: ensure min bars
for i, sym in enumerate(symbols): for i, sym in enumerate(symbols):
try: try:
# Prefer low-TF of first combo for "enough history" gate if bar_count(sym, "1d") < 40:
low0 = combos[0]["low"] if combos else "1d" backfill_symbol(sym, TF_LIST)
if bar_count(sym, low0) < 40: tip_changed = tip_update_symbol(sym, TF_LIST)
backfill_symbol(sym, tfs)
tip_changed = tip_update_symbol(sym, tfs)
if tip_changed: if tip_changed:
changed_n += 1 changed_n += 1
if force_rescan or tip_changed: if force_rescan or tip_changed or bar_count(sym, "1d") >= 40:
for combo in combos: # Always rescan on first pass after backfill; tip change triggers update
row = analyze_and_store(sym, combo_id=combo["id"]) 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 row: if row:
scanned += 1 scanned += 1
except Exception as e: except Exception as e:
@@ -87,12 +90,11 @@ def run_tick(max_symbols: int | None = None, force_rescan: bool = False) -> dict
"scanned": scanned, "scanned": scanned,
"changed_tips": changed_n, "changed_tips": changed_n,
"errors": errors, "errors": errors,
"combos": [c["id"] for c in combos],
"tfs": tfs,
} }
def _loop(interval: int, max_symbols: int | None): def _loop(interval: int, max_symbols: int | None):
# First tick: force full rescan after tip/backfill
try: try:
run_tick(max_symbols=max_symbols, force_rescan=True) run_tick(max_symbols=max_symbols, force_rescan=True)
except Exception as e: except Exception as e:
@@ -100,8 +102,7 @@ def _loop(interval: int, max_symbols: int | None):
_set(last_error=str(e), running=False) _set(last_error=str(e), running=False)
while not _stop.wait(interval): while not _stop.wait(interval):
try: try:
# Tip-driven: only force full rescan when tips change is handled inside run_tick(max_symbols=max_symbols, force_rescan=True)
run_tick(max_symbols=max_symbols, force_rescan=False)
except Exception as e: except Exception as e:
logger.exception("tick failed: %s", e) logger.exception("tick failed: %s", e)
_set(last_error=str(e), running=False) _set(last_error=str(e), running=False)
+40 -102
View File
@@ -1,7 +1,8 @@
"""SQLite persistence for crypto wyckoff scan rows (per combo).""" """SQLite persistence for crypto wyckoff scan rows."""
from __future__ import annotations from __future__ import annotations
import json
import sqlite3 import sqlite3
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
@@ -10,7 +11,7 @@ from crypto_wyckoff.domain_models import WyckoffScanRow
from crypto_wyckoff.io import SCAN_DB, ensure_dirs from crypto_wyckoff.io import SCAN_DB, ensure_dirs
_COLS = [ _COLS = [
"trade_date", "combo_id", "ts_code", "name", "industry", "engine_version", "trade_date", "ts_code", "name", "industry", "engine_version",
"m_cycle", "cycle_confidence", "trend_score", "m_cycle", "cycle_confidence", "trend_score",
"w_cycle", "w_phase", "w_current_event", "w_recent_events_json", "w_cycle", "w_phase", "w_current_event", "w_recent_events_json",
"phase_confidence", "structure_score", "phase_confidence", "structure_score",
@@ -21,80 +22,40 @@ _COLS = [
"feature_snapshot_json", "markers_json", "scanned_at", "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: def _conn() -> sqlite3.Connection:
ensure_dirs() ensure_dirs()
c = sqlite3.connect(str(SCAN_DB), timeout=60) c = sqlite3.connect(str(SCAN_DB), timeout=60)
c.row_factory = sqlite3.Row c.row_factory = sqlite3.Row
_migrate(c) c.execute(
c.commit() """
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)"
)
return c return c
def upsert_row(row: WyckoffScanRow) -> None: def upsert_row(row: WyckoffScanRow) -> None:
combo_id = getattr(row, "combo_id", None) or "d_w_m"
vals = ( vals = (
row.trade_date.isoformat() if hasattr(row.trade_date, "isoformat") else str(row.trade_date), 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.ts_code, row.name, row.industry, row.engine_version,
row.m_cycle, row.cycle_confidence, row.trend_score, row.m_cycle, row.cycle_confidence, row.trend_score,
row.w_cycle, row.w_phase, row.w_current_event, row.w_recent_events_json, row.w_cycle, row.w_phase, row.w_current_event, row.w_recent_events_json,
@@ -110,15 +71,11 @@ def upsert_row(row: WyckoffScanRow) -> None:
try: try:
placeholders = ",".join("?" * len(_COLS)) placeholders = ",".join("?" * len(_COLS))
col_sql = ",".join(_COLS) col_sql = ",".join(_COLS)
updates = ",".join( updates = ",".join(f"{c}=excluded.{c}" for c in _COLS if c not in ("trade_date", "ts_code"))
f"{col}=excluded.{col}"
for col in _COLS
if col not in ("trade_date", "combo_id", "ts_code")
)
c.execute( c.execute(
f""" f"""
INSERT INTO wyckoff_scan ({col_sql}) VALUES ({placeholders}) INSERT INTO wyckoff_scan ({col_sql}) VALUES ({placeholders})
ON CONFLICT(trade_date, combo_id, ts_code) DO UPDATE SET {updates} ON CONFLICT(trade_date, ts_code) DO UPDATE SET {updates}
""", """,
vals, vals,
) )
@@ -127,35 +84,23 @@ def upsert_row(row: WyckoffScanRow) -> None:
c.close() c.close()
def latest_trade_date(combo_id: str | None = None) -> str | None: def latest_trade_date() -> str | None:
c = _conn() c = _conn()
try: try:
if combo_id: cur = c.execute("SELECT MAX(trade_date) FROM wyckoff_scan")
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() row = cur.fetchone()
return row[0] if row and row[0] else None return row[0] if row and row[0] else None
finally: finally:
c.close() c.close()
def count_for_date(trade_date: str | None = None, combo_id: str | None = None) -> int: def count_for_date(trade_date: str | None = None) -> int:
td = trade_date or latest_trade_date(combo_id) td = trade_date or latest_trade_date()
if not td: if not td:
return 0 return 0
c = _conn() c = _conn()
try: try:
if combo_id: cur = c.execute("SELECT COUNT(*) FROM wyckoff_scan WHERE trade_date=?", (td,))
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]) return int(cur.fetchone()[0])
finally: finally:
c.close() c.close()
@@ -164,7 +109,6 @@ def count_for_date(trade_date: str | None = None, combo_id: str | None = None) -
def query_scan( def query_scan(
*, *,
trade_date: str | None = None, trade_date: str | None = None,
combo_id: str | None = None,
m_cycle: str | None = None, m_cycle: str | None = None,
w_phase: str | None = None, w_phase: str | None = None,
d_event: str | None = None, d_event: str | None = None,
@@ -175,15 +119,14 @@ def query_scan(
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
cid = combo_id or "d_w_m" td = trade_date or latest_trade_date()
td = trade_date or latest_trade_date(cid)
if not td: if not td:
return [] return []
sort_col = sort if sort in { sort_col = sort if sort in {
"overall_score", "alignment", "entry_score", "trend_score", "structure_score", "stars" "overall_score", "alignment", "entry_score", "trend_score", "structure_score", "stars"
} else "overall_score" } else "overall_score"
clauses = ["trade_date=?", "combo_id=?"] clauses = ["trade_date=?"]
args: list[Any] = [td, cid] args: list[Any] = [td]
if m_cycle: if m_cycle:
clauses.append("m_cycle=?") clauses.append("m_cycle=?")
args.append(m_cycle) args.append(m_cycle)
@@ -215,20 +158,15 @@ def query_scan(
c.close() c.close()
def get_symbol( def get_symbol(ts_code: str, trade_date: str | None = None) -> dict[str, Any] | None:
ts_code: str, td = trade_date or latest_trade_date()
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: if not td:
return None return None
c = _conn() c = _conn()
try: try:
cur = c.execute( cur = c.execute(
"SELECT * FROM wyckoff_scan WHERE trade_date=? AND combo_id=? AND ts_code=?", "SELECT * FROM wyckoff_scan WHERE trade_date=? AND ts_code=?",
(td, cid, ts_code), (td, ts_code),
) )
row = cur.fetchone() row = cur.fetchone()
return dict(row) if row else None return dict(row) if row else None
-51
View File
@@ -1,51 +0,0 @@
"""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 → BTC1000PEPE/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}
+3 -6
View File
@@ -1,20 +1,18 @@
# ECR-009 # ECR-009
**Title:** Crypto Wyckoff Screener 独立页(D/W/M **Title:** Crypto Wyckoff Screener 独立页(D/W/M
**Status:** Implementing **Status:** Approved(计划执行)
**Date:** 2026-08-07 **Date:** 2026-08-07
**Change Level:** L2 **Change Level:** L2
## Change ## 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 ## Forbidden
- 改缠论算法、主站叠层、`/api/analyze``config/`/`strategies/` - 改缠论算法、主站叠层、`/api/analyze``config/`/`strategies/`
- 自动下单 - 小周期;自动下单
## Acceptance ## Acceptance
@@ -22,4 +20,3 @@
- [ ] 本地 `data/crypto_wyckoff/` 有 K 线与 scan - [ ] 本地 `data/crypto_wyckoff/` 有 K 线与 scan
- [ ] 调度可跑 tip 更新 - [ ] 调度可跑 tip 更新
- [ ] Decision 门闩单测通过 - [ ] Decision 门闩单测通过
- [ ] 下拉可选 `8h/4h/1h`,可添加新组合
+2 -2
View File
@@ -22,6 +22,6 @@
## Notes ## Notes
- ECR-009:打开 http://localhost:8128/wyckoff_crypto 默认组合 `8h/4h/1h`,可下拉切 `1d/1w/1M` 或「添加组合」 - ECR-009:打开 http://localhost:8128/wyckoff_crypto 可用 `CRYPTO_WYCKOFF_MAX_SYMBOLS` 限流
- 可用 `CRYPTO_WYCKOFF_MAX_SYMBOLS` 限流;月线由日线 UTC 聚合 - 月线由日线 UTC 聚合provider 无 1M
- 未请求新 system tag - 未请求新 system tag
-31
View File
@@ -1,31 +0,0 @@
"""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
-77
View File
@@ -2,9 +2,6 @@
from flask import Blueprint, jsonify, request from flask import Blueprint, jsonify, request
from services.runtime import * # noqa: F403 from services.runtime import * # noqa: F403
from services import runtime as R 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__) bp = Blueprint("analyze", __name__)
@@ -788,77 +785,3 @@ def analyze():
return jsonify(result) 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)
+10 -128
View File
@@ -7,17 +7,9 @@ import threading
from flask import Blueprint, jsonify, render_template, request 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.domain_models import DecisionSignal, WyckoffCycle, WyckoffEvent, WyckoffPhase
from crypto_wyckoff.scheduler import get_status, run_tick, start_scheduler from crypto_wyckoff.scheduler import get_status, run_tick, start_scheduler
from crypto_wyckoff import store as wyckoff_store 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 from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION
bp = Blueprint("wyckoff_crypto", __name__) bp = Blueprint("wyckoff_crypto", __name__)
@@ -40,18 +32,6 @@ def ensure_scheduler() -> None:
_scheduler_started = True _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") @bp.route("/wyckoff_crypto")
def page(): def page():
ensure_scheduler() ensure_scheduler()
@@ -61,65 +41,24 @@ def page():
@bp.route("/api/wyckoff_crypto/meta") @bp.route("/api/wyckoff_crypto/meta")
def meta(): def meta():
ensure_scheduler() ensure_scheduler()
combo_id = request.args.get("combo_id") latest = wyckoff_store.latest_trade_date()
combo = get_combo(combo_id)
latest = wyckoff_store.latest_trade_date(combo["id"])
return jsonify( return jsonify(
{ {
"architecture_version": ARCHITECTURE_VERSION, "architecture_version": ARCHITECTURE_VERSION,
"engine_version": WYCKOFF_ENGINE_VERSION, "engine_version": WYCKOFF_ENGINE_VERSION,
"latest_trade_date": latest, "latest_trade_date": latest,
"scan_count": wyckoff_store.count_for_date(latest, combo["id"]), "scan_count": wyckoff_store.count_for_date(latest),
"cycles": [c.value for c in WyckoffCycle], "cycles": [c.value for c in WyckoffCycle],
"phases": [p.value for p in WyckoffPhase], "phases": [p.value for p in WyckoffPhase],
"events": [e.value for e in WyckoffEvent], "events": [e.value for e in WyckoffEvent],
"decision_signals": [s.value for s in DecisionSignal], "decision_signals": [s.value for s in DecisionSignal],
"timezone": "Asia/Shanghai", "timezone": "UTC",
"utc_offset": "+08:00", "timeframes": ["1d", "1w", "1M"],
"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(), "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") @bp.route("/api/wyckoff_crypto/status")
def status(): def status():
ensure_scheduler() ensure_scheduler()
@@ -129,10 +68,8 @@ def status():
@bp.route("/api/wyckoff_crypto/scan") @bp.route("/api/wyckoff_crypto/scan")
def scan(): def scan():
ensure_scheduler() ensure_scheduler()
combo = get_combo(request.args.get("combo_id"))
rows = wyckoff_store.query_scan( rows = wyckoff_store.query_scan(
trade_date=request.args.get("trade_date"), trade_date=request.args.get("trade_date"),
combo_id=combo["id"],
m_cycle=request.args.get("m_cycle"), m_cycle=request.args.get("m_cycle"),
w_phase=request.args.get("w_phase"), w_phase=request.args.get("w_phase"),
d_event=request.args.get("d_event"), d_event=request.args.get("d_event"),
@@ -140,19 +77,16 @@ def scan():
min_overall_score=_float_or_none(request.args.get("min_overall_score")), min_overall_score=_float_or_none(request.args.get("min_overall_score")),
min_alignment=_float_or_none(request.args.get("min_alignment")), min_alignment=_float_or_none(request.args.get("min_alignment")),
sort=request.args.get("sort") or "overall_score", sort=request.args.get("sort") or "overall_score",
limit=_safe_int(request.args.get("limit"), 100, lo=1, hi=500), limit=min(int(request.args.get("limit") or 100), 500),
offset=_safe_int(request.args.get("offset"), 0, lo=0), offset=int(request.args.get("offset") or 0),
) )
for row in rows: return jsonify({"rows": rows, "count": len(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>") @bp.route("/api/wyckoff_crypto/symbol/<path:symbol>")
def symbol_detail(symbol: str): def symbol_detail(symbol: str):
ensure_scheduler() ensure_scheduler()
combo = get_combo(request.args.get("combo_id")) row = wyckoff_store.get_symbol(symbol, request.args.get("trade_date"))
row = wyckoff_store.get_symbol(symbol, request.args.get("trade_date"), combo["id"])
if not row: if not row:
return jsonify({"error": "not_found"}), 404 return jsonify({"error": "not_found"}), 404
return jsonify(row) return jsonify(row)
@@ -162,9 +96,8 @@ def symbol_detail(symbol: str):
def manual_tick(): def manual_tick():
"""Manual one-shot tick (debug). Optional JSON/query max_symbols.""" """Manual one-shot tick (debug). Optional JSON/query max_symbols."""
ensure_scheduler() ensure_scheduler()
body = request.get_json(silent=True) or {} max_sym = request.args.get("max_symbols") or (request.json or {}).get("max_symbols")
max_sym = request.args.get("max_symbols") or body.get("max_symbols") max_symbols = int(max_sym) if max_sym else None
max_symbols = int(max_sym) if max_sym not in (None, "") else None
def _job(): def _job():
try: try:
@@ -176,57 +109,6 @@ def manual_tick():
return jsonify({"ok": True, "started": True}) 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): def _float_or_none(v):
if v in (None, ""): if v in (None, ""):
return None return None
+2 -2
View File
@@ -25,8 +25,8 @@ def analyze_chan(df, symbol=None, timeframe=None):
zs_list = chan.calculate_seg_zs(seg_list) zs_list = chan.calculate_seg_zs(seg_list)
# 计算笔中枢(BI中枢)并拍平成列表 # 计算笔中枢(BI中枢)并拍平成列表
bi_zs_list = chan.cal_bi_zs_list_pure(bi_list) #bi_zs_list = chan.cal_bi_zs_list_pure(bi_list)
#bi_zs_list = chan.cal_bi_zs(seg_list) bi_zs_list = chan.cal_bi_zs(seg_list)
bsp_list = [] bsp_list = []
if len(bi_zs_list) > 0: if len(bi_zs_list) > 0:
bsp_list = chan.find_all_bsp(bi_list, bi_zs_list) bsp_list = chan.find_all_bsp(bi_list, bi_zs_list)
+4 -4
View File
@@ -94,19 +94,19 @@ def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys):
def compute_timeframe_defaults(labels_ordered): def compute_timeframe_defaults(labels_ordered):
""" """
根据已排序的周期 中文标签映射计算主 / / 次次周期默认值 根据已排序的周期 中文标签映射计算主 / / 次次周期默认值
默认偏好 4h 1h次次 15m 默认偏好 4h 2h次次 1h威科夫与结构在小时级更可读
labels_ordered: OrderedDict 或按插入顺序排列的 dict labels_ordered: OrderedDict 或按插入顺序排列的 dict
""" """
if not labels_ordered: if not labels_ordered:
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy() labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
timeframe_keys = list(labels_ordered.keys()) timeframe_keys = list(labels_ordered.keys())
preferred_main = next((tf for tf in ['4h', '1h', '15m'] if tf in labels_ordered), None) preferred_main = next((tf for tf in ['4h', '2h', '1h'] if tf in labels_ordered), None)
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m') default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
if default_main not in labels_ordered and timeframe_keys: if default_main not in labels_ordered and timeframe_keys:
default_main = timeframe_keys[0] default_main = timeframe_keys[0]
default_element = _prefer_smaller(['1h', '15m'], labels_ordered, default_main, timeframe_keys) default_element = _prefer_smaller(['2h', '1h'], labels_ordered, default_main, timeframe_keys)
default_sub_sub = _prefer_smaller(['15m', '5m'], labels_ordered, default_element, timeframe_keys) default_sub_sub = _prefer_smaller(['1h'], labels_ordered, default_element, timeframe_keys)
return default_main, default_element, default_sub_sub, timeframe_keys return default_main, default_element, default_sub_sub, timeframe_keys
+19 -106
View File
@@ -9,26 +9,11 @@ function updateTradingViewData() {
return; return;
} }
// 优先用请求前冻结的视窗;否则现场拍(自动刷新短间隔 delta≈0,两种都稳) // 保存当前的可视范围
const frozen = window._preserveViewOnRefresh;
const oldBarCount = window._preserveViewBarCount || 0;
let savedScrollPosition = null;
if (tvWidget.mainChart) { if (tvWidget.mainChart) {
const ts = tvWidget.mainChart.timeScale(); tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
if (frozen) { tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
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线 // 检查是否显示原始K线
const showOriginalKline = $('#showOriginalKline').is(':checked'); const showOriginalKline = $('#showOriginalKline').is(':checked');
@@ -86,24 +71,6 @@ function updateTradingViewData() {
}; };
}); });
} }
// LWC 不允许 null/NaN;时间用整秒,避免 Line 渲染抛 Value is null
candles = (candles || []).filter(function (c) {
return c && c.time != null &&
isFinite(Number(c.open)) && isFinite(Number(c.high)) &&
isFinite(Number(c.low)) && isFinite(Number(c.close));
}).map(function (c) {
return {
time: Math.floor(Number(c.time)),
open: Number(c.open),
high: Number(c.high),
low: Number(c.low),
close: Number(c.close)
};
});
const newBarCount = candles.length;
const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0;
// 更新主系列数据(根据klineType) // 更新主系列数据(根据klineType)
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line')); const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
@@ -303,77 +270,23 @@ function updateTradingViewData() {
// 更新EMA52显示 // 更新EMA52显示
updateEMA52Display(currentData); updateEMA52Display(currentData);
// 与自动刷新一致:增量更新绝不碰 barSpacing(缩放本来就留在图表实例上)。 // 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐
// 一写 barSpacing,LWC 会按右边缘重锚 → 放大往右、缩小往左。
// 这里只在 setData 之后把位置扳回刷新前的 logical / time 窗口。
if (tvWidget.mainChart) { if (tvWidget.mainChart) {
const charts = [ if (tvWidget.state.visibleRange) {
tvWidget.mainChart, console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange);
tvWidget.volumeChart, tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
tvWidget.atrChart, if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
tvWidget.macdChart, if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
tvWidget.chanMacdChart if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
].filter(Boolean); if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
} else if (tvWidget.state.logicalRange) {
const vr = tvWidget.state.visibleRange; console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange);
const lr = tvWidget.state.logicalRange; tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
const savedScroll = savedScrollPosition; if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
const applyPosition = function (tag) { if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
let ok = false; if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (lr && lr.from !== undefined && lr.to !== undefined && newBarCount > 0) { }
// 视窗超出当前 K 线数量时,LWC Line 绘制会抛 Value is null
const span = Math.max(1, lr.to - lr.from);
let to = lr.to;
let from = lr.from;
const maxTo = newBarCount - 1 + 8;
if (to > maxTo) {
to = maxTo;
from = to - span;
}
if (from < -8) {
from = -8;
to = from + span;
}
const clamped = { from: from, to: to };
charts.forEach(c => {
try {
c.timeScale().setVisibleLogicalRange(clamped);
ok = true;
} catch (e) {}
});
if (ok) console.log('🔄 恢复位置 logical' + (tag || '') + ':', clamped);
}
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);
// 增量 setData 常不触发可见时间范围回调,但价格轴会变:补刷分型竖边
var bumpFxVert = function () {
if (typeof window._redrawFxBoxVerticalOverlay === 'function') {
window._redrawFxBoxVerticalOverlay();
}
};
bumpFxVert();
setTimeout(bumpFxVert, 0);
setTimeout(bumpFxVert, 50);
} }
console.log('增量更新图表完成'); console.log('增量更新图表完成');
+14 -25
View File
@@ -24,13 +24,15 @@ function chartTvFinalize(ctx) {
var chanMacdChart = ctx.chanMacdChart; var chanMacdChart = ctx.chanMacdChart;
var createChartOptions = ctx.createChartOptions; var createChartOptions = ctx.createChartOptions;
// 同步所有图表的时间轴配置 // 同步所有图表的时间轴配置
const hasPendingRestoreView = !!window._pendingRestoreView;
const pendingView = window._pendingRestoreView;
const syncTimeScaleSettings = () => { const syncTimeScaleSettings = () => {
// 获取主图表的时间轴设置
const mainTimeScale = mainChart.timeScale();
const baseOptions = { const baseOptions = {
timeVisible: true, timeVisible: true,
secondsVisible: false, secondsVisible: false,
borderColor: '#ddd', borderColor: '#ddd',
barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10,
rightOffset: 12,
lockVisibleTimeRangeOnResize: true, lockVisibleTimeRangeOnResize: true,
// 关键:确保所有图表边缘行为完全一致 // 关键:确保所有图表边缘行为完全一致
fixLeftEdge: false, fixLeftEdge: false,
@@ -39,12 +41,6 @@ function chartTvFinalize(ctx) {
ticksVisible: true, ticksVisible: true,
minimumHeight: 0, minimumHeight: 0,
}; };
// 有待恢复视图时不要先写 barSpacing/rightOffset(会钉右缘导致图往右偏),
// 交给后面 setVisibleRange 一次锁定位置+缩放。
if (!pendingView) {
baseOptions.barSpacing = symbolConfig.type === 'a_stock' ? 6 : 10;
baseOptions.rightOffset = 12;
}
console.log('🔧 同步时间轴设置:', baseOptions); console.log('🔧 同步时间轴设置:', baseOptions);
@@ -63,12 +59,8 @@ function chartTvFinalize(ctx) {
// 仅在没有待恢复视图时,设置默认可见范围 // 仅在没有待恢复视图时,设置默认可见范围
const totalBars = candles ? candles.length : 0; const totalBars = candles ? candles.length : 0;
const visibleBarsCount = 200; const visibleBarsCount = 200;
const allChartsNow = [mainChart, volumeChart, atrChart] const hasPendingRestoreView = !!window._pendingRestoreView;
.concat(showMacd && macdChart ? [macdChart] : []) if (!hasPendingRestoreView) {
.concat(showMacd && chanMacdChart ? [chanMacdChart] : []);
if (hasPendingRestoreView && pendingView) {
restoreChartViewState(allChartsNow, pendingView, { preferTime: true });
} else {
// 显示最近 200 根K线而非全部挤压(避免K线过多时重叠) // 显示最近 200 根K线而非全部挤压(避免K线过多时重叠)
if (totalBars > visibleBarsCount) { if (totalBars > visibleBarsCount) {
const rangeFrom = totalBars - visibleBarsCount; const rangeFrom = totalBars - visibleBarsCount;
@@ -79,12 +71,8 @@ function chartTvFinalize(ctx) {
} }
} }
// 立即同步其他图表到主图表的范围(无 pending 时) // 立即同步其他图表到主图表的范围
setTimeout(() => { setTimeout(() => {
if (window._pendingRestoreView) {
restoreChartViewState(allChartsNow, window._pendingRestoreView, { preferTime: true });
return;
}
const logRange = mainChart.timeScale().getVisibleLogicalRange(); const logRange = mainChart.timeScale().getVisibleLogicalRange();
if (logRange) { if (logRange) {
console.log('🔧 同步可见范围:', logRange); console.log('🔧 同步可见范围:', logRange);
@@ -132,10 +120,11 @@ function chartTvFinalize(ctx) {
} }
const defaultMAs = [ const defaultMAs = [
{ type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: false }, // { type: 'EMA', length: 13, color: '#800080', name: 'EMA13', visible: true }, //
{ type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: true }, // 黑色 · 默认开 { type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: true }, // 橙色
{ type: 'SMA', length: 30, color: '#1E90FF', name: 'MA30', visible: true }, // 蓝色 · 默认开 { type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: false }, // 黑色
{ type: 'SMA', length: 250, color: '#800080', name: 'MA250', visible: true } // 紫色 · 默认开 { type: 'EMA', length: 104, color: '#1E90FF', name: 'EMA104', visible: false }, // 蓝色
{ type: 'EMA', length: 156, color: '#F700FF', name: 'EMA156', visible: false } // 粉色
]; ];
defaultMAs.forEach(ma => { defaultMAs.forEach(ma => {
@@ -202,9 +191,9 @@ function chartTvFinalize(ctx) {
window._pendingRestoreView = null; window._pendingRestoreView = null;
if (pending) { if (pending) {
// 恢复刷新前的缩放和位置(时间范围优先,避免数据滑动后逻辑索引错位 // 恢复刷新前的缩放和位置(优先可见范围/逻辑范围,最后回退到滚动位置
console.log('📌 恢复图表视图:', JSON.stringify(pending)); console.log('📌 恢复图表视图:', JSON.stringify(pending));
restoreChartViewState(allCharts, pending, { preferTime: true }); restoreChartViewState(allCharts, pending);
} else { } else {
// 无保存视图,正常同步主图到子图 // 无保存视图,正常同步主图到子图
const visibleRange = mainChart.timeScale().getVisibleRange(); const visibleRange = mainChart.timeScale().getVisibleRange();
+69 -234
View File
@@ -1,216 +1,6 @@
/* chart_tv_overlays.js — structure zones / wyckoff / BSP / FX / bollinger */ /* chart_tv_overlays.js — structure zones / wyckoff / BSP / FX / bollinger */
/** 标记 time 必须落在主 series 的 K 线 time 上,否则 LWC 会抛 Value is null */
function alignMarkersToCandles(markers, candles) {
if (!Array.isArray(markers) || !markers.length) return [];
if (!Array.isArray(candles) || !candles.length) return [];
var times = [];
for (var i = 0; i < candles.length; i++) {
var ct = candles[i] && candles[i].time;
if (ct == null || !isFinite(Number(ct))) continue;
times.push(Math.floor(Number(ct)));
}
if (!times.length) return [];
var set = {};
for (var j = 0; j < times.length; j++) set[times[j]] = true;
var nearest = function (target) {
var best = times[0];
var bestDiff = Math.abs(best - target);
// 两端夹逼:大数据量时比全扫略好
var lo = 0, hi = times.length - 1;
while (lo <= hi) {
var mid = (lo + hi) >> 1;
var t = times[mid];
var d = Math.abs(t - target);
if (d < bestDiff) { best = t; bestDiff = d; }
if (t < target) lo = mid + 1;
else hi = mid - 1;
}
if (lo < times.length) {
var d2 = Math.abs(times[lo] - target);
if (d2 < bestDiff) best = times[lo];
}
if (hi >= 0) {
var d3 = Math.abs(times[hi] - target);
if (d3 < bestDiff) best = times[hi];
}
return best;
};
var out = [];
for (var k = 0; k < markers.length; k++) {
var m = markers[k];
if (!m || m.time == null || !isFinite(Number(m.time))) continue;
var t0 = Math.floor(Number(m.time));
var aligned = set[t0] ? t0 : nearest(t0);
var copy = Object.assign({}, m, { time: aligned });
out.push(copy);
}
return out;
}
function safeOverlayLineSetData(series, points) {
if (!series || typeof series.setData !== 'function' || !Array.isArray(points) || points.length < 2) return;
try {
var a = points[0], b = points[1];
if (!a || !b || a.time == null || b.time == null) return;
var t0 = Math.floor(Number(a.time));
var t1 = Math.floor(Number(b.time));
var v0 = Number(a.value);
var v1 = Number(b.value);
if (!isFinite(t0) || !isFinite(t1) || !isFinite(v0) || !isFinite(v1)) return;
// 竖边不用折线(任意时间差都会斜),改走 canvas
if (t0 === t1) return;
if (t0 > t1) {
series.setData([{ time: t1, value: v1 }, { time: t0, value: v0 }]);
} else {
series.setData([{ time: t0, value: v0 }, { time: t1, value: v1 }]);
}
} catch (e) {
console.warn('叠层线 setData 跳过:', e && e.message ? e.message : e);
}
}
function pushFxBoxVertical(time, lo, hi, color) {
if (!window._fxBoxVerticals) window._fxBoxVerticals = [];
var t = Math.floor(Number(time));
var a = Number(lo), b = Number(hi);
if (!isFinite(t) || !isFinite(a) || !isFinite(b) || a === b) return;
window._fxBoxVerticals.push({
time: t,
lo: Math.min(a, b),
hi: Math.max(a, b),
color: color || '#888'
});
}
function getMainPriceSeries() {
if (!window.tvWidget || !tvWidget.series) return null;
var s = tvWidget.series;
return s.candleSeries || s.klcSeries || s.barSeries || s.heikinSeries || s.renkoSeries ||
s.lineSeries || s.areaSeries || s.baselineSeries || null;
}
function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) {
if (!mainChart || !mainChartContainer) return;
if (typeof window._fxBoxOverlayCleanup === 'function') {
try { window._fxBoxOverlayCleanup(); } catch (e) {}
window._fxBoxOverlayCleanup = null;
}
var canvas = mainChartContainer.querySelector('.fx-box-vert-overlay');
if (!canvas) {
canvas = document.createElement('canvas');
canvas.className = 'fx-box-vert-overlay';
canvas.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none;z-index:6;';
if (getComputedStyle(mainChartContainer).position === 'static') {
mainChartContainer.style.position = 'relative';
}
mainChartContainer.appendChild(canvas);
}
var lastSig = '';
var watchRaf = null;
var cleaned = false;
var redrawPending = false;
var quant = function (v) {
if (v == null || !isFinite(Number(v))) return 'n';
return String(Math.round(Number(v)));
};
// LWC 4 无 priceScale 订阅:采样坐标变化(含增量 setData 后自动缩放)
var sampleSig = function () {
var boxes = window._fxBoxVerticals || [];
var series = getMainPriceSeries();
if (!series || !boxes.length) return '0';
var ts = mainChart.timeScale();
var a = boxes[0];
var b = boxes[boxes.length - 1];
return [
boxes.length,
quant(ts.timeToCoordinate(a.time)),
quant(series.priceToCoordinate(a.hi)),
quant(series.priceToCoordinate(a.lo)),
quant(ts.timeToCoordinate(b.time)),
quant(series.priceToCoordinate(b.hi)),
quant(series.priceToCoordinate(b.lo))
].join('|');
};
var redraw = function () {
var boxes = window._fxBoxVerticals || [];
var series = getMainPriceSeries();
var rect = mainChartContainer.getBoundingClientRect();
var dpr = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
canvas.style.width = rect.width + 'px';
canvas.style.height = rect.height + 'px';
var ctx2 = canvas.getContext('2d');
if (!ctx2) return;
ctx2.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx2.clearRect(0, 0, rect.width, rect.height);
if (!series || !boxes.length) {
lastSig = sampleSig();
return;
}
var ts = mainChart.timeScale();
for (var i = 0; i < boxes.length; i++) {
var box = boxes[i];
var x = ts.timeToCoordinate(box.time);
var y1 = series.priceToCoordinate(box.hi);
var y2 = series.priceToCoordinate(box.lo);
if (x == null || y1 == null || y2 == null) continue;
ctx2.beginPath();
ctx2.strokeStyle = box.color;
ctx2.lineWidth = 1;
ctx2.setLineDash([4, 3]);
ctx2.moveTo(Math.round(x) + 0.5, y1);
ctx2.lineTo(Math.round(x) + 0.5, y2);
ctx2.stroke();
}
ctx2.setLineDash([]);
lastSig = sampleSig();
};
var scheduleRedraw = function () {
if (cleaned || redrawPending) return;
redrawPending = true;
requestAnimationFrame(function () {
redrawPending = false;
if (!cleaned) redraw();
});
};
var watch = function () {
if (cleaned) return;
watchRaf = requestAnimationFrame(watch);
var sig = sampleSig();
if (sig !== lastSig) scheduleRedraw();
};
try { mainChart.timeScale().subscribeVisibleLogicalRangeChange(scheduleRedraw); } catch (e) {}
try { mainChart.timeScale().subscribeVisibleTimeRangeChange(scheduleRedraw); } catch (e) {}
var ro = null;
if (typeof ResizeObserver !== 'undefined') {
ro = new ResizeObserver(scheduleRedraw);
ro.observe(mainChartContainer);
}
window._redrawFxBoxVerticalOverlay = scheduleRedraw;
window._fxBoxOverlayCleanup = function () {
if (cleaned) return;
cleaned = true;
if (watchRaf != null) {
try { cancelAnimationFrame(watchRaf); } catch (e) {}
watchRaf = null;
}
window._redrawFxBoxVerticalOverlay = null;
try { mainChart.timeScale().unsubscribeVisibleLogicalRangeChange(scheduleRedraw); } catch (e) {}
try { mainChart.timeScale().unsubscribeVisibleTimeRangeChange(scheduleRedraw); } catch (e) {}
if (ro) try { ro.disconnect(); } catch (e) {}
try { if (canvas && canvas.parentNode) canvas.parentNode.removeChild(canvas); } catch (e) {}
};
if (!window._tvInitCleanups) window._tvInitCleanups = [];
window._tvInitCleanups.push(window._fxBoxOverlayCleanup);
scheduleRedraw();
setTimeout(scheduleRedraw, 50);
watchRaf = requestAnimationFrame(watch);
}
function chartTvRenderOverlays(ctx) { function chartTvRenderOverlays(ctx) {
window._fxBoxVerticals = [];
var symbol = ctx.symbol; var symbol = ctx.symbol;
var timeframe = ctx.timeframe; var timeframe = ctx.timeframe;
var symbolConfig = ctx.symbolConfig; var symbolConfig = ctx.symbolConfig;
@@ -1852,7 +1642,7 @@ function chartTvRenderOverlays(ctx) {
priceLineVisible: false, priceLineVisible: false,
crosshairMarkerVisible: false, crosshairMarkerVisible: false,
}); });
safeOverlayLineSetData(topSeries, [{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); topSeries.setData([{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]);
const bottomSeries = mainChart.addLineSeries({ const bottomSeries = mainChart.addLineSeries({
color: boxColor, color: boxColor,
@@ -1862,13 +1652,31 @@ function chartTvRenderOverlays(ctx) {
priceLineVisible: false, priceLineVisible: false,
crosshairMarkerVisible: false, crosshairMarkerVisible: false,
}); });
safeOverlayLineSetData(bottomSeries, [{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); bottomSeries.setData([{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]);
pushFxBoxVertical(startTs, boxLow, boxHigh, boxColor); const leftSeries = mainChart.addLineSeries({
pushFxBoxVertical(endTs, boxLow, boxHigh, boxColor); color: boxColor,
lineWidth: 1,
lineStyle: 2, // 虚线
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
});
// 左边竖线:同一 time 上下两个点(和你已有ZS绘制写法保持一致)
leftSeries.setData([{ time: startTs, value: boxLow }, { time: startTs, value: boxHigh }]);
const rightSeries = mainChart.addLineSeries({
color: boxColor,
lineWidth: 1,
lineStyle: 2, // 虚线
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
});
rightSeries.setData([{ time: endTs, value: boxLow }, { time: endTs, value: boxHigh }]);
if (!tvWidget.series.mainKlcFxBoxSeries) tvWidget.series.mainKlcFxBoxSeries = []; if (!tvWidget.series.mainKlcFxBoxSeries) tvWidget.series.mainKlcFxBoxSeries = [];
tvWidget.series.mainKlcFxBoxSeries.push(topSeries, bottomSeries); tvWidget.series.mainKlcFxBoxSeries.push(topSeries, bottomSeries, leftSeries, rightSeries);
} }
} }
@@ -2041,7 +1849,7 @@ function chartTvRenderOverlays(ctx) {
priceLineVisible: false, priceLineVisible: false,
crosshairMarkerVisible: false, crosshairMarkerVisible: false,
}); });
safeOverlayLineSetData(topSeries, [{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); topSeries.setData([{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]);
const bottomSeries = mainChart.addLineSeries({ const bottomSeries = mainChart.addLineSeries({
color: boxColor, color: boxColor,
@@ -2051,13 +1859,30 @@ function chartTvRenderOverlays(ctx) {
priceLineVisible: false, priceLineVisible: false,
crosshairMarkerVisible: false, crosshairMarkerVisible: false,
}); });
safeOverlayLineSetData(bottomSeries, [{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); bottomSeries.setData([{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]);
pushFxBoxVertical(startTs, boxLow, boxHigh, boxColor); const leftSeries = mainChart.addLineSeries({
pushFxBoxVertical(endTs, boxLow, boxHigh, boxColor); color: boxColor,
lineWidth: 1,
lineStyle: 2,
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
});
leftSeries.setData([{ time: startTs, value: boxLow }, { time: startTs, value: boxHigh }]);
const rightSeries = mainChart.addLineSeries({
color: boxColor,
lineWidth: 1,
lineStyle: 2,
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
});
rightSeries.setData([{ time: endTs, value: boxLow }, { time: endTs, value: boxHigh }]);
if (!tvWidget.series.elementKlcFxBoxSeries) tvWidget.series.elementKlcFxBoxSeries = []; if (!tvWidget.series.elementKlcFxBoxSeries) tvWidget.series.elementKlcFxBoxSeries = [];
tvWidget.series.elementKlcFxBoxSeries.push(topSeries, bottomSeries); tvWidget.series.elementKlcFxBoxSeries.push(topSeries, bottomSeries, leftSeries, rightSeries);
} }
} }
@@ -2177,7 +2002,7 @@ function chartTvRenderOverlays(ctx) {
priceLineVisible: false, priceLineVisible: false,
crosshairMarkerVisible: false, crosshairMarkerVisible: false,
}); });
safeOverlayLineSetData(topSeries, [{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]); topSeries.setData([{ time: startTs, value: boxHigh }, { time: endTs, value: boxHigh }]);
const bottomSeries = mainChart.addLineSeries({ const bottomSeries = mainChart.addLineSeries({
color: boxColor, color: boxColor,
@@ -2187,13 +2012,30 @@ function chartTvRenderOverlays(ctx) {
priceLineVisible: false, priceLineVisible: false,
crosshairMarkerVisible: false, crosshairMarkerVisible: false,
}); });
safeOverlayLineSetData(bottomSeries, [{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]); bottomSeries.setData([{ time: startTs, value: boxLow }, { time: endTs, value: boxLow }]);
pushFxBoxVertical(startTs, boxLow, boxHigh, boxColor); const leftSeries = mainChart.addLineSeries({
pushFxBoxVertical(endTs, boxLow, boxHigh, boxColor); color: boxColor,
lineWidth: 1,
lineStyle: 2,
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
});
leftSeries.setData([{ time: startTs, value: boxLow }, { time: startTs, value: boxHigh }]);
const rightSeries = mainChart.addLineSeries({
color: boxColor,
lineWidth: 1,
lineStyle: 2,
lastValueVisible: false,
priceLineVisible: false,
crosshairMarkerVisible: false,
});
rightSeries.setData([{ time: endTs, value: boxLow }, { time: endTs, value: boxHigh }]);
if (!tvWidget.series.subSubKlcFxBoxSeries) tvWidget.series.subSubKlcFxBoxSeries = []; if (!tvWidget.series.subSubKlcFxBoxSeries) tvWidget.series.subSubKlcFxBoxSeries = [];
tvWidget.series.subSubKlcFxBoxSeries.push(topSeries, bottomSeries); tvWidget.series.subSubKlcFxBoxSeries.push(topSeries, bottomSeries, leftSeries, rightSeries);
} }
} }
} catch (e) { console.error('绘制次次周期KLC分型标记出错:', e); } } catch (e) { console.error('绘制次次周期KLC分型标记出错:', e); }
@@ -2338,7 +2180,7 @@ function chartTvRenderOverlays(ctx) {
else if (klineType === 'klc') targetSeries = tvWidget.series.klcSeries; else if (klineType === 'klc') targetSeries = tvWidget.series.klcSeries;
if (targetSeries) { if (targetSeries) {
try { try {
targetSeries.setMarkers(alignMarkersToCandles(combinedMarkers, candles)); targetSeries.setMarkers(combinedMarkers);
} catch (e) { } catch (e) {
console.warn('设置主系列标记失败(可能series已释放):', e); console.warn('设置主系列标记失败(可能series已释放):', e);
} }
@@ -2467,7 +2309,7 @@ function chartTvRenderOverlays(ctx) {
else if (klineType2 === 'klc') targetSeries2 = tvWidget.series.klcSeries; else if (klineType2 === 'klc') targetSeries2 = tvWidget.series.klcSeries;
if (targetSeries2) { if (targetSeries2) {
try { try {
targetSeries2.setMarkers(alignMarkersToCandles(onlyMainAndU, candles)); targetSeries2.setMarkers(onlyMainAndU);
} catch (e) { } catch (e) {
console.warn('设置主系列标记失败(可能series已释放):', e); console.warn('设置主系列标记失败(可能series已释放):', e);
} }
@@ -2496,11 +2338,4 @@ function chartTvRenderOverlays(ctx) {
} }
} }
} }
// KLC 分型框竖边:canvas 真竖线(LWC 折线做不到不斜)
try {
syncFxBoxVerticalOverlay(mainChart, mainChartContainer);
} catch (e) {
console.warn('分型竖边 overlay 失败:', e);
}
} }
+4 -4
View File
@@ -38,7 +38,7 @@ function chartTvBuildShell(ctx) {
} }
candles = klineDataSource.map((kline) => { candles = klineDataSource.map((kline) => {
const date = new Date(kline.date); const date = new Date(kline.date);
const timestamp = Math.floor(date.getTime() / 1000); const timestamp = date.getTime() / 1000;
return { return {
time: timestamp, time: timestamp,
open: parseFloat(kline.open), open: parseFloat(kline.open),
@@ -46,7 +46,7 @@ function chartTvBuildShell(ctx) {
low: parseFloat(kline.low), low: parseFloat(kline.low),
close: parseFloat(kline.close), close: parseFloat(kline.close),
}; };
}).filter((c) => isFinite(c.time) && isFinite(c.open) && isFinite(c.high) && isFinite(c.low) && isFinite(c.close)); });
} else { } else {
if (!currentData.kline_data || !Array.isArray(currentData.kline_data)) { if (!currentData.kline_data || !Array.isArray(currentData.kline_data)) {
console.error('主周期K线数据不存在或不是数组:', currentData.kline_data); console.error('主周期K线数据不存在或不是数组:', currentData.kline_data);
@@ -54,7 +54,7 @@ function chartTvBuildShell(ctx) {
} }
candles = currentData.kline_data.map((kline) => { candles = currentData.kline_data.map((kline) => {
const date = new Date(kline.date); const date = new Date(kline.date);
const timestamp = Math.floor(date.getTime() / 1000); const timestamp = date.getTime() / 1000;
return { return {
time: timestamp, time: timestamp,
open: parseFloat(kline.open), open: parseFloat(kline.open),
@@ -62,7 +62,7 @@ function chartTvBuildShell(ctx) {
low: parseFloat(kline.low), low: parseFloat(kline.low),
close: parseFloat(kline.close), close: parseFloat(kline.close),
}; };
}).filter((c) => isFinite(c.time) && isFinite(c.open) && isFinite(c.high) && isFinite(c.low) && isFinite(c.close)); });
} }
// 根据交易对类型过滤数据(仅用于显示优化) // 根据交易对类型过滤数据(仅用于显示优化)
+15 -141
View File
@@ -1,46 +1,4 @@
/* chart_view.js — split from chart.js */ /* 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) { function updateChart(options) {
options = options || {}; options = options || {};
// 只显示旋转加载图标 // 只显示旋转加载图标
@@ -89,88 +47,9 @@ function updateChart(options) {
if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) { if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) {
try { window._analyzeXhr.abort(); } catch (e) {} 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);
const baselineSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || '';
// 自动刷新常态:只拉最近 2 根;换币对后基线不一致则禁止尾部合并(否则会叠旧缠论)
// fullAnalyze(约每 1 分钟)走全量 analyze 更新缠论
const useRecentTail = !!(
options.fromAutoRefresh &&
!options.fullAnalyze &&
chartsReady &&
hasBaseline &&
baselineSymbol &&
baselineSymbol === symbol
);
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;
}
// 手动 / 首拉:全量 analyze // 发送请求
const requestId = ++lastRequestId; // 标记本次请求
window._analyzeXhr = $.ajax({ window._analyzeXhr = $.ajax({
url: '/api/analyze', url: '/api/analyze',
data: { data: {
@@ -196,31 +75,21 @@ function updateChart(options) {
} }
// 保存当前数据 // 保存当前数据
const prevSymbol = (currentData && currentData.symbol) || window._lastChartSymbol || '';
if (currentData) { if (currentData) {
// 覆盖前断开旧引用,帮助GC尽快回收 // 覆盖前断开旧引用,帮助GC尽快回收
delete currentData.original_kline_data; delete currentData.original_kline_data;
delete currentData.original_macd; delete currentData.original_macd;
} }
currentData = data; currentData = data;
window._lastChartSymbol = symbol;
window._lastFullAnalyzeAt = Date.now();
if (typeof renderWyckoffCycleSummary === 'function') { if (typeof renderWyckoffCycleSummary === 'function') {
renderWyckoffCycleSummary(); renderWyckoffCycleSummary();
} }
// 有图则增量;笔/段/中枢/结构区只在全量 init 绘制 refreshChart(data, {
// 换币对 / 手动分析 / 结构区:必须全量重建,否则会残留旧币对叠层 incremental: options.incremental !== undefined
const ready = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart); ? !!options.incremental
const structureZonesOn = $('#showMainStructureZone').is(':checked'); : !!options.fromAutoRefresh
const symbolChanged = !!(prevSymbol && prevSymbol !== symbol); });
let wantIncremental = options.incremental !== undefined
? !!options.incremental
: (ready || !!options.fromAutoRefresh);
if (structureZonesOn || options.fullAnalyze || symbolChanged || options.incremental === false) {
wantIncremental = false;
}
refreshChart(data, { incremental: wantIncremental });
}, },
error: function(jqXHR, textStatus, errorThrown) { error: function(jqXHR, textStatus, errorThrown) {
// 隐藏加载图标 // 隐藏加载图标
@@ -252,21 +121,24 @@ function captureChartViewState(chart) {
} }
function restoreChartViewState(charts, viewState) { function restoreChartViewState(charts, viewState) {
// 全量重建备用:先缩放,再位置;不要在位置前写 rightOffset(会右边缘锚定)
if (!viewState || !Array.isArray(charts) || charts.length === 0) return; if (!viewState || !Array.isArray(charts) || charts.length === 0) return;
const validCharts = charts.filter(c => c && c.timeScale); const validCharts = charts.filter(c => c && c.timeScale);
if (validCharts.length === 0) return; if (validCharts.length === 0) return;
validCharts.forEach(c => { validCharts.forEach(c => {
try { try {
if (typeof viewState.barSpacing === 'number') { const optionsPatch = {};
c.timeScale().applyOptions({ barSpacing: viewState.barSpacing }); 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);
} }
} catch (e) {} } catch (e) {}
}); });
let restored = false; let restored = false;
// 优先按逻辑范围恢复(对新数据更稳健)
if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) { if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) {
validCharts.forEach(c => { validCharts.forEach(c => {
try { try {
@@ -276,6 +148,7 @@ function restoreChartViewState(charts, viewState) {
}); });
} }
// 逻辑范围失败时,回退到时间可见范围
if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) { if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) {
validCharts.forEach(c => { validCharts.forEach(c => {
try { try {
@@ -285,6 +158,7 @@ function restoreChartViewState(charts, viewState) {
}); });
} }
// 最后回退到滚动位置
if (!restored && typeof viewState.scrollPosition === 'number') { if (!restored && typeof viewState.scrollPosition === 'number') {
validCharts.forEach(c => { validCharts.forEach(c => {
try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {} try { c.timeScale().scrollToPosition(viewState.scrollPosition, false); } catch (e) {}
+2 -2
View File
@@ -85,9 +85,9 @@ $(document).on('change', '#showMainBiZs', function() {
$(document).on('change', '#showMainStructureZone', function() { $(document).on('change', '#showMainStructureZone', function() {
const on = $('#showMainStructureZone').is(':checked'); const on = $('#showMainStructureZone').is(':checked');
console.log('结构区切换为:', on); console.log('结构区切换为:', on);
// 勾选后才向服务器请求多周期结构区数据;结构区叠层只在全量 init 里绘制,必须 incremental:false // 勾选后才向服务器请求多周期结构区数据;取消勾选仅重绘,不重复拉取
if (on) { if (on) {
updateChart({ incremental: false }); updateChart();
} else { } else {
updateChartDisplay(); updateChartDisplay();
} }
+21 -47
View File
@@ -272,10 +272,10 @@ function loadSymbols() {
}); });
} }
// 设置默认时间范围:最近 1 个月 // 设置默认时间范围(需覆盖威科夫 lookback;1 天在 4h/1h 上几乎检不出区间)
function setDefaultTimeRange() { function setDefaultTimeRange() {
const now = new Date(); const now = new Date();
const daysBack = 30; const daysBack = 14;
const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000)); const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm // 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
@@ -493,9 +493,6 @@ $(document).ready(function() {
let autoRefreshTimer = null; let autoRefreshTimer = null;
let nextRefreshTime = null; let nextRefreshTime = null;
let autoRefreshTick = 0; let autoRefreshTick = 0;
/** 自动刷新时,缠论全量重算间隔(毫秒);时间戳见 window._lastFullAnalyzeAt */
const AUTO_FULL_ANALYZE_MS = 60 * 1000;
// 初始化自动刷新功能 // 初始化自动刷新功能
function initAutoRefresh() { function initAutoRefresh() {
// 监听自动刷新勾选框变化 // 监听自动刷新勾选框变化
@@ -522,10 +519,10 @@ function startAutoRefresh() {
stopAutoRefresh(); stopAutoRefresh();
// 获取刷新频率(分钟) // 获取刷新频率(分钟)
const interval = parseFloat($('#refreshInterval').val()) || (5 / 60); const interval = parseFloat($('#refreshInterval').val()) || 5;
const intervalMs = interval * 60 * 1000; const intervalMs = interval * 60 * 1000;
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒);缠论全量每 ${AUTO_FULL_ANALYZE_MS / 1000}s`); console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒)`);
// 计算下次刷新时间 // 计算下次刷新时间
nextRefreshTime = new Date(Date.now() + intervalMs); nextRefreshTime = new Date(Date.now() + intervalMs);
@@ -534,36 +531,16 @@ function startAutoRefresh() {
// 启动定时器 // 启动定时器
autoRefreshTick = 0; autoRefreshTick = 0;
autoRefreshTimer = setInterval(function() { autoRefreshTimer = setInterval(function() {
// 刷新前先钉住当前缩放/位置(updateEndTime / 请求返回前都可能被改写) // 更新结束时间为当前时间
if (tvWidget && tvWidget.mainChart && typeof captureChartViewState === 'function') {
try {
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
} catch (e) {
window._pendingRestoreView = null;
}
}
// 更新结束时间显示(仅 UI
updateEndTimeToNow(); updateEndTimeToNow();
// 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏)
autoRefreshTick += 1; autoRefreshTick += 1;
const now = Date.now(); const fullRebuild = (autoRefreshTick % 6) === 0;
const lastFull = window._lastFullAnalyzeAt || 0; updateChart({
const needFullAnalyze = !lastFull || (now - lastFull >= AUTO_FULL_ANALYZE_MS); fromAutoRefresh: true,
// 常态:/api/klines/recent 合并尾部 K;满 1 分钟:全量 /api/analyze 刷新缠论 incremental: !fullRebuild
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); nextRefreshTime = new Date(Date.now() + intervalMs);
@@ -807,8 +784,7 @@ function refreshChart(data, options) {
// 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts // 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts
if (preferIncremental && chartsReady) { if (preferIncremental && chartsReady) {
try { try {
// 若定时器已捕获则保留;否则此刻再捕获一次 if (tvWidget.mainChart) {
if (!window._pendingRestoreView && tvWidget.mainChart) {
try { try {
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
} catch (e) { } catch (e) {
@@ -816,10 +792,7 @@ function refreshChart(data, options) {
} }
} }
updateTradingViewData(); updateTradingViewData();
// recent-tail 刷新结构未变,跳过表格重绘以提速 updateTables(data);
if (!options.skipTables) {
updateTables(data);
}
if (currentData && currentData.ema52_dict) { if (currentData && currentData.ema52_dict) {
updateEMA52Display(currentData); updateEMA52Display(currentData);
} }
@@ -831,7 +804,7 @@ function refreshChart(data, options) {
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window // 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上 // tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) { if (tvWidget && tvWidget.mainChart) {
try { try {
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart); window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView)); console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView));
@@ -839,8 +812,6 @@ function refreshChart(data, options) {
console.warn('保存图表视图失败:', e); console.warn('保存图表视图失败:', e);
window._pendingRestoreView = null; window._pendingRestoreView = null;
} }
} else if (window._pendingRestoreView) {
console.log('📌 使用已保存图表视图:', JSON.stringify(window._pendingRestoreView));
} }
initTradingView($('#symbol').val(), $('#timeframe').val()); initTradingView($('#symbol').val(), $('#timeframe').val());
@@ -874,14 +845,14 @@ $('#showElementMacdDiv').change(function() {
refreshChartOnly(); refreshChartOnly();
}); });
// 绑定分型类型显示开关(与笔一致:全量重建,避免增量路径标记未对齐) // 绑定分型类型显示开关
$('#showKlcFxType').change(function() { $('#showKlcFxType').change(function() {
updateChartDisplay(); refreshChartOnly();
}); });
// 绑定小周期分型显示开关 // 绑定小周期分型显示开关
$('#showElementKlcFxType').change(function() { $('#showElementKlcFxType').change(function() {
updateChartDisplay(); refreshChart(currentData);
}); });
@@ -894,7 +865,10 @@ $('#showElementBollinger').change(function() {
updateChartDisplay(); updateChartDisplay();
}); });
// K线周期切换由 macd_ui.js 统一走 updateChartDisplay(勿再绑 refreshChart,会重复且易漏对齐) // 绑定K线周期切换
$('input[name="klinePeriod"]').change(function() {
refreshChart(currentData);
});
// 绑定主图U显示开关 // 绑定主图U显示开关
$('#toggleUOnMain').change(function() { $('#toggleUOnMain').change(function() {
+5 -21
View File
@@ -4,16 +4,6 @@ window.App.Charts = (function() {
// 依赖 Indicators // 依赖 Indicators
const Indicators = (window.App && window.App.Indicators) || {}; const Indicators = (window.App && window.App.Indicators) || {};
function sanitizeLinePoints(points) {
if (!Array.isArray(points)) return [];
return points.filter(function (p) {
return p && p.time != null && p.value != null &&
isFinite(Number(p.time)) && isFinite(Number(p.value));
}).map(function (p) {
return { time: Math.floor(Number(p.time)), value: Number(p.value) };
});
}
function addMovingAveragesToChart(candleData) { function addMovingAveragesToChart(candleData) {
if (!window.tvWidget || !tvWidget.mainChart || !candleData || candleData.length === 0) return; if (!window.tvWidget || !tvWidget.mainChart || !candleData || candleData.length === 0) return;
if (!window.movingAverages) return; if (!window.movingAverages) return;
@@ -31,8 +21,6 @@ window.App.Charts = (function() {
try { try {
const maData = Indicators.calculateMA(candleData, maConfig.type, maConfig.length, maConfig.source); const maData = Indicators.calculateMA(candleData, maConfig.type, maConfig.length, maConfig.source);
const smoothedData = maConfig.smoothType !== 'none' ? (window.applySmoothToMA ? window.applySmoothToMA(maData, maConfig.smoothType, maConfig.smoothLength) : maData) : maData; const smoothedData = maConfig.smoothType !== 'none' ? (window.applySmoothToMA ? window.applySmoothToMA(maData, maConfig.smoothType, maConfig.smoothLength) : maData) : maData;
const cleanData = sanitizeLinePoints(smoothedData);
if (!cleanData.length) return;
const maSeries = tvWidget.mainChart.addLineSeries({ const maSeries = tvWidget.mainChart.addLineSeries({
color: maConfig.color, color: maConfig.color,
lineWidth: maConfig.lineWidth || 2, lineWidth: maConfig.lineWidth || 2,
@@ -42,8 +30,8 @@ window.App.Charts = (function() {
priceLineVisible: false, priceLineVisible: false,
crosshairMarkerVisible: true, crosshairMarkerVisible: true,
}); });
maSeries.setData(cleanData); maSeries.setData(smoothedData);
maConfig.data = cleanData; maConfig.data = smoothedData;
tvWidget.series.maSeries.push(maSeries); tvWidget.series.maSeries.push(maSeries);
} catch(e) {} } catch(e) {}
}); });
@@ -63,16 +51,12 @@ window.App.Charts = (function() {
if (!bbConfig.visible) return; if (!bbConfig.visible) return;
try { try {
const bbData = Indicators.calculateBB(candleData, bbConfig.length, bbConfig.upperMultiplier, bbConfig.lowerMultiplier, bbConfig.source); const bbData = Indicators.calculateBB(candleData, bbConfig.length, bbConfig.upperMultiplier, bbConfig.lowerMultiplier, bbConfig.source);
const upper = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.upper })));
const middle = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.middle })));
const lower = sanitizeLinePoints(bbData.map(item => ({ time: item.time, value: item.lower })));
if (!upper.length || !middle.length || !lower.length) return;
const upperSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.upperColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true }); const upperSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.upperColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
const middleSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.middleColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true }); const middleSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.middleColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
const lowerSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.lowerColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true }); const lowerSeries = tvWidget.mainChart.addLineSeries({ color: bbConfig.lowerColor, lineWidth: bbConfig.lineWidth || 2, lineStyle: bbConfig.lineStyle || 0, lastValueVisible: false, priceLineVisible: false, crosshairMarkerVisible: true });
upperSeries.setData(upper); upperSeries.setData(bbData.map(item => ({ time: item.time, value: item.upper })));
middleSeries.setData(middle); middleSeries.setData(bbData.map(item => ({ time: item.time, value: item.middle })));
lowerSeries.setData(lower); lowerSeries.setData(bbData.map(item => ({ time: item.time, value: item.lower })));
bbConfig.data = bbData; bbConfig.data = bbData;
tvWidget.series.bbSeries.push(upperSeries, middleSeries, lowerSeries); tvWidget.series.bbSeries.push(upperSeries, middleSeries, lowerSeries);
} catch(e) {} } catch(e) {}
+1 -4
View File
@@ -63,10 +63,7 @@ window.App.Indicators = (function() {
default: default:
value = sourceData[i]; value = sourceData[i];
} }
if (value == null || !isFinite(value) || data[i].time == null || !isFinite(Number(data[i].time))) { result.push({ time: data[i].time, value });
continue;
}
result.push({ time: Math.floor(Number(data[i].time)), value: Number(value) });
} }
return result; return result;
} }
+23 -23
View File
@@ -22,8 +22,8 @@
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- TradingView Widget BEGIN --> <!-- TradingView Widget BEGIN -->
<script src="https://cdn.jsdelivr.net/npm/lightweight-charts@4.0.1/dist/lightweight-charts.standalone.production.js"></script> <script src="https://cdn.jsdelivr.net/npm/lightweight-charts@4.0.1/dist/lightweight-charts.standalone.production.js"></script>
<script defer src="{{ url_for('static', filename='js/indicators.js') }}?v=20260809i"></script> <script defer src="{{ url_for('static', filename='js/indicators.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/charts.js') }}?v=20260809i"></script> <script defer src="{{ url_for('static', filename='js/charts.js') }}"></script>
<!-- TradingView Widget END --> <!-- TradingView Widget END -->
<script> <script>
window.AVAILABLE_TIMEFRAMES = JSON.parse('{{ timeframe_keys_json | safe }}'); window.AVAILABLE_TIMEFRAMES = JSON.parse('{{ timeframe_keys_json | safe }}');
@@ -982,7 +982,7 @@
<input type="datetime-local" id="end_time" class="form-control"> <input type="datetime-local" id="end_time" class="form-control">
</div> </div>
<div class="col-md-1"> <div class="col-md-1">
<button class="btn btn-primary w-100" onclick="updateEndTimeToNow(); updateChart({ incremental: false, fullAnalyze: true })" style="padding: 8px 6px; font-size: 14px;"> <button class="btn btn-primary w-100" onclick="updateChart()" style="padding: 8px 6px; font-size: 14px;">
分析 分析
</button> </button>
</div> </div>
@@ -1028,14 +1028,14 @@
<div class="d-flex align-items-center mb-2"> <div class="d-flex align-items-center mb-2">
<label for="refreshInterval" class="form-label me-2 mb-0">自动刷新:</label> <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;"> <select id="refreshInterval" class="form-select form-select-sm me-2" style="width: 80px;">
<option value="0.0833" selected>5秒</option> <option value="0.0833">5秒</option>
<option value="0.1667">10秒</option> <option value="0.1667">10秒</option>
<option value="0.25">15秒</option> <option value="0.25">15秒</option>
<option value="0.5">30秒</option> <option value="0.5">30秒</option>
<option value="1">1分钟</option> <option value="1">1分钟</option>
<option value="2">2分钟</option> <option value="2">2分钟</option>
<option value="3">3分钟</option> <option value="3">3分钟</option>
<option value="5">5分钟</option> <option value="5" selected>5分钟</option>
<option value="10">10分钟</option> <option value="10">10分钟</option>
</select> </select>
<div class="form-check form-check-inline me-2"> <div class="form-check form-check-inline me-2">
@@ -1383,24 +1383,24 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script> <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') }}?v=20260808i"></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') }}?v=20260808i"></script> <script defer src="{{ url_for('static', filename='js/app/state.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}?v=20260808i"></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=20260808j"></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=20260808i"></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=20260809q"></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=20260808i"></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=20260809j"></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=20260808i"></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=20260808i"></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=20260809o"></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=20260809d"></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=20260808i"></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=20260809j"></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=20260808i"></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=20260809j"></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') }}?v=20260808i"></script> <script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260808i"></script> <script defer src="{{ url_for('static', filename='js/app/main.js') }}"></script>
<!-- 均线配置弹窗 --> <!-- 均线配置弹窗 -->
<div id="maConfigModal" class="ma-config-modal"> <div id="maConfigModal" class="ma-config-modal">
File diff suppressed because it is too large Load Diff
-22
View File
@@ -64,33 +64,11 @@ def test_analyze_route_registered():
rules = {r.rule for r in app.url_map.iter_rules()} rules = {r.rule for r in app.url_map.iter_rules()}
assert "/api/analyze" in rules assert "/api/analyze" in rules
assert "/api/klines/recent" in rules
assert "/api/chart_metadata" in rules assert "/api/chart_metadata" in rules
assert "/" in rules assert "/" in rules
assert "/chan_tv" 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(): def test_contract_keys_stable():
assert "bi_list" in CONTRACT_KEYS and "seg_list" in CONTRACT_KEYS assert "bi_list" in CONTRACT_KEYS and "seg_list" in CONTRACT_KEYS
for k in ("kline_data", "macd", "zs_list", "bsp_list", "chan_macd"): for k in ("kline_data", "macd", "zs_list", "bsp_list", "chan_macd"):
+2 -77
View File
@@ -31,8 +31,6 @@ def test_wyckoff_crypto_page_ok(client):
resp = client.get("/wyckoff_crypto") resp = client.get("/wyckoff_crypto")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"Crypto Wyckoff Screener" in resp.data 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): def test_wyckoff_crypto_meta_ok(client):
@@ -40,84 +38,11 @@ def test_wyckoff_crypto_meta_ok(client):
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.get_json() data = resp.get_json()
assert "engine_version" in data assert "engine_version" in data
assert data.get("combo", {}).get("id") == "h8_4_1" assert data.get("timeframes") == ["1d", "1w", "1M"]
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): def test_wyckoff_crypto_scan_ok(client):
resp = client.get("/api/wyckoff_crypto/scan?limit=5&combo_id=h8_4_1") resp = client.get("/api/wyckoff_crypto/scan?limit=5")
assert resp.status_code == 200 assert resp.status_code == 200
data = resp.get_json() data = resp.get_json()
assert "rows" in data 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