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:
+201
-25
@@ -2,9 +2,103 @@
|
||||
from flask import Blueprint, jsonify, request
|
||||
from services.runtime import * # noqa: F403
|
||||
from services import runtime as R
|
||||
# import * 不会带出下划线私有名;结构区缓存需显式导入
|
||||
from services.runtime.state import _zone_cache
|
||||
from services.runtime.timeframes import _zone_cache_ttl
|
||||
|
||||
bp = Blueprint("analyze", __name__)
|
||||
|
||||
_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():
|
||||
"""分析接口"""
|
||||
@@ -25,6 +119,9 @@ 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')
|
||||
@@ -249,6 +346,7 @@ 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)
|
||||
@@ -427,6 +525,7 @@ 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')
|
||||
@@ -656,33 +755,110 @@ def analyze():
|
||||
else:
|
||||
result['structure_zones'] = []
|
||||
|
||||
# 威科夫分析 —— 按需:include_wyckoff=1,且须有主周期分析(非 elements_only)
|
||||
include_wyckoff_param = request.args.get('include_wyckoff', '')
|
||||
include_wyckoff = str(include_wyckoff_param).lower() in ('1', 'true', 'yes')
|
||||
# 威科夫:主 / 次 / 次次各算一份(非 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:
|
||||
try:
|
||||
from chanlun.analysis.wyckoff import analyze_wyckoff
|
||||
wyckoff_lookback = int(request.args.get('wyckoff_lookback', 120))
|
||||
# ECR-004:默认/上限 24 bins(A+C)
|
||||
wyckoff_bins = int(request.args.get('wyckoff_vp_bins', 24))
|
||||
result['wyckoff'] = analyze_wyckoff(
|
||||
df,
|
||||
lookback=max(40, min(wyckoff_lookback, 500)),
|
||||
vp_bins=max(10, min(wyckoff_bins, 24)),
|
||||
# 主周期先算;次/次次只同步 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,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"Wyckoff 分析出错: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
result['wyckoff'] = {
|
||||
'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': {}},
|
||||
'error': str(e),
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
"""页面路由。"""
|
||||
from flask import Blueprint, render_template, send_from_directory
|
||||
from flask import Blueprint, jsonify, render_template, request, send_from_directory
|
||||
from config import DATA_SERVICE_URL, DATA_SERVICE_WS_URL
|
||||
from services.runtime import * # noqa: F403
|
||||
from services import runtime as R
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user