feat(web): 增量自动刷新、结构区修复与默认指标/周期

自动刷新常态只拉 recent 尾部 K,每 1 分钟全量重算缠论;修复结构区缓存导入;默认指标/4h·1h·15m/近30天;同步 ECR-009 screener 相关改动。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-08 15:45:40 +08:00
co-authored by Cursor
parent 0f6eb92a1f
commit 18a7f485e6
23 changed files with 2133 additions and 310 deletions
+77
View File
@@ -2,6 +2,9 @@
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__)
@@ -785,3 +788,77 @@ def analyze():
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)
+128 -10
View File
@@ -7,9 +7,17 @@ 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__)
@@ -32,6 +40,18 @@ def ensure_scheduler() -> None:
_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()
@@ -41,24 +61,65 @@ def page():
@bp.route("/api/wyckoff_crypto/meta")
def meta():
ensure_scheduler()
latest = wyckoff_store.latest_trade_date()
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),
"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": "UTC",
"timeframes": ["1d", "1w", "1M"],
"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()
@@ -68,8 +129,10 @@ def 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"),
@@ -77,16 +140,19 @@ def scan():
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=min(int(request.args.get("limit") or 100), 500),
offset=int(request.args.get("offset") or 0),
limit=_safe_int(request.args.get("limit"), 100, lo=1, hi=500),
offset=_safe_int(request.args.get("offset"), 0, lo=0),
)
return jsonify({"rows": rows, "count": len(rows)})
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()
row = wyckoff_store.get_symbol(symbol, request.args.get("trade_date"))
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)
@@ -96,8 +162,9 @@ def symbol_detail(symbol: str):
def manual_tick():
"""Manual one-shot tick (debug). Optional JSON/query max_symbols."""
ensure_scheduler()
max_sym = request.args.get("max_symbols") or (request.json or {}).get("max_symbols")
max_symbols = int(max_sym) if max_sym else None
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:
@@ -109,6 +176,57 @@ def manual_tick():
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
+4 -4
View File
@@ -94,19 +94,19 @@ def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys):
def compute_timeframe_defaults(labels_ordered):
"""
根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。
默认偏好:主 4h、次 2h、次次 1h(威科夫与结构在小时级更可读)
默认偏好:主 4h、次 1h、次次 15m
labels_ordered: OrderedDict 或按插入顺序排列的 dict。
"""
if not labels_ordered:
labels_ordered = DEFAULT_TIMEFRAME_LABELS.copy()
timeframe_keys = list(labels_ordered.keys())
preferred_main = next((tf for tf in ['4h', '2h', '1h'] if tf in labels_ordered), None)
preferred_main = next((tf for tf in ['4h', '1h', '15m'] if tf in labels_ordered), None)
default_main = preferred_main or (timeframe_keys[0] if timeframe_keys else '1m')
if default_main not in labels_ordered and timeframe_keys:
default_main = timeframe_keys[0]
default_element = _prefer_smaller(['2h', '1h'], labels_ordered, default_main, timeframe_keys)
default_sub_sub = _prefer_smaller(['1h'], labels_ordered, default_element, timeframe_keys)
default_element = _prefer_smaller(['1h', '15m'], labels_ordered, default_main, timeframe_keys)
default_sub_sub = _prefer_smaller(['15m', '5m'], labels_ordered, default_element, timeframe_keys)
return default_main, default_element, default_sub_sub, timeframe_keys
+68 -19
View File
@@ -9,11 +9,26 @@ function updateTradingViewData() {
return;
}
// 保存当前的可视范围
// 优先用请求前冻结的视窗;否则现场拍(自动刷新短间隔 delta≈0,两种都稳)
const frozen = window._preserveViewOnRefresh;
const oldBarCount = window._preserveViewBarCount || 0;
let savedScrollPosition = null;
if (tvWidget.mainChart) {
tvWidget.state.visibleRange = tvWidget.mainChart.timeScale().getVisibleRange();
tvWidget.state.logicalRange = tvWidget.mainChart.timeScale().getVisibleLogicalRange();
const ts = tvWidget.mainChart.timeScale();
if (frozen) {
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线
const showOriginalKline = $('#showOriginalKline').is(':checked');
@@ -71,6 +86,9 @@ function updateTradingViewData() {
};
});
}
const newBarCount = candles.length;
const barDelta = (oldBarCount > 0 && newBarCount > 0) ? (newBarCount - oldBarCount) : 0;
// 更新主系列数据(根据klineType)
const klineType = ($('#klineType').val() || (showOriginalKline ? 'candlestick' : 'line'));
@@ -270,23 +288,54 @@ function updateTradingViewData() {
// 更新EMA52显示
updateEMA52Display(currentData);
// 恢复之前的可视范围 - 优先使用visibleRange以确保时间轴对齐
// 与自动刷新一致:增量更新绝不碰 barSpacing(缩放本来就留在图表实例上)。
// 一写 barSpacing,LWC 会按右边缘重锚 → 放大往右、缩小往左。
// 这里只在 setData 之后把位置扳回刷新前的 logical / time 窗口。
if (tvWidget.mainChart) {
if (tvWidget.state.visibleRange) {
console.log('🔄 恢复可见范围:', tvWidget.state.visibleRange);
tvWidget.mainChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleRange(tvWidget.state.visibleRange);
} else if (tvWidget.state.logicalRange) {
console.log('🔄 恢复逻辑范围:', tvWidget.state.logicalRange);
tvWidget.mainChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.volumeChart) tvWidget.volumeChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.atrChart) tvWidget.atrChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.macdChart) tvWidget.macdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
if (tvWidget.chanMacdChart) tvWidget.chanMacdChart.timeScale().setVisibleLogicalRange(tvWidget.state.logicalRange);
}
const charts = [
tvWidget.mainChart,
tvWidget.volumeChart,
tvWidget.atrChart,
tvWidget.macdChart,
tvWidget.chanMacdChart
].filter(Boolean);
const vr = tvWidget.state.visibleRange;
const lr = tvWidget.state.logicalRange;
const savedScroll = savedScrollPosition;
const applyPosition = function (tag) {
let ok = false;
if (lr && lr.from !== undefined && lr.to !== undefined) {
charts.forEach(c => {
try {
c.timeScale().setVisibleLogicalRange({ from: lr.from, to: lr.to });
ok = true;
} catch (e) {}
});
if (ok) console.log('🔄 恢复位置 logical' + (tag || '') + ':', lr);
}
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);
}
console.log('增量更新图表完成');
+25 -14
View File
@@ -24,15 +24,13 @@ function chartTvFinalize(ctx) {
var chanMacdChart = ctx.chanMacdChart;
var createChartOptions = ctx.createChartOptions;
// 同步所有图表的时间轴配置
const hasPendingRestoreView = !!window._pendingRestoreView;
const pendingView = window._pendingRestoreView;
const syncTimeScaleSettings = () => {
// 获取主图表的时间轴设置
const mainTimeScale = mainChart.timeScale();
const baseOptions = {
timeVisible: true,
secondsVisible: false,
borderColor: '#ddd',
barSpacing: symbolConfig.type === 'a_stock' ? 6 : 10,
rightOffset: 12,
lockVisibleTimeRangeOnResize: true,
// 关键:确保所有图表边缘行为完全一致
fixLeftEdge: false,
@@ -41,6 +39,12 @@ function chartTvFinalize(ctx) {
ticksVisible: true,
minimumHeight: 0,
};
// 有待恢复视图时不要先写 barSpacing/rightOffset(会钉右缘导致图往右偏),
// 交给后面 setVisibleRange 一次锁定位置+缩放。
if (!pendingView) {
baseOptions.barSpacing = symbolConfig.type === 'a_stock' ? 6 : 10;
baseOptions.rightOffset = 12;
}
console.log('🔧 同步时间轴设置:', baseOptions);
@@ -59,8 +63,12 @@ function chartTvFinalize(ctx) {
// 仅在没有待恢复视图时,设置默认可见范围
const totalBars = candles ? candles.length : 0;
const visibleBarsCount = 200;
const hasPendingRestoreView = !!window._pendingRestoreView;
if (!hasPendingRestoreView) {
const allChartsNow = [mainChart, volumeChart, atrChart]
.concat(showMacd && macdChart ? [macdChart] : [])
.concat(showMacd && chanMacdChart ? [chanMacdChart] : []);
if (hasPendingRestoreView && pendingView) {
restoreChartViewState(allChartsNow, pendingView, { preferTime: true });
} else {
// 显示最近 200 根K线而非全部挤压(避免K线过多时重叠)
if (totalBars > visibleBarsCount) {
const rangeFrom = totalBars - visibleBarsCount;
@@ -71,8 +79,12 @@ function chartTvFinalize(ctx) {
}
}
// 立即同步其他图表到主图表的范围
// 立即同步其他图表到主图表的范围(无 pending 时)
setTimeout(() => {
if (window._pendingRestoreView) {
restoreChartViewState(allChartsNow, window._pendingRestoreView, { preferTime: true });
return;
}
const logRange = mainChart.timeScale().getVisibleLogicalRange();
if (logRange) {
console.log('🔧 同步可见范围:', logRange);
@@ -120,11 +132,10 @@ function chartTvFinalize(ctx) {
}
const defaultMAs = [
{ type: 'EMA', length: 13, color: '#800080', name: 'EMA13', visible: true }, //
{ type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: true }, // 橙色
{ type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: false }, // 黑色
{ type: 'EMA', length: 104, color: '#1E90FF', name: 'EMA104', visible: false }, // 蓝色
{ type: 'EMA', length: 156, color: '#F700FF', name: 'EMA156', visible: false } // 粉色
{ type: 'EMA', length: 26, color: '#FF8C00', name: 'EMA26', visible: false }, //
{ type: 'EMA', length: 52, color: '#000000', name: 'EMA52', visible: true }, // 黑色 · 默认开
{ type: 'SMA', length: 30, color: '#1E90FF', name: 'MA30', visible: true }, // 蓝色 · 默认开
{ type: 'SMA', length: 250, color: '#800080', name: 'MA250', visible: true } // 紫色 · 默认开
];
defaultMAs.forEach(ma => {
@@ -191,9 +202,9 @@ function chartTvFinalize(ctx) {
window._pendingRestoreView = null;
if (pending) {
// 恢复刷新前的缩放和位置(优先可见范围/逻辑范围,最后回退到滚动位置
// 恢复刷新前的缩放和位置(时间范围优先,避免数据滑动后逻辑索引错位
console.log('📌 恢复图表视图:', JSON.stringify(pending));
restoreChartViewState(allCharts, pending);
restoreChartViewState(allCharts, pending, { preferTime: true });
} else {
// 无保存视图,正常同步主图到子图
const visibleRange = mainChart.timeScale().getVisibleRange();
+128 -15
View File
@@ -1,4 +1,46 @@
/* 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) {
options = options || {};
// 只显示旋转加载图标
@@ -47,9 +89,79 @@ function updateChart(options) {
if (options.fromAutoRefresh && window._analyzeXhr && window._analyzeXhr.readyState !== 4) {
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);
// 自动刷新常态:只拉最近 2 根;fullAnalyze(约每 1 分钟)走全量 analyze 更新缠论
const useRecentTail = !!(options.fromAutoRefresh && !options.fullAnalyze && chartsReady && hasBaseline);
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;
}
// 发送请求
const requestId = ++lastRequestId; // 标记本次请求
// 手动 / 首拉:全量 analyze
window._analyzeXhr = $.ajax({
url: '/api/analyze',
data: {
@@ -81,15 +193,21 @@ function updateChart(options) {
delete currentData.original_macd;
}
currentData = data;
window._lastFullAnalyzeAt = Date.now();
if (typeof renderWyckoffCycleSummary === 'function') {
renderWyckoffCycleSummary();
}
refreshChart(data, {
incremental: options.incremental !== undefined
? !!options.incremental
: !!options.fromAutoRefresh
});
// 有图则增量;笔/段/中枢/结构区只在全量 init 绘制,fullAnalyze 必须重建
const ready = !!(tvWidget && tvWidget.state && tvWidget.state.isInitialized && tvWidget.mainChart);
const structureZonesOn = $('#showMainStructureZone').is(':checked');
let wantIncremental = options.incremental !== undefined
? !!options.incremental
: (ready || !!options.fromAutoRefresh);
if (structureZonesOn || options.fullAnalyze) {
wantIncremental = false;
}
refreshChart(data, { incremental: wantIncremental });
},
error: function(jqXHR, textStatus, errorThrown) {
// 隐藏加载图标
@@ -121,24 +239,21 @@ function captureChartViewState(chart) {
}
function restoreChartViewState(charts, viewState) {
// 全量重建备用:先缩放,再位置;不要在位置前写 rightOffset(会右边缘锚定)
if (!viewState || !Array.isArray(charts) || charts.length === 0) return;
const validCharts = charts.filter(c => c && c.timeScale);
if (validCharts.length === 0) return;
validCharts.forEach(c => {
try {
const optionsPatch = {};
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);
if (typeof viewState.barSpacing === 'number') {
c.timeScale().applyOptions({ barSpacing: viewState.barSpacing });
}
} catch (e) {}
});
let restored = false;
// 优先按逻辑范围恢复(对新数据更稳健)
if (viewState.logicalRange && viewState.logicalRange.from !== undefined && viewState.logicalRange.to !== undefined) {
validCharts.forEach(c => {
try {
@@ -148,7 +263,6 @@ function restoreChartViewState(charts, viewState) {
});
}
// 逻辑范围失败时,回退到时间可见范围
if (!restored && viewState.visibleRange && viewState.visibleRange.from !== undefined && viewState.visibleRange.to !== undefined) {
validCharts.forEach(c => {
try {
@@ -158,7 +272,6 @@ function restoreChartViewState(charts, viewState) {
});
}
// 最后回退到滚动位置
if (!restored && typeof viewState.scrollPosition === 'number') {
validCharts.forEach(c => {
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() {
const on = $('#showMainStructureZone').is(':checked');
console.log('结构区切换为:', on);
// 勾选后才向服务器请求多周期结构区数据;取消勾选仅重绘,不重复拉取
// 勾选后才向服务器请求多周期结构区数据;结构区叠层只在全量 init 里绘制,必须 incremental:false
if (on) {
updateChart();
updateChart({ incremental: false });
} else {
updateChartDisplay();
}
+43 -14
View File
@@ -272,10 +272,10 @@ function loadSymbols() {
});
}
// 设置默认时间范围(需覆盖威科夫 lookback;1 天在 4h/1h 上几乎检不出区间)
// 设置默认时间范围:最近 1 个月
function setDefaultTimeRange() {
const now = new Date();
const daysBack = 14;
const daysBack = 30;
const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000));
// 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm
@@ -493,6 +493,9 @@ $(document).ready(function() {
let autoRefreshTimer = null;
let nextRefreshTime = null;
let autoRefreshTick = 0;
/** 自动刷新时,缠论全量重算间隔(毫秒);时间戳见 window._lastFullAnalyzeAt */
const AUTO_FULL_ANALYZE_MS = 60 * 1000;
// 初始化自动刷新功能
function initAutoRefresh() {
// 监听自动刷新勾选框变化
@@ -519,10 +522,10 @@ function startAutoRefresh() {
stopAutoRefresh();
// 获取刷新频率(分钟)
const interval = parseFloat($('#refreshInterval').val()) || 5;
const interval = parseFloat($('#refreshInterval').val()) || (5 / 60);
const intervalMs = interval * 60 * 1000;
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒)`);
console.log(`开始自动刷新,频率: ${interval}分钟 (${intervalMs}毫秒);缠论全量每 ${AUTO_FULL_ANALYZE_MS / 1000}s`);
// 计算下次刷新时间
nextRefreshTime = new Date(Date.now() + intervalMs);
@@ -531,16 +534,36 @@ function startAutoRefresh() {
// 启动定时器
autoRefreshTick = 0;
autoRefreshTimer = setInterval(function() {
// 更新结束时间为当前时间
// 刷新前先钉住当前缩放/位置(updateEndTime / 请求返回前都可能被改写)
if (tvWidget && tvWidget.mainChart && typeof captureChartViewState === 'function') {
try {
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
} catch (e) {
window._pendingRestoreView = null;
}
}
// 更新结束时间显示(仅 UI
updateEndTimeToNow();
// 多数周期增量更新;每隔若干次全量重建以刷新笔/段/中枢(dispose 已防泄漏)
autoRefreshTick += 1;
const fullRebuild = (autoRefreshTick % 6) === 0;
updateChart({
fromAutoRefresh: true,
incremental: !fullRebuild
});
const now = Date.now();
const lastFull = window._lastFullAnalyzeAt || 0;
const needFullAnalyze = !lastFull || (now - lastFull >= AUTO_FULL_ANALYZE_MS);
// 常态:/api/klines/recent 合并尾部 K;满 1 分钟:全量 /api/analyze 刷新缠论
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);
@@ -784,7 +807,8 @@ function refreshChart(data, options) {
// 自动刷新:增量更新,避免每次销毁/重建 Lightweight Charts
if (preferIncremental && chartsReady) {
try {
if (tvWidget.mainChart) {
// 若定时器已捕获则保留;否则此刻再捕获一次
if (!window._pendingRestoreView && tvWidget.mainChart) {
try {
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
} catch (e) {
@@ -792,7 +816,10 @@ function refreshChart(data, options) {
}
}
updateTradingViewData();
updateTables(data);
// recent-tail 刷新结构未变,跳过表格重绘以提速
if (!options.skipTables) {
updateTables(data);
}
if (currentData && currentData.ema52_dict) {
updateEMA52Display(currentData);
}
@@ -804,7 +831,7 @@ function refreshChart(data, options) {
// 保存当前缩放(barSpacing)和滚动位置(scrollPosition)到 window
// tvWidget 会在 initTradingView 内被重建,所以必须存到 window 上
if (tvWidget && tvWidget.mainChart) {
if (!window._pendingRestoreView && tvWidget && tvWidget.mainChart) {
try {
window._pendingRestoreView = captureChartViewState(tvWidget.mainChart);
console.log('📌 保存图表视图:', JSON.stringify(window._pendingRestoreView));
@@ -812,6 +839,8 @@ function refreshChart(data, options) {
console.warn('保存图表视图失败:', e);
window._pendingRestoreView = null;
}
} else if (window._pendingRestoreView) {
console.log('📌 使用已保存图表视图:', JSON.stringify(window._pendingRestoreView));
}
initTradingView($('#symbol').val(), $('#timeframe').val());
+21 -21
View File
@@ -982,7 +982,7 @@
<input type="datetime-local" id="end_time" class="form-control">
</div>
<div class="col-md-1">
<button class="btn btn-primary w-100" onclick="updateChart()" style="padding: 8px 6px; font-size: 14px;">
<button class="btn btn-primary w-100" onclick="updateEndTimeToNow(); updateChart({ incremental: true })" style="padding: 8px 6px; font-size: 14px;">
分析
</button>
</div>
@@ -1028,14 +1028,14 @@
<div class="d-flex align-items-center mb-2">
<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;">
<option value="0.0833">5秒</option>
<option value="0.0833" selected>5秒</option>
<option value="0.1667">10秒</option>
<option value="0.25">15秒</option>
<option value="0.5">30秒</option>
<option value="1">1分钟</option>
<option value="2">2分钟</option>
<option value="3">3分钟</option>
<option value="5" selected>5分钟</option>
<option value="5">5分钟</option>
<option value="10">10分钟</option>
</select>
<div class="form-check form-check-inline me-2">
@@ -1383,24 +1383,24 @@
</div>
<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') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/state.js') }}"></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=20260807f"></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=20260807f"></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=20260807f"></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=20260807f"></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=20260807f"></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=20260807f"></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=20260807f"></script>
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/app/main.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/state.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/trend.js') }}?v=20260808i"></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/chart_format.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260809c"></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_shell.js') }}?v=20260808i"></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_chan.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260808i"></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.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260808i"></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/ui.js') }}?v=20260809d"></script>
<script defer src="{{ url_for('static', filename='js/app/overlays.js') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/main.js') }}?v=20260808i"></script>
<!-- 均线配置弹窗 -->
<div id="maConfigModal" class="ma-config-modal">
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"):
+77 -2
View File
@@ -31,6 +31,8 @@ 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):
@@ -38,11 +40,84 @@ def test_wyckoff_crypto_meta_ok(client):
assert resp.status_code == 200
data = resp.get_json()
assert "engine_version" in data
assert data.get("timeframes") == ["1d", "1w", "1M"]
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")
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