refactor: 精简仓库为 chanlun 核心与 web 分析,移除威科夫与遗留模块
删除根目录旧 Chan 模块、策略、配置、文档及 wyckoff 相关代码;更新缠论 pipeline 与笔中枢计算;补充 research 研究与 web 测试。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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] 的 start(WYCKOFF-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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "错误日志尚未创建"
|
||||
@@ -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 "错误日志尚未创建"
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 停止所有gunicorn进程
|
||||
pkill gunicorn
|
||||
|
||||
echo "已停止所有gunicorn进程"
|
||||
@@ -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
@@ -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"]
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user