refactor: 精简仓库为 chanlun 核心与 web 分析,移除威科夫与遗留模块

删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-27 01:05:12 +08:00
co-authored by Cursor
parent 5c10e35b76
commit 7f393b93ed
360 changed files with 140008 additions and 41167 deletions
-126
View File
@@ -8,96 +8,6 @@ from services.runtime.timeframes import _zone_cache_ttl
bp = Blueprint("analyze", __name__)
_WYCKOFF_EMPTY = {
'trading_range': None,
'bias': 'unknown',
'phases': [],
'events': [],
'volume_profile': {'bins': [], 'poc': None, 'vah': None, 'val': None, 'bin_count': 0},
'volume_confirm': {'avg_volume': 0.0, 'event_checks': {}},
'cycles': [],
'live': None,
'lifecycle': 'UNKNOWN',
}
def _localize_wyckoff_payload(w, client_tz):
"""把威科夫时间统一成客户端时区 ISO,便于与主图对齐。"""
if not w:
return w
def _loc_tr(tr):
if not tr:
return
tr['start_time'] = format_time_safely(tr.get('start_time'), client_tz) or tr.get('start_time')
tr['end_time'] = format_time_safely(tr.get('end_time'), client_tz) or tr.get('end_time')
def _loc_cycle(c):
if not c:
return
per = c.get('period') or {}
per['start_time'] = format_time_safely(per.get('start_time'), client_tz) or per.get('start_time')
per['end_time'] = format_time_safely(per.get('end_time'), client_tz) or per.get('end_time')
c['period'] = per
_loc_tr(c.get('trading_range'))
for ph in c.get('phases') or []:
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
for ev in c.get('events') or []:
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
_loc_tr(w.get('trading_range'))
for ph in w.get('phases') or []:
ph['start_time'] = format_time_safely(ph.get('start_time'), client_tz) or ph.get('start_time')
ph['end_time'] = format_time_safely(ph.get('end_time'), client_tz) or ph.get('end_time')
for ev in w.get('events') or []:
ev['time'] = format_time_safely(ev.get('time'), client_tz) or ev.get('time')
for c in w.get('cycles') or []:
_loc_cycle(c)
return w
def _compute_wyckoff_from_df(df, tf, vp_bins, client_tz=None, range_start_time=None, prefer_start_time=None):
"""直接用该周期已有 DataFrame(与缠论同一份)。
搜索窗口 = 整段数据;箱体在窗内评分选取(近优分取更长),
次/次次可用 prefer_start_time 对齐主箱起点。
"""
from chanlun.analysis.wyckoff import analyze_wyckoff
try:
if df is None or len(df) < 30:
empty = dict(_WYCKOFF_EMPTY)
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
empty['timeframe'] = tf
return empty
lookback = len(df)
min_bars = max(24, min(80, lookback // 12))
out = analyze_wyckoff(
df,
lookback=lookback,
vp_bins=vp_bins,
min_bars=min_bars,
range_start_time=range_start_time,
prefer_start_time=prefer_start_time,
)
out['timeframe'] = tf
out['lookback'] = lookback
out['min_bars'] = min_bars
if client_tz is not None:
_localize_wyckoff_payload(out, client_tz)
return out
except Exception as e:
print(f"Wyckoff 分析出错 ({tf}): {e}")
import traceback
traceback.print_exc()
empty = dict(_WYCKOFF_EMPTY)
empty['volume_profile'] = dict(_WYCKOFF_EMPTY['volume_profile'])
empty['volume_confirm'] = dict(_WYCKOFF_EMPTY['volume_confirm'])
empty['timeframe'] = tf
empty['error'] = str(e)
return empty
@bp.route('/api/analyze')
def analyze():
@@ -119,9 +29,6 @@ def analyze():
# 获取分形元素时间周期与次次周期
element_timeframe = request.args.get('element_timeframe')
sub_sub_timeframe = request.args.get('sub_sub_timeframe')
# 供文末三周期威科夫复用(避免重复拉数)
element_df_for_wyckoff = None
sub_sub_df_for_wyckoff = None
# 获取是否只需要分形元素数据的参数
elements_only_param = request.args.get('elements_only')
@@ -346,7 +253,6 @@ def analyze():
if element_df is not None and len(element_df) > 0:
# 添加小周期技术指标(包括布林带)
element_df = add_indicators(element_df)
element_df_for_wyckoff = element_df
# 对小周期数据进行缠论分析
element_analysis = analyze_chan(element_df, symbol, element_timeframe)
@@ -525,7 +431,6 @@ def analyze():
sub_sub_df = get_kl_data(symbol, sub_sub_timeframe, start_time=start_time, end_time=end_time)
if sub_sub_df is not None and len(sub_sub_df) > 0:
sub_sub_df = add_indicators(sub_sub_df)
sub_sub_df_for_wyckoff = sub_sub_df
sub_sub_analysis = analyze_chan(sub_sub_df, symbol, sub_sub_timeframe)
result['sub_sub_timeframe'] = sub_sub_timeframe
result['sub_sub_kline_data'] = clean_dataframe_for_json(sub_sub_df).to_dict('records')
@@ -755,37 +660,6 @@ def analyze():
else:
result['structure_zones'] = []
# 威科夫:主 / 次 / 次次各算一份(非 elements_only);前端开关只控制绘制
# include_wyckoff=0 可显式跳过;缺省与其它真值均计算
include_wyckoff_param = request.args.get('include_wyckoff', '1')
include_wyckoff = str(include_wyckoff_param).lower() not in ('0', 'false', 'no')
if include_wyckoff and not elements_only:
# 主周期先算;次/次次只同步 active=cycles[0] 的 startWYCKOFF-MULTI-CYCLE-001
wyckoff_bins = max(10, min(int(request.args.get('wyckoff_vp_bins', 24)), 24))
result['wyckoff'] = _compute_wyckoff_from_df(df, timeframe, wyckoff_bins, client_tz=None)
main_w = result.get('wyckoff') or {}
cycles = main_w.get('cycles') or []
# active 唯一来源 cycles[0];禁止 cycles[-1]
active = cycles[0] if cycles else None
prefer_start = None
if active:
prefer_start = ((active.get('trading_range') or {}).get('start_time')
or (active.get('period') or {}).get('start_time'))
elif main_w.get('trading_range'):
prefer_start = main_w['trading_range'].get('start_time')
if client_tz is not None:
_localize_wyckoff_payload(result['wyckoff'], client_tz)
if element_timeframe:
result['element_wyckoff'] = _compute_wyckoff_from_df(
element_df_for_wyckoff, element_timeframe, wyckoff_bins, client_tz,
prefer_start_time=prefer_start,
)
if sub_sub_timeframe:
result['sub_sub_wyckoff'] = _compute_wyckoff_from_df(
sub_sub_df_for_wyckoff, sub_sub_timeframe, wyckoff_bins, client_tz,
prefer_start_time=prefer_start,
)
return jsonify(result)
-236
View File
@@ -1,236 +0,0 @@
"""Crypto Wyckoff Screener API + page (independent of /api/analyze)."""
from __future__ import annotations
import os
import threading
from flask import Blueprint, jsonify, render_template, request
from crypto_wyckoff.combos import (
ALLOWED_TFS,
add_combo,
delete_combo,
get_combo,
list_combos,
)
from crypto_wyckoff.domain_models import DecisionSignal, WyckoffCycle, WyckoffEvent, WyckoffPhase
from crypto_wyckoff.scheduler import get_status, run_tick, start_scheduler
from crypto_wyckoff import store as wyckoff_store
from crypto_wyckoff.symbols_cn import display_name_cn, symbol_name_map
from crypto_wyckoff.version import ARCHITECTURE_VERSION, WYCKOFF_ENGINE_VERSION
bp = Blueprint("wyckoff_crypto", __name__)
_scheduler_started = False
_sched_lock = threading.Lock()
def ensure_scheduler() -> None:
global _scheduler_started
with _sched_lock:
if _scheduler_started:
return
if os.environ.get("CRYPTO_WYCKOFF_DISABLE", "").lower() in ("1", "true", "yes"):
return
interval = int(os.environ.get("CRYPTO_WYCKOFF_INTERVAL", "60"))
max_sym = os.environ.get("CRYPTO_WYCKOFF_MAX_SYMBOLS")
max_symbols = int(max_sym) if max_sym else None
start_scheduler(interval_sec=interval, max_symbols=max_symbols)
_scheduler_started = True
def _safe_int(raw, default: int, *, lo: int | None = None, hi: int | None = None) -> int:
try:
v = int(raw)
except (TypeError, ValueError):
v = default
if lo is not None:
v = max(lo, v)
if hi is not None:
v = min(hi, v)
return v
@bp.route("/wyckoff_crypto")
def page():
ensure_scheduler()
return render_template("wyckoff_crypto.html")
@bp.route("/api/wyckoff_crypto/meta")
def meta():
ensure_scheduler()
combo_id = request.args.get("combo_id")
combo = get_combo(combo_id)
latest = wyckoff_store.latest_trade_date(combo["id"])
return jsonify(
{
"architecture_version": ARCHITECTURE_VERSION,
"engine_version": WYCKOFF_ENGINE_VERSION,
"latest_trade_date": latest,
"scan_count": wyckoff_store.count_for_date(latest, combo["id"]),
"cycles": [c.value for c in WyckoffCycle],
"phases": [p.value for p in WyckoffPhase],
"events": [e.value for e in WyckoffEvent],
"decision_signals": [s.value for s in DecisionSignal],
"timezone": "Asia/Shanghai",
"utc_offset": "+08:00",
"timeframes": [combo["low"], combo["mid"], combo["high"]],
"combo": combo,
"combos": list_combos(),
"allowed_tfs": list(ALLOWED_TFS),
"symbol_names": symbol_name_map(),
"default_symbol": "BTC/USDT:USDT",
"status": get_status(),
}
)
@bp.route("/api/wyckoff_crypto/combos", methods=["GET"])
def combos_list():
ensure_scheduler()
return jsonify({"combos": list_combos(), "allowed_tfs": list(ALLOWED_TFS)})
@bp.route("/api/wyckoff_crypto/combos", methods=["POST"])
def combos_add():
ensure_scheduler()
body = request.get_json(silent=True) or {}
high = (body.get("high") or request.args.get("high") or "").strip()
mid = (body.get("mid") or request.args.get("mid") or "").strip()
low = (body.get("low") or request.args.get("low") or "").strip()
label = (body.get("label") or request.args.get("label") or "").strip() or None
try:
row = add_combo(high, mid, low, label=label)
except ValueError as e:
return jsonify({"error": str(e)}), 400
return jsonify({"ok": True, "combo": row, "combos": list_combos()})
@bp.route("/api/wyckoff_crypto/combos/<combo_id>", methods=["DELETE"])
def combos_delete(combo_id: str):
ensure_scheduler()
try:
removed = delete_combo(combo_id)
except ValueError as e:
return jsonify({"error": str(e)}), 400
if not removed:
return jsonify({"error": "not_found"}), 404
return jsonify({"ok": True, "combos": list_combos()})
@bp.route("/api/wyckoff_crypto/status")
def status():
ensure_scheduler()
return jsonify(get_status())
@bp.route("/api/wyckoff_crypto/scan")
def scan():
ensure_scheduler()
combo = get_combo(request.args.get("combo_id"))
rows = wyckoff_store.query_scan(
trade_date=request.args.get("trade_date"),
combo_id=combo["id"],
m_cycle=request.args.get("m_cycle"),
w_phase=request.args.get("w_phase"),
d_event=request.args.get("d_event"),
decision_signal=request.args.get("decision_signal"),
min_overall_score=_float_or_none(request.args.get("min_overall_score")),
min_alignment=_float_or_none(request.args.get("min_alignment")),
sort=request.args.get("sort") or "overall_score",
limit=_safe_int(request.args.get("limit"), 100, lo=1, hi=500),
offset=_safe_int(request.args.get("offset"), 0, lo=0),
)
for row in rows:
row["name"] = display_name_cn(row.get("ts_code") or "")
return jsonify({"rows": rows, "count": len(rows), "combo": combo})
@bp.route("/api/wyckoff_crypto/symbol/<path:symbol>")
def symbol_detail(symbol: str):
ensure_scheduler()
combo = get_combo(request.args.get("combo_id"))
row = wyckoff_store.get_symbol(symbol, request.args.get("trade_date"), combo["id"])
if not row:
return jsonify({"error": "not_found"}), 404
return jsonify(row)
@bp.route("/api/wyckoff_crypto/tick", methods=["POST"])
def manual_tick():
"""Manual one-shot tick (debug). Optional JSON/query max_symbols."""
ensure_scheduler()
body = request.get_json(silent=True) or {}
max_sym = request.args.get("max_symbols") or body.get("max_symbols")
max_symbols = int(max_sym) if max_sym not in (None, "") else None
def _job():
try:
run_tick(max_symbols=max_symbols, force_rescan=True)
except Exception:
pass
threading.Thread(target=_job, daemon=True).start()
return jsonify({"ok": True, "started": True})
@bp.route("/api/wyckoff_crypto/klines")
def klines():
"""Local cached OHLCV for chart (combo TFs)."""
ensure_scheduler()
from crypto_wyckoff.io import is_intraday_tf, load_bars_with_ts
symbol = request.args.get("symbol") or ""
combo = get_combo(request.args.get("combo_id"))
allowed = {combo["low"], combo["mid"], combo["high"]}
tf = request.args.get("tf") or combo["low"]
limit = _safe_int(request.args.get("limit"), 180, lo=1, hi=500)
if not symbol or tf not in allowed:
return jsonify({"error": "bad_request", "allowed": sorted(allowed)}), 400
items = load_bars_with_ts(symbol, tf, lookback=limit)
return jsonify({
"items": items,
"symbol": symbol,
"tf": tf,
"count": len(items),
"intraday": is_intraday_tf(tf),
"combo": combo,
})
@bp.route("/api/wyckoff_crypto/overlay")
def overlay():
"""Phase/event overlay for chart."""
ensure_scheduler()
from crypto_wyckoff.annotate import annotate_symbol
symbol = request.args.get("symbol") or ""
combo = get_combo(request.args.get("combo_id"))
allowed = {combo["low"], combo["mid"], combo["high"]}
tf = request.args.get("tf") or combo["low"]
bars = _safe_int(request.args.get("bars"), 180, lo=20, hi=400)
if not symbol or tf not in allowed:
return jsonify({"error": "bad_request", "allowed": sorted(allowed)}), 400
try:
data = annotate_symbol(symbol, freq=tf, lookback=bars, combo_id=combo["id"])
except Exception:
return jsonify({
"error": "overlay_failed",
"phases": [],
"events": [],
"levels": {},
"zones": [],
"combo_id": combo["id"],
}), 500
return jsonify(data)
def _float_or_none(v):
if v in (None, ""):
return None
try:
return float(v)
except (TypeError, ValueError):
return None
-7
View File
@@ -15,7 +15,6 @@ from api.analyze import bp as analyze_bp
from api.pages import bp as pages_bp
from api.symbols import bp as symbols_bp
from api.trend import bp as trend_bp
from api.wyckoff_crypto import bp as wyckoff_crypto_bp, ensure_scheduler
def create_app() -> Flask:
@@ -24,12 +23,6 @@ def create_app() -> Flask:
app.register_blueprint(analyze_bp)
app.register_blueprint(symbols_bp)
app.register_blueprint(trend_bp)
app.register_blueprint(wyckoff_crypto_bp)
# Start crypto wyckoff tip scheduler (daemon); disable with CRYPTO_WYCKOFF_DISABLE=1
try:
ensure_scheduler()
except Exception:
pass
return app
-25
View File
@@ -1,25 +0,0 @@
#!/bin/bash
# 创建日志目录(如果不存在)
mkdir -p logs
# 激活虚拟环境(如果使用)
# source venv/bin/activate
# 安装依赖
pip install -r requirements.txt
# 使用gunicorn启动应用
# 参数说明:
# -w 4: 使用4个工作进程
# -b 0.0.0.0:8123: 绑定到所有接口的8123端口
# --access-logfile: 访问日志文件路径
# --error-logfile: 错误日志文件路径
# --daemon: 以守护进程(后台)方式运行
# app:app: app.py中的app变量
gunicorn -w 4 -b 0.0.0.0:8123 --access-logfile logs/access.log --error-logfile logs/error.log --daemon app:app
echo "服务已启动在 http://服务器IP:8123"
echo "查看进程状态: ps aux | grep gunicorn"
echo "停止服务: pkill gunicorn"
-32
View File
@@ -1,32 +0,0 @@
#!/bin/bash
# 确保安装了虚拟环境工具
echo "检查并安装虚拟环境工具..."
sudo apt update
sudo apt install -y python3-venv python3-full
# 创建日志目录(如果不存在)
mkdir -p logs
# 创建虚拟环境(如果不存在)
if [ ! -d "venv" ]; then
echo "创建虚拟环境..."
python3 -m venv venv
fi
# 激活虚拟环境
echo "激活虚拟环境..."
source venv/bin/activate
# 安装依赖
echo "安装依赖..."
pip install --upgrade pip
pip install -r requirements.txt
# 使用gunicorn启动应用
echo "启动应用..."
venv/bin/gunicorn -w 4 -b 0.0.0.0:8123 --access-logfile logs/access.log --error-logfile logs/error.log --daemon app:app
echo "服务已启动在 http://服务器IP:8123"
echo "查看进程状态: ps aux | grep gunicorn"
echo "停止服务: ./stop_venv.sh"
+2 -2
View File
@@ -25,8 +25,8 @@ def analyze_chan(df, symbol=None, timeframe=None):
zs_list = chan.calculate_seg_zs(seg_list)
# 计算笔中枢(BI中枢)并拍平成列表
#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_list_pure(bi_list)
#bi_zs_list = chan.cal_bi_zs(seg_list)
bsp_list = []
if len(bi_zs_list) > 0:
bsp_list = chan.find_all_bsp(bi_list, bi_zs_list)
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
# 检查gunicorn进程
echo "Gunicorn进程状态:"
ps aux | grep gunicorn | grep -v grep
# 检查端口
echo -e "\n端口8123状态:"
netstat -tulpn 2>/dev/null | grep 8123 || echo "端口8123未被占用"
# 检查日志文件最后几行
echo -e "\n访问日志(最后5行):"
tail -n 5 logs/access.log 2>/dev/null || echo "访问日志尚未创建"
echo -e "\n错误日志(最后5行):"
tail -n 5 logs/error.log 2>/dev/null || echo "错误日志尚未创建"
-16
View File
@@ -1,16 +0,0 @@
#!/bin/bash
# 检查虚拟环境中的gunicorn进程
echo "Gunicorn进程状态:"
ps aux | grep "venv/bin/gunicorn" | grep -v grep
# 检查端口
echo -e "\n端口8123状态:"
netstat -tulpn 2>/dev/null | grep 8123 || echo "端口8123未被占用"
# 检查日志文件最后几行
echo -e "\n访问日志(最后5行):"
tail -n 5 logs/access.log 2>/dev/null || echo "访问日志尚未创建"
echo -e "\n错误日志(最后5行):"
tail -n 5 logs/error.log 2>/dev/null || echo "错误日志尚未创建"
-6
View File
@@ -1,6 +0,0 @@
#!/bin/bash
# 停止所有gunicorn进程
pkill gunicorn
echo "已停止所有gunicorn进程"
-7
View File
@@ -1,7 +0,0 @@
#!/bin/bash
# 找到并终止gunicorn进程
echo "停止gunicorn进程..."
pkill -f "venv/bin/gunicorn"
echo "已停止所有gunicorn进程"
File diff suppressed because it is too large Load Diff
+22
View File
@@ -0,0 +1,22 @@
{
"required": [
"bi_list",
"bi_zs_list",
"bsp_list",
"chan_macd",
"klc_fx_info",
"klc_list",
"klc_trend",
"kline_data",
"macd",
"seg_list",
"timezone",
"uncompleted_bi_list",
"uncompleted_seg_list",
"uncompleted_zs_list",
"zs_list"
],
"optional_when": {
"include_structure_zones": ["structure_zones"]
}
}
+28
View File
@@ -0,0 +1,28 @@
"""测试辅助函数。"""
from __future__ import annotations
import numpy as np
import pandas as pd
def make_ohlcv(n: int = 400, seed: int = 42) -> pd.DataFrame:
rng = np.random.default_rng(seed)
dates = pd.date_range("2024-01-01", periods=n, freq="5min", tz="UTC")
rets = rng.normal(0, 0.002, size=n)
close = 100 * np.exp(np.cumsum(rets))
open_ = np.roll(close, 1)
open_[0] = close[0]
spread = np.abs(rng.normal(0, 0.0015, size=n)) * close
high = np.maximum(open_, close) + spread
low = np.minimum(open_, close) - spread
volume = rng.uniform(100, 1000, size=n)
return pd.DataFrame(
{
"date": dates,
"open": open_,
"high": high,
"low": low,
"close": close,
"volume": volume,
}
)
+5 -103
View File
@@ -1,4 +1,4 @@
"""ECR-002加深 /api/analyze 相关契约 —— mock 行情 + analyze_chan 关键字段快照。"""
"""加深 /api/analyze 相关契约 —— mock 行情 + analyze_chan 关键字段快照。"""
from __future__ import annotations
import json
@@ -10,25 +10,21 @@ import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[2]
WEB_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "web"))
sys.path.insert(0, str(WEB_ROOT))
from tests.generate_golden import make_ohlcv # noqa: E402
from tests.helpers import make_ohlcv # noqa: E402
_CONTRACT_DOC = json.loads(
(ROOT / "tests" / "fixtures" / "analyze_contract_keys.json").read_text(encoding="utf-8")
(Path(__file__).resolve().parent / "fixtures" / "analyze_contract_keys.json").read_text(encoding="utf-8")
)
CONTRACT_KEYS = (
_CONTRACT_DOC["required"]
if isinstance(_CONTRACT_DOC, dict) and "required" in _CONTRACT_DOC
else _CONTRACT_DOC
)
WYCKOFF_KEYS = (
_CONTRACT_DOC.get("wyckoff_keys", [])
if isinstance(_CONTRACT_DOC, dict)
else []
)
# analyze_chan 直接返回的对象字段(未序列化前)
ANALYZE_CHAN_KEYS = {
@@ -74,7 +70,6 @@ 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(
@@ -88,7 +83,6 @@ def test_klines_recent_returns_tail_only():
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():
@@ -119,7 +113,6 @@ def test_serialize_chan_macd_shape():
result = analyze_chan(df)
serialized = serialize_chan_macd_data(result["chan_macd"], timezone("Asia/Shanghai"))
assert set(serialized.keys()) == CHAN_MACD_SERIALIZED_KEYS
# JSON 可序列化
json.dumps(serialized)
@@ -133,7 +126,6 @@ def test_analyze_http_contract_with_mocked_kl():
if "timestamp" not in df.columns:
df["timestamp"] = (pd.to_datetime(df["date"]).astype("int64") // 10**6).astype("int64")
# analyze 路由使用 `from services.runtime import *`,须 patch 其模块命名空间
with patch("api.analyze.get_kl_data", return_value=df):
client = app.test_client()
resp = client.get(
@@ -149,93 +141,3 @@ 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" in payload
for k in WYCKOFF_KEYS:
assert k in payload["wyckoff"], f"missing wyckoff key: {k}"
def test_analyze_http_wyckoff_can_opt_out():
"""include_wyckoff=0 时可显式跳过威科夫。"""
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": "5m",
"timezone": "Asia/Shanghai",
"include_wyckoff": 0,
},
)
assert resp.status_code == 200, resp.data[:500]
payload = resp.get_json()
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 时不返回 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": "5m",
"element_timeframe": "1m",
"timezone": "Asia/Shanghai",
"elements_only": "true",
},
)
assert resp.status_code == 200, resp.data[:500]
payload = resp.get_json()
assert payload is not None
assert "wyckoff" not in payload
+4 -5
View File
@@ -5,12 +5,11 @@ from pathlib import Path
import pytest
# 将项目根目录加入 sys.path,确保可以直接导入业务代码
ROOT_DIR = Path(__file__).resolve().parents[4]
if str(ROOT_DIR) not in sys.path:
sys.path.append(str(ROOT_DIR))
WEB_ROOT = Path(__file__).resolve().parents[1]
if str(WEB_ROOT) not in sys.path:
sys.path.insert(0, str(WEB_ROOT))
from user_data.Chan.web.cn_stock_data import ChinaStockData
from cn_stock_data import ChinaStockData
@pytest.mark.network
-123
View File
@@ -1,123 +0,0 @@
"""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