fix(web): 自动刷新保留 K 线视窗;威科夫与图表增量更新
自动刷新改用 tail update 与 scrollToPosition 恢复视窗,避免 setData 后跳到最右;拆分 chart_tv 模块并扩展 analyze/recent API。同步威科夫分析、pipeline 增量构建及相关策略与配置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -64,11 +64,33 @@ def test_analyze_route_registered():
|
||||
|
||||
rules = {r.rule for r in app.url_map.iter_rules()}
|
||||
assert "/api/analyze" in rules
|
||||
assert "/api/klines/recent" in rules
|
||||
assert "/api/chart_metadata" in rules
|
||||
assert "/" in rules
|
||||
assert "/chan_tv" in rules
|
||||
|
||||
|
||||
def test_klines_recent_returns_tail_only():
|
||||
from app import app
|
||||
|
||||
df = make_ohlcv(n=30)
|
||||
# analyze 蓝图 star-import 后绑定在 api.analyze 命名空间
|
||||
with patch("api.analyze.get_kl_data", return_value=df):
|
||||
client = app.test_client()
|
||||
resp = client.get(
|
||||
"/api/klines/recent",
|
||||
query_string={"symbol": "BTC/USDT:USDT", "timeframe": "5m", "limit": 2},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.get_json()
|
||||
assert body.get("partial") is True
|
||||
assert body.get("limit") == 2
|
||||
assert isinstance(body.get("kline_data"), list)
|
||||
assert len(body["kline_data"]) == 2
|
||||
assert "bi_list" not in body
|
||||
assert "wyckoff" not in body
|
||||
|
||||
|
||||
def test_contract_keys_stable():
|
||||
assert "bi_list" in CONTRACT_KEYS and "seg_list" in CONTRACT_KEYS
|
||||
for k in ("kline_data", "macd", "zs_list", "bsp_list", "chan_macd"):
|
||||
@@ -127,11 +149,13 @@ def test_analyze_http_contract_with_mocked_kl():
|
||||
assert payload is not None and "error" not in payload
|
||||
missing = [k for k in CONTRACT_KEYS if k not in payload]
|
||||
assert not missing, f"missing contract keys: {missing}"
|
||||
assert "wyckoff" not in payload
|
||||
assert "wyckoff" in payload
|
||||
for k in WYCKOFF_KEYS:
|
||||
assert k in payload["wyckoff"], f"missing wyckoff key: {k}"
|
||||
|
||||
|
||||
def test_analyze_http_wyckoff_opt_in():
|
||||
"""include_wyckoff=1 时响应含 wyckoff 约定键;默认不返回。"""
|
||||
def test_analyze_http_wyckoff_can_opt_out():
|
||||
"""include_wyckoff=0 时可显式跳过威科夫。"""
|
||||
from app import app
|
||||
from services.runtime import add_indicators
|
||||
|
||||
@@ -148,19 +172,49 @@ def test_analyze_http_wyckoff_opt_in():
|
||||
"symbol": "BTC/USDT:USDT",
|
||||
"timeframe": "5m",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"include_wyckoff": 1,
|
||||
"include_wyckoff": 0,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.data[:500]
|
||||
payload = resp.get_json()
|
||||
assert payload is not None and "wyckoff" in payload
|
||||
w = payload["wyckoff"]
|
||||
for k in WYCKOFF_KEYS:
|
||||
assert k in w, f"missing wyckoff key: {k}"
|
||||
assert payload is not None and "wyckoff" not in payload
|
||||
|
||||
|
||||
def test_analyze_http_wyckoff_for_three_timeframes():
|
||||
"""主/次/次次均返回各自 wyckoff 载荷。"""
|
||||
from app import app
|
||||
from services.runtime import add_indicators
|
||||
|
||||
df = add_indicators(make_ohlcv(300))
|
||||
df = df.copy()
|
||||
if "timestamp" not in df.columns:
|
||||
df["timestamp"] = (pd.to_datetime(df["date"]).astype("int64") // 10**6).astype("int64")
|
||||
|
||||
with patch("api.analyze.get_kl_data", return_value=df):
|
||||
client = app.test_client()
|
||||
resp = client.get(
|
||||
"/api/analyze",
|
||||
query_string={
|
||||
"symbol": "BTC/USDT:USDT",
|
||||
"timeframe": "4h",
|
||||
"element_timeframe": "2h",
|
||||
"sub_sub_timeframe": "1h",
|
||||
"timezone": "Asia/Shanghai",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.data[:500]
|
||||
payload = resp.get_json()
|
||||
assert payload is not None and "error" not in payload
|
||||
assert "wyckoff" in payload
|
||||
assert "element_wyckoff" in payload
|
||||
assert "sub_sub_wyckoff" in payload
|
||||
for key in ("wyckoff", "element_wyckoff", "sub_sub_wyckoff"):
|
||||
for k in WYCKOFF_KEYS:
|
||||
assert k in payload[key], f"missing {k} in {key}"
|
||||
|
||||
|
||||
def test_analyze_http_wyckoff_skipped_when_elements_only():
|
||||
"""elements_only=true 时即使 include_wyckoff=1 也不返回 wyckoff。"""
|
||||
"""elements_only=true 时不返回 wyckoff。"""
|
||||
from app import app
|
||||
from services.runtime import add_indicators
|
||||
|
||||
@@ -179,7 +233,6 @@ def test_analyze_http_wyckoff_skipped_when_elements_only():
|
||||
"element_timeframe": "1m",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"elements_only": "true",
|
||||
"include_wyckoff": 1,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 200, resp.data[:500]
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
"""ECR-009: page/API smoke without requiring live provider during assert."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure repo root + web on path like app.py
|
||||
_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
_WEB = os.path.join(_ROOT, "web")
|
||||
for p in (_ROOT, _WEB):
|
||||
if p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
os.environ.setdefault("CRYPTO_WYCKOFF_DISABLE", "1")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
from app import create_app
|
||||
|
||||
app = create_app()
|
||||
app.config["TESTING"] = True
|
||||
with app.test_client() as c:
|
||||
yield c
|
||||
|
||||
|
||||
def test_wyckoff_crypto_page_ok(client):
|
||||
resp = client.get("/wyckoff_crypto")
|
||||
assert resp.status_code == 200
|
||||
assert b"Crypto Wyckoff Screener" in resp.data
|
||||
assert b"fCombo" in resp.data
|
||||
assert b"chartCanvas" in resp.data
|
||||
|
||||
|
||||
def test_wyckoff_crypto_meta_ok(client):
|
||||
resp = client.get("/api/wyckoff_crypto/meta")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "engine_version" in data
|
||||
assert data.get("combo", {}).get("id") == "h8_4_1"
|
||||
assert data["combo"]["low"] == "1h"
|
||||
ids = {c["id"] for c in data.get("combos") or []}
|
||||
assert "h8_4_1" in ids and "d_w_m" in ids
|
||||
|
||||
|
||||
def test_wyckoff_crypto_scan_ok(client):
|
||||
resp = client.get("/api/wyckoff_crypto/scan?limit=5&combo_id=h8_4_1")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "rows" in data
|
||||
assert data.get("combo", {}).get("id") == "h8_4_1"
|
||||
|
||||
|
||||
def test_wyckoff_crypto_klines_bad_request(client):
|
||||
resp = client.get("/api/wyckoff_crypto/klines")
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_wyckoff_crypto_klines_ok(client):
|
||||
resp = client.get(
|
||||
"/api/wyckoff_crypto/klines?symbol=BTC/USDT:USDT&tf=1h&limit=10&combo_id=h8_4_1"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "items" in data
|
||||
assert data.get("tf") == "1h"
|
||||
assert data.get("intraday") is True
|
||||
if data["items"]:
|
||||
assert "datetime" in data["items"][0]
|
||||
assert "ts" in data["items"][0]
|
||||
assert "T" in data["items"][0]["datetime"]
|
||||
assert "+08:00" in data["items"][0]["datetime"]
|
||||
|
||||
|
||||
def test_wyckoff_crypto_klines_bad_limit_ok(client):
|
||||
resp = client.get(
|
||||
"/api/wyckoff_crypto/klines?symbol=BTC/USDT:USDT&tf=1h&limit=abc&combo_id=h8_4_1"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
def test_wyckoff_crypto_overlay_ok(client):
|
||||
resp = client.get(
|
||||
"/api/wyckoff_crypto/overlay?symbol=BTC/USDT:USDT&tf=1h&bars=60&combo_id=h8_4_1"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "phases" in data
|
||||
assert "events" in data
|
||||
|
||||
|
||||
def test_combos_add_and_list(client, tmp_path, monkeypatch):
|
||||
from crypto_wyckoff import combos as cm
|
||||
|
||||
monkeypatch.setattr(cm, "_COMBOS_FILE", tmp_path / "combos.json")
|
||||
monkeypatch.setattr(cm, "_cache", None)
|
||||
|
||||
resp = client.get("/api/wyckoff_crypto/combos")
|
||||
assert resp.status_code == 200
|
||||
assert len(resp.get_json()["combos"]) >= 2
|
||||
|
||||
bad = client.post(
|
||||
"/api/wyckoff_crypto/combos",
|
||||
json={"high": "1h", "mid": "4h", "low": "8h"},
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
|
||||
ok = client.post(
|
||||
"/api/wyckoff_crypto/combos",
|
||||
json={"high": "12h", "mid": "4h", "low": "1h", "label": "12h/4h/1h"},
|
||||
)
|
||||
assert ok.status_code == 200
|
||||
cid = ok.get_json()["combo"]["id"]
|
||||
assert cid == "12h_4h_1h"
|
||||
|
||||
deleted = client.delete(f"/api/wyckoff_crypto/combos/{cid}")
|
||||
assert deleted.status_code == 200
|
||||
|
||||
builtin = client.delete("/api/wyckoff_crypto/combos/h8_4_1")
|
||||
assert builtin.status_code == 400
|
||||
Reference in New Issue
Block a user