diff --git a/research/PROMOTE_coinglass_relay.md b/research/PROMOTE_coinglass_relay.md new file mode 100644 index 0000000..cec297a --- /dev/null +++ b/research/PROMOTE_coinglass_relay.md @@ -0,0 +1,7 @@ +# 已 superseded + +v0 改走免费交易所,由 **data_provider** 拉,chan 只从中转取。 + +见 [PROMOTE_deriv_relay.md](./PROMOTE_deriv_relay.md)。 + +CoinGlass 付费档等看完交易所效果再开,不要和本阶段混做。 diff --git a/research/PROMOTE_deriv_relay.md b/research/PROMOTE_deriv_relay.md new file mode 100644 index 0000000..3adc738 --- /dev/null +++ b/research/PROMOTE_deriv_relay.md @@ -0,0 +1,94 @@ +# 需求:资金面数据(data_provider → chan) + +**提出方:** chan +**执行方:** data_provider +**阶段:** Paper。先看效果。不进 Live。 + +--- + +## 要什么 + +chan 要在缠论图上叠资金面,和现有 K 线对得上。 + +data_provider 对外提供资金面;chan **只从 data_provider 取**,不访问交易所,不访问 CoinGlass。 + +K 线路径不动(现有 `/api/candles` 与 K 线推送)。本次只加资金面。 + +--- + +## 数据 + +先三个币:**BTC、ETH、SOL**(USDT 永续,符号与现有 K 线相同,如 `BTC/USDT:USDT`)。 + +要两样: + +1. **持仓量(OI)** + - 要历史,能覆盖缠论常用周期:`15m`、`30m`、`4h`、`1d`(有 `1h`/`2h` 更好)。 + - 要当前最新值。 + - 历史长度至少约 30 天。 + +2. **资金费率(funding)** + - 要当前值。 + - 要历史结算序列。 + - 对齐到各周期 K 线:结算点落到所在那根;非结算 bar 沿用上一次结算值,不要插值编造。 + +来源:交易所公开数据即可,本阶段不买 CoinGlass。OI 历史哪家所没有,用另一家所公开数据补,需标明来源。 + +**本阶段不要:** 清算、热力图、多空比、订单簿、CoinGlass。 + +--- + +## 给 chan 的接口 + +与 `/api/candles` 同一套约定: + +- `symbol` 与蜡烛相同 +- 时间戳毫秒 UTC +- 按周期 `tf` 取序列 +- 支持 `start` / `end` / `limit`(默认 `limit=500`) + +示例: + +```http +GET /api/deriv?symbol=BTC/USDT:USDT&tf=15m&metrics=oi,funding +``` + +每根: + +| 字段 | 要求 | +|---|---| +| `timestamp` | 与同 `tf` 的 `/api/candles` **开盘时间**对齐;对不齐的不要 | +| `oi` | 该 bar 持仓量;缺则 `null` | +| `oi_src` | 该值来自哪家所 | +| `funding` | 该 bar 资金费率;缺则 `null` | +| `funding_src` | 该值来自哪家所 | + +健康状态要能看出:资金面是否可用、各所是否通、上次成功时间。 + +--- + +## 约束 + +- 全程 HTTPS REST。本阶段不要求资金面 WebSocket。 +- chan、浏览器不得直连交易所。 +- 现有 K 线接口行为不变。 +- 一家所挂了:缺那家字段,另一家仍要能出;两边都没有且无可用数据时明确失败。 +- 上游限流或超时:不要拖垮 K 线。 + +--- + +## 不算本次 + +- CoinGlass / 付费数据 +- 清算、热力、多空 +- Live、下单 +- chan 叠图(等本接口可用再做) + +--- + +## 怎样算齐 + +1. `GET /api/deriv?symbol=BTC/USDT:USDT&tf=15m` 能拿到 `oi`、`funding`,时间能对上同参数的 `/api/candles`。 +2. chan / 浏览器零次访问交易所。 +3. 只挂一家所时,接口仍可用,只缺对应字段。 +4. K 线不受影响。 diff --git a/web/api/provider.py b/web/api/provider.py new file mode 100644 index 0000000..b815e55 --- /dev/null +++ b/web/api/provider.py @@ -0,0 +1,73 @@ +"""把 data_provider 的资金面转给 Web,浏览器不直连交易所。""" +from __future__ import annotations + +import logging + +import requests +from flask import Blueprint, jsonify, request + +from services.runtime.market_data import ( + fetch_derivatives, + fetch_sentiment_latest, + fetch_sentiment_metrics, +) + +logger = logging.getLogger(__name__) + +bp = Blueprint("provider", __name__) + + +def _http_error(exc: requests.HTTPError): + status = 502 + detail = str(exc) + if exc.response is not None: + status = exc.response.status_code or 502 + try: + body = exc.response.json() + detail = body.get("detail") or body.get("error") or detail + except Exception: + detail = exc.response.text or detail + return jsonify({"error": detail}), status + + +@bp.route("/api/derivatives") +def api_derivatives(): + symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip() + exchange = (request.args.get("exchange") or "").strip() or None + try: + return jsonify(fetch_derivatives(symbol, exchange)) + except requests.HTTPError as exc: + return _http_error(exc) + except Exception as exc: + logger.warning("derivatives 中转失败: %s", exc) + return jsonify({"error": str(exc)}), 503 + + +@bp.route("/api/sentiment/latest") +def api_sentiment_latest(): + symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip() + try: + return jsonify(fetch_sentiment_latest(symbol)) + except requests.HTTPError as exc: + return _http_error(exc) + except Exception as exc: + logger.warning("sentiment latest 中转失败: %s", exc) + return jsonify({"error": str(exc)}), 503 + + +@bp.route("/api/sentiment/metrics") +def api_sentiment_metrics(): + metric = (request.args.get("metric") or "").strip() + if not metric: + return jsonify({"error": "metric required"}), 400 + symbol = (request.args.get("symbol") or "BTC/USDT:USDT").strip() + start = request.args.get("start", type=int) + end = request.args.get("end", type=int) + limit = request.args.get("limit", type=int) + try: + return jsonify(fetch_sentiment_metrics(metric, symbol, start=start, end=end, limit=limit)) + except requests.HTTPError as exc: + return _http_error(exc) + except Exception as exc: + logger.warning("sentiment 中转失败: %s", exc) + return jsonify({"error": str(exc)}), 503 diff --git a/web/app.py b/web/app.py index 8a46e31..9cab576 100644 --- a/web/app.py +++ b/web/app.py @@ -13,6 +13,7 @@ from flask import Flask from config import FLASK_HOST, FLASK_PORT from api.analyze import bp as analyze_bp from api.pages import bp as pages_bp +from api.provider import bp as provider_bp from api.symbols import bp as symbols_bp from api.trend import bp as trend_bp @@ -23,6 +24,7 @@ def create_app() -> Flask: app.register_blueprint(analyze_bp) app.register_blueprint(symbols_bp) app.register_blueprint(trend_bp) + app.register_blueprint(provider_bp) return app diff --git a/web/services/runtime/__init__.py b/web/services/runtime/__init__.py index bcb28bb..bc32b2f 100644 --- a/web/services/runtime/__init__.py +++ b/web/services/runtime/__init__.py @@ -54,6 +54,9 @@ from .market_data import ( # noqa: F401 get_crypto_kl_data, get_a_stock_kl_data, load_crypto_symbols, + fetch_derivatives, + fetch_sentiment_metrics, + fetch_sentiment_latest, ) from .indicators import ( # noqa: F401 add_indicators, diff --git a/web/services/runtime/market_data.py b/web/services/runtime/market_data.py index 53e5fc9..9885b5a 100644 --- a/web/services/runtime/market_data.py +++ b/web/services/runtime/market_data.py @@ -319,3 +319,34 @@ def load_crypto_symbols(limit=200): except Exception: return DEFAULT_SYMBOLS[:limit] + +def _provider_get(path, params, timeout=8): + resp = requests.get(f"{DATA_SERVICE_URL}{path}", params=params, timeout=timeout) + resp.raise_for_status() + return resp.json() + + +def fetch_derivatives(symbol, exchange=None): + """当前资金面快照。只打 data_provider,不打交易所。""" + params = {"symbol": symbol} + if exchange: + params["exchange"] = exchange + return _provider_get("/api/derivatives", params) + + +def fetch_sentiment_metrics(metric, symbol, start=None, end=None, limit=None): + """情绪/资金面序列。只打 data_provider。""" + params = {"metric": metric, "symbol": symbol} + if start is not None: + params["start"] = int(start) + if end is not None: + params["end"] = int(end) + if limit is not None: + params["limit"] = int(limit) + return _provider_get("/api/sentiment/metrics", params) + + +def fetch_sentiment_latest(symbol): + """情绪面最新快照。只打 data_provider。""" + return _provider_get("/api/sentiment/latest", {"symbol": symbol}) + diff --git a/web/services/runtime/state.py b/web/services/runtime/state.py index 2142ce3..7767d25 100644 --- a/web/services/runtime/state.py +++ b/web/services/runtime/state.py @@ -46,6 +46,7 @@ DEFAULT_TIMEFRAME_LABELS = OrderedDict([ ("5m", "5分钟"), ("15m", "15分钟"), ("30m", "30分钟"), + ("45m", "45分钟"), ("1h", "1小时"), ("2h", "2小时"), ("4h", "4小时"), diff --git a/web/services/runtime/timeframes.py b/web/services/runtime/timeframes.py index deb6a1d..f851afd 100644 --- a/web/services/runtime/timeframes.py +++ b/web/services/runtime/timeframes.py @@ -94,19 +94,19 @@ def _prefer_smaller(candidates, labels_ordered, ceiling_tf, timeframe_keys): def compute_timeframe_defaults(labels_ordered): """ 根据已排序的「周期 → 中文标签」映射,计算主 / 次 / 次次周期默认值。 - 默认偏好:主 4h、次 1h、次次 15m。 + 默认偏好:主 45m、次 15m、次次 5m。 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', '1h', '15m'] if tf in labels_ordered), None) + preferred_main = next((tf for tf in ['45m', '30m', '1h'] 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(['1h', '15m'], labels_ordered, default_main, timeframe_keys) - default_sub_sub = _prefer_smaller(['15m', '5m'], labels_ordered, default_element, timeframe_keys) + default_element = _prefer_smaller(['15m', '5m', '30m'], labels_ordered, default_main, timeframe_keys) + default_sub_sub = _prefer_smaller(['5m', '1m', '15m'], labels_ordered, default_element, timeframe_keys) return default_main, default_element, default_sub_sub, timeframe_keys diff --git a/web/static/js/app/api_client.js b/web/static/js/app/api_client.js index c3c3e84..828ba65 100644 --- a/web/static/js/app/api_client.js +++ b/web/static/js/app/api_client.js @@ -10,6 +10,33 @@ window.ChanApi = { symbols: function() { return fetch('/api/symbols').then(r => r.json()); }, + derivatives: function(params) { + const q = new URLSearchParams(params || {}); + return fetch('/api/derivatives?' + q.toString()).then(function(r) { + return r.json().then(function(body) { + if (!r.ok) throw new Error((body && body.error) || r.statusText); + return body; + }); + }); + }, + sentimentLatest: function(params) { + const q = new URLSearchParams(params || {}); + return fetch('/api/sentiment/latest?' + q.toString()).then(function(r) { + return r.json().then(function(body) { + if (!r.ok) throw new Error((body && body.error) || r.statusText); + return body; + }); + }); + }, + sentimentMetrics: function(params) { + const q = new URLSearchParams(params || {}); + return fetch('/api/sentiment/metrics?' + q.toString()).then(function(r) { + return r.json().then(function(body) { + if (!r.ok) throw new Error((body && body.error) || r.statusText); + return body; + }); + }); + }, macdConfig: function(body) { if (body === undefined) return fetch('/api/macd_config').then(r => r.json()); return fetch('/api/macd_config', { diff --git a/web/static/js/app/chart_sync.js b/web/static/js/app/chart_sync.js index b6d05d5..d5b85de 100644 --- a/web/static/js/app/chart_sync.js +++ b/web/static/js/app/chart_sync.js @@ -229,29 +229,29 @@ function updateTradingViewData(options) { } // 更新MACD数据 - if (tvWidget.series.macdLineSeries && currentData.macd && currentData.kline_data && Array.isArray(currentData.kline_data)) { - // 提取MACD数据 + const macdKlineSrc = useSubSubPeriod ? (currentData.sub_sub_kline_data || []) : (useElementPeriod ? (currentData.element_kline_data || []) : (currentData.kline_data || [])); + const macdSrc = useSubSubPeriod ? (currentData.sub_sub_macd || currentData.macd) : (useElementPeriod ? (currentData.element_macd || currentData.macd) : currentData.macd); + if (tvWidget.series.macdLineSeries && macdSrc && macdKlineSrc && Array.isArray(macdKlineSrc)) { const macdData = []; const signalData = []; const histogramData = []; - for (let i = 0; i < currentData.kline_data.length; i++) { - const kline = currentData.kline_data[i]; + for (let i = 0; i < macdKlineSrc.length; i++) { + const kline = macdKlineSrc[i]; const timestamp = Math.floor(new Date(kline.date).getTime() / 1000); - if (currentData.macd && currentData.macd.macd && currentData.macd.macd[i] !== undefined) { + if (macdSrc && macdSrc.macd && macdSrc.macd[i] !== undefined) { macdData.push({ time: timestamp, - value: currentData.macd.macd[i] + value: macdSrc.macd[i] }); signalData.push({ time: timestamp, - value: currentData.macd.signal[i] + value: macdSrc.signal[i] }); - // 设置直方图颜色 - const histValue = currentData.macd.histogram[i]; + const histValue = macdSrc.histogram[i]; histogramData.push({ time: timestamp, value: histValue, @@ -309,6 +309,9 @@ function updateTradingViewData(options) { } ); } + if (typeof refreshUnittfOverlayFromData === 'function') { + refreshUnittfOverlayFromData(currentData); + } } catch (e) { console.warn('更新ChanMACD标注失败:', e); } @@ -331,7 +334,8 @@ function updateTradingViewData(options) { tvWidget.volumeChart, tvWidget.atrChart, tvWidget.macdChart, - tvWidget.chanMacdChart + tvWidget.chanMacdChart, + tvWidget.sentimentChart ].filter(Boolean); const vr = clampedVisibleRange || savedVisibleRange; @@ -409,8 +413,11 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai volume: false, atr: false, macd: false, - chanmacd: false + chanmacd: false, + sentiment: false }; + const sentimentChart = tvWidget && tvWidget.sentimentChart; + const sentimentChartContainer = tvWidget && tvWidget.sentimentChartContainer; // 同步图表的时间范围 function syncCharts(sourceChart, sourceContainer) { @@ -438,6 +445,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai if (showMacd && chanMacdChart && sourceChart !== chanMacdChart && chanMacdChart.timeScale) { try { chanMacdChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} } + if (sentimentChart && sourceChart !== sentimentChart && sentimentChart.timeScale) { + try { sentimentChart.timeScale().setVisibleLogicalRange(logicalRange); } catch (e) {} + } if (tvWidget && tvWidget.state) { tvWidget.state.logicalRange = logicalRange; @@ -458,7 +468,8 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai chart === volumeChart ? 'volume' : chart === atrChart ? 'atr' : chart === macdChart ? 'macd' : - chart === chanMacdChart ? 'chanmacd' : 'unknown'; + chart === chanMacdChart ? 'chanmacd' : + chart === sentimentChart ? 'sentiment' : 'unknown'; const timeRangeHandler = () => { if (!syncInProgress) { @@ -515,6 +526,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai if (showMacd && chanMacdChartContainer && chanMacdChart) { addChartSyncEvents(chanMacdChartContainer, chanMacdChart); } + if (sentimentChartContainer && sentimentChart) { + addChartSyncEvents(sentimentChartContainer, sentimentChart); + } // 窗口大小变化时重绘图表 — 使用可清理的方式注册 const resizeHandler = () => { @@ -533,6 +547,9 @@ function bindSyncEvents(mainChartContainer, volumeChartContainer, atrChartContai if (showMacd && chanMacdChart && chanMacdChartContainer) { chanMacdChart.applyOptions({ width: chanMacdChartContainer.clientWidth, height: chanMacdChartContainer.clientHeight }); } + if (sentimentChart && sentimentChartContainer) { + sentimentChart.applyOptions({ width: sentimentChartContainer.clientWidth, height: sentimentChartContainer.clientHeight }); + } setTimeout(() => { if (mainChart) syncCharts(mainChart, mainChartContainer); }, 200); }; window.addEventListener('resize', resizeHandler); @@ -549,9 +566,11 @@ function setupTooltip(mainChart, buyMarkers = [], sellMarkers = [], mainChartCon // 初始化 U 显示状态(主/次周期分开控制) const isShowUMain = $('#toggleUOnMain').is(':checked'); const isShowUElement = $('#toggleUOnElement').is(':checked'); + const isShowUSubSub = $('#toggleUOnSubSub').is(':checked'); window.showUOnMain = isShowUMain; window.showUOnElement = isShowUElement; - if (!isShowUMain && !isShowUElement) { + window.showUOnSubSub = isShowUSubSub; + if (!isShowUMain && !isShowUElement && !isShowUSubSub) { // 隐藏时清空子图上的 U 标记 if (tvWidget.series && tvWidget.series.chanMacdLineSeries) { try { tvWidget.series.chanMacdLineSeries.setMarkers([]); } catch (e) {} diff --git a/web/static/js/app/chart_tables.js b/web/static/js/app/chart_tables.js index 3e38571..82f7fce 100644 --- a/web/static/js/app/chart_tables.js +++ b/web/static/js/app/chart_tables.js @@ -169,7 +169,7 @@ function updateTables(currentData) { }); // 更新数据源信息显示 - const selectedPeriod = useSubSubPeriod ? '次次周期' : (useElementPeriod ? '小周期' : '主周期'); + const selectedPeriod = useSubSubPeriod ? '次次' : (useElementPeriod ? '次' : '主'); const timeframe = useSubSubPeriod && data.sub_sub_timeframe ? data.sub_sub_timeframe : (useElementPeriod && data.element_timeframe ? data.element_timeframe : $('#timeframe').val()); $('#dataSourceText').html(`当前显示的是${selectedPeriod} (${timeframe}) 数据`); @@ -297,55 +297,55 @@ function setupDataSourceInfo(data) { $('#kline-tab, #macd-tab').off('click').on('click', function() { $('.data-source-info').show(); if (useSubSubPeriod && data.sub_sub_kline_data && data.sub_sub_kline_data.length > 0) { - $('#dataSourceText').html(`当前显示的是次次周期 (${subSubTimeframe}) 数据`); + $('#dataSourceText').html(`当前显示的是次次 (${subSubTimeframe}) 数据`); } else if (useElementPeriod && data.element_kline_data && data.element_kline_data.length > 0) { - $('#dataSourceText').html(`当前显示的是小周期 (${elementTimeframe}) 数据`); + $('#dataSourceText').html(`当前显示的是次 (${elementTimeframe}) 数据`); } else { - $('#dataSourceText').html(`当前显示的是主周期 (${mainTimeframe}) 数据`); + $('#dataSourceText').html(`当前显示的是主 (${mainTimeframe}) 数据`); } }); $('#bi-tab').off('click').on('click', function() { $('.data-source-info').show(); if (useSubSubPeriod && data.sub_sub_bi_list && data.sub_sub_bi_list.length > 0) { - $('#dataSourceText').html(`当前显示的是次次周期 (${subSubTimeframe}) 笔数据`); + $('#dataSourceText').html(`当前显示的是次次 (${subSubTimeframe}) 笔数据`); } else if (useElementPeriod && data.element_bi_list && data.element_bi_list.length > 0) { - $('#dataSourceText').html(`当前显示的是小周期 (${elementTimeframe}) 笔数据`); + $('#dataSourceText').html(`当前显示的是次 (${elementTimeframe}) 笔数据`); } else { - $('#dataSourceText').html(`当前显示的是主周期 (${mainTimeframe}) 笔数据`); + $('#dataSourceText').html(`当前显示的是主 (${mainTimeframe}) 笔数据`); } }); $('#seg-tab').off('click').on('click', function() { $('.data-source-info').show(); if (useSubSubPeriod && data.sub_sub_seg_list && data.sub_sub_seg_list.length > 0) { - $('#dataSourceText').html(`当前显示的是次次周期 (${subSubTimeframe}) 线段数据`); + $('#dataSourceText').html(`当前显示的是次次 (${subSubTimeframe}) 线段数据`); } else if (useElementPeriod && data.element_seg_list && data.element_seg_list.length > 0) { - $('#dataSourceText').html(`当前显示的是小周期 (${elementTimeframe}) 线段数据`); + $('#dataSourceText').html(`当前显示的是次 (${elementTimeframe}) 线段数据`); } else { - $('#dataSourceText').html(`当前显示的是主周期 (${mainTimeframe}) 线段数据`); + $('#dataSourceText').html(`当前显示的是主 (${mainTimeframe}) 线段数据`); } }); $('#zs-tab').off('click').on('click', function() { $('.data-source-info').show(); if (useSubSubPeriod && data.sub_sub_zs_list && data.sub_sub_zs_list.length > 0) { - $('#dataSourceText').html(`当前显示的是次次周期 (${subSubTimeframe}) 中枢数据`); + $('#dataSourceText').html(`当前显示的是次次 (${subSubTimeframe}) 中枢数据`); } else if (useElementPeriod && data.element_zs_list && data.element_zs_list.length > 0) { - $('#dataSourceText').html(`当前显示的是小周期 (${elementTimeframe}) 中枢数据`); + $('#dataSourceText').html(`当前显示的是次 (${elementTimeframe}) 中枢数据`); } else { - $('#dataSourceText').html(`当前显示的是主周期 (${mainTimeframe}) 中枢数据`); + $('#dataSourceText').html(`当前显示的是主 (${mainTimeframe}) 中枢数据`); } }); $('#trade-points-tab').off('click').on('click', function() { $('.data-source-info').show(); if (useSubSubPeriod && data.sub_sub_bsp_list && data.sub_sub_bsp_list.length > 0) { - $('#dataSourceText').html(`当前显示的是次次周期 (${subSubTimeframe}) 买卖点数据`); + $('#dataSourceText').html(`当前显示的是次次 (${subSubTimeframe}) 买卖点数据`); } else if (useElementPeriod && data.element_trade_points && data.element_trade_points.length > 0) { - $('#dataSourceText').html(`当前显示的是小周期 (${elementTimeframe}) 买卖点数据`); + $('#dataSourceText').html(`当前显示的是次 (${elementTimeframe}) 买卖点数据`); } else { - $('#dataSourceText').html(`当前显示的是主周期 (${mainTimeframe}) 买卖点数据`); + $('#dataSourceText').html(`当前显示的是主 (${mainTimeframe}) 买卖点数据`); } }); diff --git a/web/static/js/app/chart_tv.js b/web/static/js/app/chart_tv.js index 4990e08..82400fb 100644 --- a/web/static/js/app/chart_tv.js +++ b/web/static/js/app/chart_tv.js @@ -18,6 +18,7 @@ function initTradingView(symbol, timeframe) { chartTvRenderChan(ctx); chartTvRenderOverlays(ctx); chartTvFinalize(ctx); + if (window.ChanDeriv) ChanDeriv.loadOverlays(); console.log('图表初始化完成'); } catch (e) { diff --git a/web/static/js/app/chart_tv_finalize.js b/web/static/js/app/chart_tv_finalize.js index c9dc84f..bece7e4 100644 --- a/web/static/js/app/chart_tv_finalize.js +++ b/web/static/js/app/chart_tv_finalize.js @@ -22,6 +22,8 @@ function chartTvFinalize(ctx) { var atrChart = ctx.atrChart; var macdChart = ctx.macdChart; var chanMacdChart = ctx.chanMacdChart; + var sentimentChart = ctx.sentimentChart; + var sentimentChartContainer = ctx.sentimentChartContainer; var createChartOptions = ctx.createChartOptions; // 同步所有图表的时间轴配置 const hasPendingRestoreView = !!window._pendingRestoreView; @@ -55,6 +57,9 @@ function chartTvFinalize(ctx) { if (showMacd && macdChart) { macdChart.timeScale().applyOptions(baseOptions); } + if (sentimentChart) { + sentimentChart.timeScale().applyOptions(baseOptions); + } }; // 首先同步时间轴设置 @@ -65,7 +70,8 @@ function chartTvFinalize(ctx) { const visibleBarsCount = 200; const allChartsNow = [mainChart, volumeChart, atrChart] .concat(showMacd && macdChart ? [macdChart] : []) - .concat(showMacd && chanMacdChart ? [chanMacdChart] : []); + .concat(showMacd && chanMacdChart ? [chanMacdChart] : []) + .concat(sentimentChart ? [sentimentChart] : []); const restoreOpts = function () { const firstT = candles && candles.length ? candles[0].time : null; const lastT = candles && candles.length ? candles[candles.length - 1].time : null; @@ -103,6 +109,9 @@ function chartTvFinalize(ctx) { if (showMacd && chanMacdChart) { chanMacdChart.timeScale().setVisibleLogicalRange(logRange); } + if (sentimentChart) { + sentimentChart.timeScale().setVisibleLogicalRange(logRange); + } } return; } @@ -117,6 +126,9 @@ function chartTvFinalize(ctx) { if (showMacd && chanMacdChart) { chanMacdChart.timeScale().setVisibleLogicalRange(logRange); } + if (sentimentChart) { + sentimentChart.timeScale().setVisibleLogicalRange(logRange); + } console.log('🔧 时间轴同步完成'); } }, 50); @@ -127,6 +139,8 @@ function chartTvFinalize(ctx) { tvWidget.atrChart = atrChart; tvWidget.macdChart = macdChart; tvWidget.chanMacdChart = chanMacdChart; + tvWidget.sentimentChart = sentimentChart; + tvWidget.sentimentChartContainer = sentimentChartContainer; tvWidget.state.isInitialized = true; // 注册窗口卸载时释放资源,避免GPU内存泄漏 window.onbeforeunload = function() { @@ -137,6 +151,7 @@ function chartTvFinalize(ctx) { if (tvWidget.macdChart && typeof tvWidget.macdChart.remove === 'function') tvWidget.macdChart.remove(); if (tvWidget.chanMacdChart && typeof tvWidget.chanMacdChart.remove === 'function') tvWidget.chanMacdChart.remove(); if (tvWidget.atrChart && typeof tvWidget.atrChart.remove === 'function') tvWidget.atrChart.remove(); + if (tvWidget.sentimentChart && typeof tvWidget.sentimentChart.remove === 'function') tvWidget.sentimentChart.remove(); } } catch (e) {} }; @@ -220,6 +235,7 @@ function chartTvFinalize(ctx) { const allCharts = [mainChart, volumeChart, atrChart]; if (showMacd && macdChart) allCharts.push(macdChart); if (showMacd && chanMacdChart) allCharts.push(chanMacdChart); + if (sentimentChart) allCharts.push(sentimentChart); // 检查是否有待恢复的视图(缩放 + 位置) const pending = window._pendingRestoreView || pendingView; @@ -243,7 +259,8 @@ function chartTvFinalize(ctx) { console.log('🔧 最终同步可见范围:', visibleRange); [volumeChart, atrChart].concat( showMacd && macdChart ? [macdChart] : [], - showMacd && chanMacdChart ? [chanMacdChart] : [] + showMacd && chanMacdChart ? [chanMacdChart] : [], + sentimentChart ? [sentimentChart] : [] ).forEach(c => { try { c.timeScale().setVisibleRange(visibleRange); } catch(e) {} }); diff --git a/web/static/js/app/chart_tv_indicators.js b/web/static/js/app/chart_tv_indicators.js index d33d210..ccdc3ad 100644 --- a/web/static/js/app/chart_tv_indicators.js +++ b/web/static/js/app/chart_tv_indicators.js @@ -5,6 +5,40 @@ function formatSdMarkerText(separateDiv) { return `SD${n === 99999 ? 0 : n}`; } +function shouldShowSdMarker(separateDiv) { + const n = Number(separateDiv); + return isFinite(n) && n > 0 && n !== 99999; +} + +var SD_CD_MARKER_COLOR = '#dc3545'; +var SD_CD_MARKER_SIZE = 1.2; + +function histArrowShape(pos) { + return pos === 'belowBar' ? 'arrowDown' : 'arrowUp'; +} + +function makeSdMarker(ts, pos, separateDiv) { + return { + time: ts, + position: pos, + color: SD_CD_MARKER_COLOR, + shape: histArrowShape(pos), + text: formatSdMarkerText(separateDiv), + size: SD_CD_MARKER_SIZE + }; +} + +function makeCdMarker(ts, pos) { + return { + time: ts, + position: pos, + color: SD_CD_MARKER_COLOR, + shape: histArrowShape(pos), + text: 'CD', + size: SD_CD_MARKER_SIZE + }; +} + function chartTvRenderIndicators(ctx) { var symbol = ctx.symbol; var timeframe = ctx.timeframe; @@ -214,7 +248,7 @@ function chartTvRenderIndicators(ctx) { const chanMacdLineSeries = chanMacdChart.addLineSeries({ color: '#2962FF', lineWidth: 1, - title: 'ChanMACD', + title: 'MACD', lastValueVisible: false, priceLineVisible: false, }); @@ -375,7 +409,8 @@ function chartTvRenderIndicators(ctx) { const k = klineArr[i]; if (!k || !k.date) continue; const t = Math.floor(new Date(k.date).getTime() / 1000); - const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null; + const hist = (macdObj.histogram && macdObj.histogram[i] !== undefined && macdObj.histogram[i] !== null) ? macdObj.histogram[i] : null; + const val = (hist !== null) ? hist : ((macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null); map.set(t, val); } return map; @@ -392,15 +427,15 @@ function chartTvRenderIndicators(ctx) { if (!item || !item.time) return; const ts = Math.floor(new Date(item.time).getTime() / 1000); if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { + if (shouldShowSdMarker(item.separate_div)) { const macdVal = mainMacdMap.get(ts); const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; - mainMarkers.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: formatSdMarkerText(item.separate_div), size: 0.6 }); + mainMarkers.push(makeSdMarker(ts, posSd, item.separate_div)); } if (item.continue_div === true) { const macdVal = mainMacdMap.get(ts); const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; - mainMarkers.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 }); + mainMarkers.push(makeCdMarker(ts, posCd)); } if (item.near0_return && Number(item.near0_return) > 0) { mainMarkers.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); @@ -414,15 +449,15 @@ function chartTvRenderIndicators(ctx) { if (!item || !item.time) return; const ts = Math.floor(new Date(item.time).getTime() / 1000); if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { + if (shouldShowSdMarker(item.separate_div)) { const macdVal = elementMacdMap.get(ts); const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; - elementMarkers.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: formatSdMarkerText(item.separate_div), size: 0.6 }); + elementMarkers.push(makeSdMarker(ts, posSd, item.separate_div)); } if (item.continue_div === true) { const macdVal = elementMacdMap.get(ts); const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; - elementMarkers.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 }); + elementMarkers.push(makeCdMarker(ts, posCd)); } if (item.near0_return && Number(item.near0_return) > 0) { elementMarkers.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); @@ -432,21 +467,24 @@ function chartTvRenderIndicators(ctx) { const subSubCm = currentData.sub_sub_chan_macd || {}; const subSubMarkers = []; - const subSubMacdMap = buildMacdTimeMap(currentData.macd, currentData.kline_data); + const subSubMacdMap = buildMacdTimeMap( + (currentData.sub_sub_macd || currentData.macd), + (currentData.sub_sub_kline_data || currentData.kline_data) + ); if (window.showUOnSubSub && Array.isArray(subSubCm.klu_list)) { subSubCm.klu_list.forEach((item) => { if (!item || !item.time) return; const ts = Math.floor(new Date(item.time).getTime() / 1000); if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { + if (shouldShowSdMarker(item.separate_div)) { const macdVal = subSubMacdMap.get(ts); const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; - subSubMarkers.push({ time: ts, position: posSd, color: '#00897b', shape: 'arrowUp', text: formatSdMarkerText(item.separate_div), size: 0.6 }); + subSubMarkers.push(makeSdMarker(ts, posSd, item.separate_div)); } if (item.continue_div === true) { const macdVal = subSubMacdMap.get(ts); const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; - subSubMarkers.push({ time: ts, position: posCd, color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 }); + subSubMarkers.push(makeCdMarker(ts, posCd)); } if (item.near0_return && Number(item.near0_return) > 0) { subSubMarkers.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); @@ -489,7 +527,8 @@ function chartTvRenderIndicators(ctx) { const k = klineArr[i]; if (!k || !k.date) continue; const t = Math.floor(new Date(k.date).getTime() / 1000); - const val = (macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null; + const hist = (macdObj.histogram && macdObj.histogram[i] !== undefined && macdObj.histogram[i] !== null) ? macdObj.histogram[i] : null; + const val = (hist !== null) ? hist : ((macdObj.macd && macdObj.macd[i] !== undefined && macdObj.macd[i] !== null) ? macdObj.macd[i] : null); map.set(t, val); } return map; @@ -499,20 +538,24 @@ function chartTvRenderIndicators(ctx) { (currentData.element_macd || currentData.macd), (currentData.element_kline_data || currentData.kline_data) ); + const subSubMacdMapAll = buildMacdTimeMapAll( + (currentData.sub_sub_macd || currentData.macd), + (currentData.sub_sub_kline_data || currentData.kline_data) + ); if ((typeof window.showUOnMain === 'undefined' ? false : window.showUOnMain) && Array.isArray(mainCmAll.klu_list)) { mainCmAll.klu_list.forEach((item) => { if (!item || !item.time) return; const ts = Math.floor(new Date(item.time).getTime() / 1000); if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { + if (shouldShowSdMarker(item.separate_div)) { const macdVal = mainMacdMapAll.get(ts); const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; - mainMarkersAll.push({ time: ts, position: posSd, color: '#03a9f4', shape: 'arrowUp', text: formatSdMarkerText(item.separate_div), size: 0.6 }); + mainMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div)); } if (item.continue_div === true) { const macdVal = mainMacdMapAll.get(ts); const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; - mainMarkersAll.push({ time: ts, position: posCd, color: '#ff9800', shape: 'arrowDown', text: 'CD', size: 0.6 }); + mainMarkersAll.push(makeCdMarker(ts, posCd)); } if (item.near0_return && Number(item.near0_return) > 0) { mainMarkersAll.push({ time: ts, position: 'belowBar', color: '#8bc34a', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); @@ -524,15 +567,15 @@ function chartTvRenderIndicators(ctx) { if (!item || !item.time) return; const ts = Math.floor(new Date(item.time).getTime() / 1000); if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { + if (shouldShowSdMarker(item.separate_div)) { const macdVal = elementMacdMapAll.get(ts); const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; - elementMarkersAll.push({ time: ts, position: posSd, color: '#9c27b0', shape: 'arrowUp', text: formatSdMarkerText(item.separate_div), size: 0.6 }); + elementMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div)); } if (item.continue_div === true) { const macdVal = elementMacdMapAll.get(ts); const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; - elementMarkersAll.push({ time: ts, position: posCd, color: '#4caf50', shape: 'arrowDown', text: 'CD', size: 0.6 }); + elementMarkersAll.push(makeCdMarker(ts, posCd)); } if (item.near0_return && Number(item.near0_return) > 0) { elementMarkersAll.push({ time: ts, position: 'belowBar', color: '#009688', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); @@ -548,11 +591,15 @@ function chartTvRenderIndicators(ctx) { if (!item || !item.time) return; const ts = Math.floor(new Date(item.time).getTime() / 1000); if (isNaN(ts)) return; - if (Number(item.separate_div) > 0) { - subSubMarkersAll.push({ time: ts, position: 'aboveBar', color: '#00897b', shape: 'arrowUp', text: formatSdMarkerText(item.separate_div), size: 0.6 }); + if (shouldShowSdMarker(item.separate_div)) { + const macdVal = subSubMacdMapAll.get(ts); + const posSd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'aboveBar'; + subSubMarkersAll.push(makeSdMarker(ts, posSd, item.separate_div)); } if (item.continue_div === true) { - subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#26a69a', shape: 'arrowDown', text: 'CD', size: 0.6 }); + const macdVal = subSubMacdMapAll.get(ts); + const posCd = (macdVal > 0) ? 'aboveBar' : (macdVal < 0) ? 'belowBar' : 'belowBar'; + subSubMarkersAll.push(makeCdMarker(ts, posCd)); } if (item.near0_return && Number(item.near0_return) > 0) { subSubMarkersAll.push({ time: ts, position: 'belowBar', color: '#00695c', shape: 'circle', text: `${Number(item.near0_return)}`, size: 0.6 }); diff --git a/web/static/js/app/chart_tv_lifecycle.js b/web/static/js/app/chart_tv_lifecycle.js index 3f1c19f..668330f 100644 --- a/web/static/js/app/chart_tv_lifecycle.js +++ b/web/static/js/app/chart_tv_lifecycle.js @@ -25,7 +25,7 @@ function disposeTradingViewCharts() { } if (tvWidget) { - ['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart'].forEach(function (key) { + ['mainChart', 'volumeChart', 'macdChart', 'chanMacdChart', 'atrChart', 'sentimentChart'].forEach(function (key) { try { if (tvWidget[key] && typeof tvWidget[key].remove === 'function') { tvWidget[key].remove(); diff --git a/web/static/js/app/chart_tv_overlays.js b/web/static/js/app/chart_tv_overlays.js index ffc4be8..3eb643d 100644 --- a/web/static/js/app/chart_tv_overlays.js +++ b/web/static/js/app/chart_tv_overlays.js @@ -82,11 +82,6 @@ var SUB_SUB_KLC_TREND_STYLE = { UNKNOWN: { position: 'inBar', color: '#004d40', shape: 'square', size: 0.5 } }; -function parseAreaDivValue(v) { - var n = Number(v); - return (isFinite(n) && n !== 0) ? n : 0; -} - function pushAreaTextLabel(out, item, style, text) { if (!item || !item.end_time || !text) return; var ts = Math.floor(new Date(item.end_time).getTime() / 1000); @@ -104,25 +99,6 @@ function pushAreaTextLabel(out, item, style, text) { }); } -function pushAreaDivMarker(out, item, style) { - var div = parseAreaDivValue(item && item.macd_div); - if (!div) return; - pushAreaTextLabel(out, item, style, div.toFixed(2)); -} - -function collectAreaDivMarkers(biList, uncompletedBi, segList, uncompletedSeg, biStyle, segStyle, showBi, showSeg) { - var out = []; - if (showBi) { - (biList || []).forEach(function (bi) { pushAreaDivMarker(out, bi, biStyle); }); - (uncompletedBi || []).forEach(function (bi) { pushAreaDivMarker(out, bi, biStyle); }); - } - if (showSeg) { - (segList || []).forEach(function (seg) { pushAreaDivMarker(out, seg, segStyle); }); - (uncompletedSeg || []).forEach(function (seg) { pushAreaDivMarker(out, seg, segStyle); }); - } - return out; -} - function formatMacdAreaText(v) { var n = Number(v); if (!isFinite(n) || n === 0) return ''; @@ -197,54 +173,6 @@ function buildAreaHistMarkersFromData(data) { return markers; } -function buildAreaDivMarkersFromData(data) { - var markers = []; - if (!data) return markers; - var showMainBi = $('#showMainMacdDiv').is(':checked'); - var showMainSeg = $('#showMainSegMacdDiv').is(':checked'); - if (showMainBi || showMainSeg) { - markers = markers.concat(collectAreaDivMarkers( - data.bi_list, - data.uncompleted_bi_list, - data.seg_list, - data.uncompleted_seg_list, - { color: '#e53935', size: 0.6 }, - { color: '#fb8c00', size: 0.7 }, - showMainBi, - showMainSeg - )); - } - var showElementBi = $('#showElementMacdDiv').is(':checked'); - var showElementSeg = $('#showElementSegMacdDiv').is(':checked'); - if (showElementBi || showElementSeg) { - markers = markers.concat(collectAreaDivMarkers( - data.element_bi_list, - data.element_uncompleted_bi_list, - data.element_seg_list, - data.element_uncompleted_seg_list, - { color: '#8e24aa', size: 0.55 }, - { color: '#5e35b1', size: 0.65 }, - showElementBi, - showElementSeg - )); - } - var showSubSubBi = $('#showSubSubMacdDiv').is(':checked'); - var showSubSubSeg = $('#showSubSubSegMacdDiv').is(':checked'); - if (showSubSubBi || showSubSubSeg) { - markers = markers.concat(collectAreaDivMarkers( - data.sub_sub_bi_list, - data.sub_sub_uncompleted_bi_list, - data.sub_sub_seg_list, - data.sub_sub_uncompleted_seg_list, - { color: '#00897b', size: 0.5 }, - { color: '#00695c', size: 0.6 }, - showSubSubBi, - showSubSubSeg - )); - } - return markers; -} - function buildKlcTrendMarker(timeAligned, trendRaw, palette) { var kind = normalizeKlcTrendRaw(trendRaw); var style = palette[kind] || palette.UNKNOWN || palette.FLAT; @@ -299,6 +227,21 @@ function getMainPriceSeries() { s.lineSeries || s.areaSeries || s.baselineSeries || null; } +function plotLeftOffset(chart) { + try { + var left = chart && chart.priceScale && chart.priceScale('left'); + if (!left) return 0; + var visible = true; + try { + var opts = left.options && left.options(); + if (opts && opts.visible === false) return 0; + } catch (e) {} + return (typeof left.width === 'function' ? left.width() : 0) || 0; + } catch (e) { + return 0; + } +} + function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) { if (!mainChart || !mainChartContainer) return; if (typeof window._fxBoxOverlayCleanup === 'function') { @@ -330,7 +273,7 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) { var series = getMainPriceSeries(); if (!series || (!boxes.length && !labels.length)) return '0'; var ts = mainChart.timeScale(); - var parts = [boxes.length, labels.length]; + var parts = [boxes.length, labels.length, quant(plotLeftOffset(mainChart))]; if (boxes.length) { var a = boxes[0]; var b = boxes[boxes.length - 1]; @@ -374,18 +317,20 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) { return; } var ts = mainChart.timeScale(); + var x0 = plotLeftOffset(mainChart); for (var i = 0; i < boxes.length; i++) { var box = boxes[i]; var x = ts.timeToCoordinate(box.time); var y1 = series.priceToCoordinate(box.hi); var y2 = series.priceToCoordinate(box.lo); if (x == null || y1 == null || y2 == null) continue; + var px = Math.round(x + x0) + 0.5; ctx2.beginPath(); ctx2.strokeStyle = box.color; ctx2.lineWidth = 1; ctx2.setLineDash([4, 3]); - ctx2.moveTo(Math.round(x) + 0.5, y1); - ctx2.lineTo(Math.round(x) + 0.5, y2); + ctx2.moveTo(px, y1); + ctx2.lineTo(px, y2); ctx2.stroke(); } ctx2.setLineDash([]); @@ -399,7 +344,7 @@ function syncFxBoxVerticalOverlay(mainChart, mainChartContainer) { if (lx == null || ly == null) continue; ctx2.fillStyle = lab.color; ctx2.textBaseline = lab.above ? 'bottom' : 'top'; - ctx2.fillText(lab.text, Math.round(lx), lab.above ? ly - 3 : ly + 3); + ctx2.fillText(lab.text, Math.round(lx + x0), lab.above ? ly - 3 : ly + 3); } } lastSig = sampleSig(); @@ -1182,56 +1127,6 @@ function chartTvRenderOverlays(ctx) { window.bspMarkers = []; } - // 第四类买卖点(B4/S4):中枢突破回抽后当根入场,位置同 B3/S3 但早 7~8 根。 - // 与 BSP 分开收集,因为它数量远多于 B1/B2/B3,混在一个开关里图会糊掉。 - if ($('#showMainFastBsp').is(':checked') || $('#showElementFastBsp').is(':checked') || $('#showSubSubFastBsp').is(':checked')) { - // 深色 = 区间套(大级别分型同向) + 中枢顺向推进都满足;浅色 = 未通过过滤 - const FAST_BSP_STYLE = { - 'BUY': { strong: '#FF6D00', weak: '#FFCC80', text: 'B4', position: 'belowBar' }, - 'SELL': { strong: '#0091EA', weak: '#81D4FA', text: 'S4', position: 'aboveBar' }, - }; - const onlyFiltered = ($('#fastBspFilterMode').val() || 'all') === 'filtered'; - const allFastBspMarkers = []; - - const collectFastBsp = function(list, prefix, label) { - (list || []).forEach(function(bsp) { - try { - const ts = Math.floor(new Date(bsp.time).getTime() / 1000); - if (isNaN(ts)) return; - const style = FAST_BSP_STYLE[(bsp.dir || '').toUpperCase()]; - if (!style) return; - const passed = !!(bsp.htf_agree && bsp.ladder_ok); - if (onlyFiltered && !passed) return; - allFastBspMarkers.push({ - time: ts, - position: style.position, - color: passed ? style.strong : style.weak, - text: prefix + (passed ? style.text : style.text.toLowerCase()), - size: passed ? 2 : 1 - }); - } catch (e) { - console.error(label + '第四类买卖点处理出错:', e); - } - }); - }; - - if ($('#showMainFastBsp').is(':checked')) { - collectFastBsp(currentData.fast_bsp_list, '', '主周期'); - } - if ($('#showElementFastBsp').is(':checked')) { - collectFastBsp(currentData.element_fast_bsp_list, 'e', '次周期'); - } - if ($('#showSubSubFastBsp').is(':checked')) { - collectFastBsp(currentData.sub_sub_fast_bsp_list, 's', '次次周期'); - } - - allFastBspMarkers.sort((a, b) => a.time - b.time); - window.fastBspMarkers = allFastBspMarkers; - console.log(`绘制第四类买卖点,共${allFastBspMarkers.length}个标记(${onlyFiltered ? '仅过滤后' : '全部'})`); - } else { - window.fastBspMarkers = []; - } - // 添加买卖点标记(旧版,保留兼容) // 这里为了与主面板上的「买卖点」开关保持一致, // 同时响应顶部的 `#showMainBsp` 复选框 @@ -1807,7 +1702,7 @@ function chartTvRenderOverlays(ctx) { lineStyle: 2, // 虚线 lastValueVisible: false, priceLineVisible: false, - title: '次周期布林上轨' + title: '次布林上轨' }); elementUpperBandSeries.setData(elementUpperBandData); @@ -1818,7 +1713,7 @@ function chartTvRenderOverlays(ctx) { lineStyle: 2, // 虚线 lastValueVisible: false, priceLineVisible: false, - title: '次周期布林下轨' + title: '次布林下轨' }); elementLowerBandSeries.setData(elementLowerBandData); @@ -1828,7 +1723,7 @@ function chartTvRenderOverlays(ctx) { lineWidth: 1, lastValueVisible: false, priceLineVisible: false, - title: '次周期布林中轨' + title: '次布林中轨' }); elementMiddleBandSeries.setData(elementMiddleBandData); @@ -1943,7 +1838,7 @@ function chartTvRenderOverlays(ctx) { const fxMarker = { time: timestamp, tooltip: `
- 主周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}
+ 主${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}
强度分数: ${fx.fx_strength}分
强度等级: ${fx.fx_strength_level}
是否强分型: ${fx.is_strong_fx ? '是' : '否'}
@@ -2007,7 +1902,7 @@ function chartTvRenderOverlays(ctx) { const fxMarker = { time: timestamp, tooltip: `
- 主周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}
+ 主${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}
强度分数: ${fx.fx_strength}分
强度等级: ${fx.fx_strength_level}
是否强分型: ${fx.is_strong_fx ? '是' : '否'}
@@ -2045,7 +1940,7 @@ function chartTvRenderOverlays(ctx) { window.fxMarkers = []; } window._areaTextLabels = alignMarkersToCandles( - buildAreaDivMarkersFromData(currentData).concat(buildAreaHistMarkersFromData(currentData)), + buildAreaHistMarkersFromData(currentData), candles ); if (typeof window._redrawFxBoxVerticalOverlay === 'function') { @@ -2140,7 +2035,7 @@ function chartTvRenderOverlays(ctx) { const elementFxMarker = { time: timestamp, tooltip: `
- 小周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}
+ 次${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}
强度分数: ${fx.fx_strength}分
强度等级: ${fx.fx_strength_level}
是否强分型: ${fx.is_strong_fx ? '是' : '否'}
@@ -2196,7 +2091,7 @@ function chartTvRenderOverlays(ctx) { const elementFxMarker = { time: timestamp, tooltip: `
- 小周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}
+ 次${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}
强度分数: ${fx.fx_strength}分
强度等级: ${fx.fx_strength_level}
是否强分型: ${fx.is_strong_fx ? '是' : '否'}
@@ -2365,8 +2260,7 @@ function chartTvRenderOverlays(ctx) { ...(window.kluDivMarkersElement || []), ...(window.kluDivMarkersSubSub || []), ...trendMarkersToUse, - ...(window.bspMarkers || []), - ...(window.fastBspMarkers || []) + ...(window.bspMarkers || []) ]; if (combinedMarkers.length > 0) { console.log( @@ -2375,7 +2269,6 @@ function chartTvRenderOverlays(ctx) { '个,小周期分型:', allElementFxMarkers.length, '个,背驰:', (window.kluDivMarkersMain || []).length, '个,BSP标记:', (window.bspMarkers || []).length, - '个,第四类标记:', (window.fastBspMarkers || []).length, '个)' ); @@ -2477,15 +2370,14 @@ function chartTvRenderOverlays(ctx) { // 这里的 onlyMainAndU 实际上是「最终要挂到主K线上」的一组标记 // 之前没有把 window.bspMarkers 合进去,导致上面已经合并了 BSP 标记, // 但在这里再次调用 setMarkers 时把 BSP 覆盖掉了,从而前端看不到买卖点。 - // 修复:把 BSP 标记一并合并进来。第四类买卖点同理,两处都要带上。 + // 修复:把 BSP 标记一并合并进来。两处都要带上,否则后面这次 setMarkers 会把买卖点盖掉。 const onlyMainAndU = [ ...(window.mainFxMarkers || []), ...(window.kluDivMarkersMain || []), ...(window.kluDivMarkersElement || []), ...(window.kluDivMarkersSubSub || []), ...trendMarkersToUse, - ...(window.bspMarkers || []), - ...(window.fastBspMarkers || []) + ...(window.bspMarkers || []) ]; if (onlyMainAndU.length > 0) { console.log('仅设置', onlyMainAndU.length, '个主图标记(主周期分型:', (window.mainFxMarkers || []).length, ',背驰:', (window.kluDivMarkersMain || []).length, ')'); diff --git a/web/static/js/app/chart_tv_shell.js b/web/static/js/app/chart_tv_shell.js index eae4258..bf6106b 100644 --- a/web/static/js/app/chart_tv_shell.js +++ b/web/static/js/app/chart_tv_shell.js @@ -77,6 +77,7 @@ function chartTvBuildShell(ctx) { mainChart: null, volumeChart: null, macdChart: null, + sentimentChart: null, series: { candleSeries: null, lineSeries: null, @@ -125,6 +126,7 @@ function chartTvBuildShell(ctx) { // 是否显示MACD const showMacd = $('#showMacd').is(':checked'); const showOriginalKline = $('#showOriginalKline').is(':checked'); + const showSentiment = ($('#dataSource').val() || 'crypto') === 'crypto' && $('#showDeriv').is(':checked'); // 创建主图容器 const mainChartContainer = document.createElement('div'); @@ -153,25 +155,20 @@ function chartTvBuildShell(ctx) { // 如果需要显示MACD,创建MACD容器 let macdChartContainer = null; let chanMacdChartContainer = null; - if (showMacd) { - // 仅显示新的 ChanMACD 图:让其占用原 MACD+ChanMACD 的整体高度 - // 新布局:主图(40%) → ChanMACD(30%) → 成交量(17.5%) → ATR(12.5%) - mainChartContainer.style.height = '40%'; - - // 隐藏旧 MACD 容器(不创建) - // 创建 ChanMACD 容器占据原 MACD+ChanMACD 高度(30%) + let sentimentChartContainer = null; + if (showMacd && showSentiment) { + mainChartContainer.style.height = '34%'; chanMacdChartContainer = document.createElement('div'); chanMacdChartContainer.style.width = '100%'; - chanMacdChartContainer.style.height = '30%'; + chanMacdChartContainer.style.height = 'calc(22% - 50px)'; chanMacdChartContainer.style.position = 'absolute'; - chanMacdChartContainer.style.top = '40%'; + chanMacdChartContainer.style.top = '34%'; chanMacdChartContainer.style.left = '0'; chanMacdChartContainer.style.right = '0'; chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0'; chanMacdChartContainer.style.zIndex = '10'; - // 水印:便于区分是新的 ChanMACD 子图 const chanMacdWatermark = document.createElement('div'); - chanMacdWatermark.textContent = 'ChanMACD'; + chanMacdWatermark.textContent = 'MACD'; chanMacdWatermark.style.position = 'absolute'; chanMacdWatermark.style.top = '4px'; chanMacdWatermark.style.left = '8px'; @@ -179,31 +176,81 @@ function chartTvBuildShell(ctx) { chanMacdWatermark.style.color = '#888'; chanMacdWatermark.style.pointerEvents = 'none'; chanMacdChartContainer.appendChild(chanMacdWatermark); - - // 成交量位于 ChanMACD 之下 - volumeChartContainer.style.top = '70%'; - volumeChartContainer.style.height = '17.5%'; - - // ATR 位于最底部 - atrChartContainer.style.top = '87.5%'; - atrChartContainer.style.height = '12.5%'; + volumeChartContainer.style.top = 'calc(56% - 50px)'; + volumeChartContainer.style.height = 'calc(12% + 20px)'; + atrChartContainer.style.top = 'calc(68% - 30px)'; + atrChartContainer.style.height = 'calc(10% - 20px)'; + } else if (showMacd) { + mainChartContainer.style.height = '40%'; + chanMacdChartContainer = document.createElement('div'); + chanMacdChartContainer.style.width = '100%'; + chanMacdChartContainer.style.height = 'calc(30% - 50px)'; + chanMacdChartContainer.style.position = 'absolute'; + chanMacdChartContainer.style.top = '40%'; + chanMacdChartContainer.style.left = '0'; + chanMacdChartContainer.style.right = '0'; + chanMacdChartContainer.style.borderTop = '1px solid #e0e0e0'; + chanMacdChartContainer.style.zIndex = '10'; + const chanMacdWatermark = document.createElement('div'); + chanMacdWatermark.textContent = 'MACD'; + chanMacdWatermark.style.position = 'absolute'; + chanMacdWatermark.style.top = '4px'; + chanMacdWatermark.style.left = '8px'; + chanMacdWatermark.style.fontSize = '11px'; + chanMacdWatermark.style.color = '#888'; + chanMacdWatermark.style.pointerEvents = 'none'; + chanMacdChartContainer.appendChild(chanMacdWatermark); + volumeChartContainer.style.top = 'calc(70% - 50px)'; + volumeChartContainer.style.height = 'calc(17.5% + 20px)'; + atrChartContainer.style.top = 'calc(87.5% - 30px)'; + atrChartContainer.style.height = 'calc(12.5% - 20px)'; + } else if (showSentiment) { + mainChartContainer.style.height = '48%'; + volumeChartContainer.style.top = '48%'; + volumeChartContainer.style.height = '14%'; + atrChartContainer.style.top = '62%'; + atrChartContainer.style.height = '12%'; } else { - // 不显示MACD时的高度 - 主图、成交量图和ATR图分配 - mainChartContainer.style.height = '55%'; // 主图占55% + mainChartContainer.style.height = '55%'; volumeChartContainer.style.top = '55%'; - volumeChartContainer.style.height = '22.5%'; // 成交量图占22.5% - - atrChartContainer.style.top = '77.5%'; // ATR图从77.5%位置开始 - atrChartContainer.style.height = '22.5%'; // ATR图占22.5% + volumeChartContainer.style.height = '22.5%'; + atrChartContainer.style.top = '77.5%'; + atrChartContainer.style.height = '22.5%'; + } + if (showSentiment) { + sentimentChartContainer = document.createElement('div'); + sentimentChartContainer.style.width = '100%'; + sentimentChartContainer.style.position = 'absolute'; + sentimentChartContainer.style.left = '0'; + sentimentChartContainer.style.right = '0'; + sentimentChartContainer.style.borderTop = '1px solid #e0e0e0'; + if (showMacd) { + sentimentChartContainer.style.top = '78%'; + sentimentChartContainer.style.height = '22%'; + } else { + sentimentChartContainer.style.top = '74%'; + sentimentChartContainer.style.height = '26%'; + } + const sentimentWatermark = document.createElement('div'); + sentimentWatermark.textContent = '衍生品 买卖比 / 多空 / 大户 / 费率'; + sentimentWatermark.style.position = 'absolute'; + sentimentWatermark.style.top = '4px'; + sentimentWatermark.style.left = '8px'; + sentimentWatermark.style.fontSize = '11px'; + sentimentWatermark.style.color = '#888'; + sentimentWatermark.style.pointerEvents = 'none'; + sentimentChartContainer.appendChild(sentimentWatermark); } container.appendChild(mainChartContainer); container.appendChild(volumeChartContainer); container.appendChild(atrChartContainer); if (showMacd) { - // 只追加新的 ChanMACD 容器 container.appendChild(chanMacdChartContainer); } + if (showSentiment && sentimentChartContainer) { + container.appendChild(sentimentChartContainer); + } // 防止同步过程中的无限循环(实际同步由 bindSyncEvents 负责) @@ -221,6 +268,8 @@ function chartTvBuildShell(ctx) { chartHeight = macdChartContainer ? macdChartContainer.clientHeight : 0; } else if (chartType === 'chanmacd') { chartHeight = chanMacdChartContainer ? chanMacdChartContainer.clientHeight : 0; + } else if (chartType === 'sentiment') { + chartHeight = sentimentChartContainer ? sentimentChartContainer.clientHeight : 0; } else { chartHeight = mainChartContainer.clientHeight; } @@ -376,9 +425,26 @@ function chartTvBuildShell(ctx) { // 创建MACD图表(如果需要):仅创建新的 ChanMACD 图 let macdChart = null; let chanMacdChart = null; + let sentimentChart = null; if (showMacd) { chanMacdChart = LightweightCharts.createChart(chanMacdChartContainer, createChartOptions(false, 'chanmacd')); } + if (showSentiment && sentimentChartContainer) { + sentimentChart = LightweightCharts.createChart(sentimentChartContainer, createChartOptions(false, 'sentiment')); + if (candles.length) { + const axisSeries = sentimentChart.addLineSeries({ + priceScaleId: '__time', + color: 'rgba(0,0,0,0)', + lineWidth: 0, + lastValueVisible: false, + priceLineVisible: false, + crosshairMarkerVisible: false + }); + sentimentChart.priceScale('__time').applyOptions({ visible: false }); + axisSeries.setData(candles.map(function (c) { return { time: c.time, value: 0 }; })); + tvWidget.series.sentimentAxisSeries = axisSeries; + } + } // 创建主价格系列并设置数据(支持多种图表类型) (function(){ @@ -495,10 +561,13 @@ function chartTvBuildShell(ctx) { ctx.atrChartContainer = atrChartContainer; ctx.macdChartContainer = macdChartContainer; ctx.chanMacdChartContainer = chanMacdChartContainer; + ctx.sentimentChartContainer = sentimentChartContainer; + ctx.showSentiment = showSentiment; ctx.mainChart = mainChart; ctx.volumeChart = volumeChart; ctx.atrChart = atrChart; ctx.macdChart = macdChart; ctx.chanMacdChart = chanMacdChart; + ctx.sentimentChart = sentimentChart; ctx.createChartOptions = createChartOptions; } diff --git a/web/static/js/app/chart_view.js b/web/static/js/app/chart_view.js index 06a7c95..7f7c921 100644 --- a/web/static/js/app/chart_view.js +++ b/web/static/js/app/chart_view.js @@ -74,10 +74,10 @@ function readChartFormContext() { return { dataSource: dataSource, symbol: symbol, - timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '4h', + timeframe: $('#timeframe').val() || window.DEFAULT_MAIN_TIMEFRAME || '45m', timezone: $('#timezone').val() || 'Asia/Shanghai', - elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '1m', - subSubTimeframe: $('#subSubTimeframe').val() || '', + elementTimeframe: $('#elementTimeframe').val() || window.DEFAULT_ELEMENT_TIMEFRAME || '15m', + subSubTimeframe: $('#subSubTimeframe').val() || window.DEFAULT_SUB_SUB_TIMEFRAME || '5m', startTimeMs: $('#start_time').val() ? new Date($('#start_time').val()).getTime() : null, endTimeMs: $('#end_time').val() ? new Date($('#end_time').val()).getTime() : null }; diff --git a/web/static/js/app/deriv_ui.js b/web/static/js/app/deriv_ui.js new file mode 100644 index 0000000..1697aad --- /dev/null +++ b/web/static/js/app/deriv_ui.js @@ -0,0 +1,436 @@ +/* 资金面 + 情绪面。只打本站中转。OI 叠主图左侧,其余叠情绪副图。 */ +window.ChanDeriv = (function () { + var snapshotReq = 0; + var latestReq = 0; + var overlayReq = 0; + var caches = {}; + + var METRICS = [ + { + id: 'oi', + checkbox: 'showOi', + metric: 'open_interest_history', + field: 'open_interest_amount', + seriesKey: 'oiSeries', + target: 'main', + color: 'rgba(123, 31, 162, 0.85)', + title: 'OI', + priceFormat: { type: 'volume' } + }, + { + id: 'taker', + checkbox: 'showDeriv', + metric: 'taker_buy_sell_ratio', + field: 'buy_sell_ratio', + seriesKey: 'takerSeries', + target: 'sentiment', + color: '#0d9488', + title: '买卖比', + priceFormat: { type: 'price', precision: 3 } + }, + { + id: 'lsAccount', + checkbox: 'showDeriv', + metric: 'long_short_account_ratio', + field: 'long_short_ratio', + seriesKey: 'lsAccountSeries', + target: 'sentiment', + color: '#2563eb', + title: '多空', + priceFormat: { type: 'price', precision: 3 } + }, + { + id: 'lsTop', + checkbox: 'showDeriv', + metric: 'top_long_short_position_ratio', + field: 'long_short_ratio', + seriesKey: 'lsTopSeries', + target: 'sentiment', + color: '#ea580c', + title: '大户', + priceFormat: { type: 'price', precision: 3 } + }, + { + id: 'funding', + checkbox: 'showDeriv', + metric: 'funding_rate_history', + field: 'funding_rate', + seriesKey: 'fundingHistSeries', + target: 'sentiment', + color: '#7c3aed', + title: '费率%', + scale: 'funding', + mul: 100, + priceFormat: { type: 'price', precision: 4 } + } + ]; + + function isCrypto() { + return ($('#dataSource').val() || 'crypto') === 'crypto'; + } + + function currentSymbol() { + return $('#symbol').val() || 'BTC/USDT:USDT'; + } + + function fmtOi(n) { + if (n == null || !isFinite(Number(n))) return '—'; + return Number(n).toLocaleString('en-US', { maximumFractionDigits: 1 }); + } + + function fmtFunding(n) { + if (n == null || !isFinite(Number(n))) return '—'; + return (Number(n) * 100).toFixed(4) + '%'; + } + + function fmtChg(n) { + if (n == null || !isFinite(Number(n))) return '—'; + var v = Number(n); + return (v > 0 ? '+' : '') + v.toFixed(2) + '%'; + } + + function fmtBasis(n) { + if (n == null || !isFinite(Number(n))) return '—'; + var v = Number(n); + return (v > 0 ? '+' : '') + v.toFixed(2); + } + + function fmtRatio(n) { + if (n == null || !isFinite(Number(n))) return '—'; + return Number(n).toFixed(3); + } + + function setChip(id, text, tone) { + var el = document.getElementById(id); + if (!el) return; + el.textContent = text; + el.classList.remove('up', 'down'); + if (tone) el.classList.add(tone); + } + + function tone(n, invert) { + if (!isFinite(n) || n === 0) return null; + var up = n > 0; + if (invert) up = !up; + return up ? 'up' : 'down'; + } + + function renderDerivEmpty() { + setChip('derivOi', 'OI —'); + setChip('derivOiChg', 'Δ —'); + setChip('derivFunding', '费率 —'); + setChip('derivBasis', '基差 —'); + var src = document.getElementById('derivSrc'); + if (src) src.textContent = ''; + } + + function renderSentimentEmpty() { + setChip('derivTaker', '买卖比 —'); + setChip('derivLs', '多空 —'); + setChip('derivTop', '大户 —'); + } + + function setVisible(on) { + var bar = document.getElementById('derivBar'); + var wrap = document.getElementById('showSentimentWrap'); + if (bar) bar.style.display = on ? '' : 'none'; + if (wrap) wrap.style.display = on ? '' : 'none'; + if (!on) clearAllSeries(); + } + + function activeKlines() { + var data = (typeof currentData !== 'undefined') ? currentData : null; + if (!data) return []; + if ($('#subSubPeriodKline').is(':checked') && data.sub_sub_kline_data) return data.sub_sub_kline_data; + if ($('#elementPeriodKline').is(':checked') && data.element_kline_data) return data.element_kline_data; + return data.kline_data || []; + } + + function klineRangeMs() { + var rows = activeKlines(); + if (!rows.length) return null; + var start = new Date(rows[0].date).getTime(); + var end = new Date(rows[rows.length - 1].date).getTime(); + if (!isFinite(start) || !isFinite(end)) return null; + return { start: start, end: end }; + } + + function toPoints(rows, field, mul) { + var out = []; + var lastT = null; + var factor = mul || 1; + (rows || []).forEach(function (row) { + var ms = Number(row.timestamp); + var val = Number(row[field]); + if (!isFinite(ms) || !isFinite(val)) return; + var t = Math.floor(ms / 1000); + var v = val * factor; + if (lastT === t) { + out[out.length - 1].value = v; + return; + } + lastT = t; + out.push({ time: t, value: v }); + }); + return out; + } + + function candleTimesSec() { + var rows = activeKlines(); + var times = []; + (rows || []).forEach(function (k) { + var t = Math.floor(new Date(k.date).getTime() / 1000); + if (isFinite(t)) times.push(t); + }); + return times; + } + + function alignToTimes(src, times) { + if (!src || !src.length || !times || !times.length) return []; + var out = []; + var j = 0; + var lastVal; + for (var i = 0; i < times.length; i++) { + var t = times[i]; + while (j < src.length && src[j].time <= t) { + lastVal = src[j].value; + j++; + } + if (lastVal !== undefined) out.push({ time: t, value: lastVal }); + } + return out; + } + + function syncSentimentTime() { + if (!tvWidget || !tvWidget.mainChart || !tvWidget.sentimentChart) return; + try { + var vr = tvWidget.mainChart.timeScale().getVisibleRange(); + var lr = tvWidget.mainChart.timeScale().getVisibleLogicalRange(); + var opts = tvWidget.mainChart.timeScale().options ? tvWidget.mainChart.timeScale().options() : null; + if (opts) { + tvWidget.sentimentChart.timeScale().applyOptions({ + barSpacing: opts.barSpacing, + rightOffset: opts.rightOffset + }); + } + if (vr) tvWidget.sentimentChart.timeScale().setVisibleRange(vr); + if (lr) tvWidget.sentimentChart.timeScale().setVisibleLogicalRange(lr); + } catch (e) {} + } + + function targetChart(spec) { + if (!tvWidget) return null; + if (spec.target === 'main') return tvWidget.mainChart; + return tvWidget.sentimentChart || null; + } + + function safeRemove(chart, seriesKey) { + var series = tvWidget && tvWidget.series && tvWidget.series[seriesKey]; + if (series && chart) { + try { chart.removeSeries(series); } catch (e) {} + } + if (tvWidget && tvWidget.series) tvWidget.series[seriesKey] = null; + } + + function clearAllSeries() { + METRICS.forEach(function (spec) { + safeRemove(targetChart(spec), spec.seriesKey); + }); + safeRemove(tvWidget && tvWidget.sentimentChart, 'sentimentBaseSeries'); + try { + if (tvWidget && tvWidget.mainChart) { + tvWidget.mainChart.applyOptions({ leftPriceScale: { visible: false } }); + } + } catch (e) {} + } + + function drawSeries(spec, points) { + var chart = targetChart(spec); + if (!chart || !points || !points.length) return; + safeRemove(chart, spec.seriesKey); + if (spec.target === 'main') { + chart.applyOptions({ + leftPriceScale: { + visible: true, + borderVisible: false, + scaleMargins: { top: 0.08, bottom: 0.12 } + } + }); + } + var opts = { + color: spec.color, + lineWidth: 1, + title: spec.title, + lastValueVisible: true, + priceLineVisible: false, + priceFormat: spec.priceFormat + }; + if (spec.target === 'main') opts.priceScaleId = 'left'; + if (spec.scale) { + opts.priceScaleId = spec.scale; + chart.priceScale(spec.scale).applyOptions({ + scaleMargins: { top: 0.15, bottom: 0.1 }, + borderVisible: false + }); + } + var series = chart.addLineSeries(opts); + series.setData(points); + tvWidget.series[spec.seriesKey] = series; + if (spec.target === 'sentiment') syncSentimentTime(); + if (spec.target === 'main' && typeof window._redrawFxBoxVerticalOverlay === 'function') { + window._redrawFxBoxVerticalOverlay(); + setTimeout(window._redrawFxBoxVerticalOverlay, 50); + } + } + + function drawRatioBaseline(points) { + var chart = tvWidget && tvWidget.sentimentChart; + if (!chart || !points || !points.length) return; + safeRemove(chart, 'sentimentBaseSeries'); + var baseline = points.map(function (p) { return { time: p.time, value: 1 }; }); + var series = chart.addLineSeries({ + color: 'rgba(120, 120, 120, 0.45)', + lineWidth: 1, + lineStyle: 2, + lastValueVisible: false, + priceLineVisible: false, + title: '1.0' + }); + series.setData(baseline); + tvWidget.series.sentimentBaseSeries = series; + } + + function loadOne(spec, range, symbol, req) { + var checked = $('#' + spec.checkbox).is(':checked'); + var chart = targetChart(spec); + if (!checked) { + safeRemove(chart, spec.seriesKey); + if (spec.id === 'oi') { + try { + if (tvWidget && tvWidget.mainChart) { + tvWidget.mainChart.applyOptions({ leftPriceScale: { visible: false } }); + } + } catch (e) {} + if (typeof window._redrawFxBoxVerticalOverlay === 'function') { + window._redrawFxBoxVerticalOverlay(); + } + } + return; + } + if (!chart) return; + var times = candleTimesSec(); + var key = [symbol, spec.metric, times[0] || '', times[times.length - 1] || '', times.length].join(':'); + var cached = caches[spec.id]; + function paint(raw) { + var aligned = spec.target === 'main' || spec.target === 'sentiment' ? alignToTimes(raw, times) : raw; + if (!aligned.length) aligned = raw; + drawSeries(spec, aligned); + if (spec.target === 'sentiment') maybeDrawBaseline(); + } + if (cached && cached.raw && cached.raw.length) { + paint(cached.raw); + if (cached.key === key) return; + } + if (!range || !window.ChanApi || !ChanApi.sentimentMetrics) return; + ChanApi.sentimentMetrics({ + metric: spec.metric, + symbol: symbol, + start: range.start - 8 * 60 * 60 * 1000, + end: range.end, + limit: 2000 + }).then(function (payload) { + if (req !== overlayReq) return; + var raw = toPoints(payload && payload.data, spec.field, spec.mul); + if (!raw.length) return; + caches[spec.id] = { key: key, raw: raw }; + if ($('#' + spec.checkbox).is(':checked') && targetChart(spec)) paint(raw); + }).catch(function (err) { + if (req !== overlayReq) return; + console.warn(spec.title + ' 序列不可用', err); + }); + } + + function maybeDrawBaseline() { + var times = candleTimesSec(); + if (!times.length) return; + drawRatioBaseline(times.map(function (t) { return { time: t, value: 1 }; })); + syncSentimentTime(); + } + + function loadOverlays() { + if (!isCrypto()) { + clearAllSeries(); + return; + } + if (!tvWidget || !tvWidget.mainChart) return; + var range = klineRangeMs(); + var symbol = currentSymbol(); + var req = ++overlayReq; + METRICS.forEach(function (spec) { + loadOne(spec, range, symbol, req); + }); + maybeDrawBaseline(); + } + + function loadSnapshot() { + if (!isCrypto() || !window.ChanApi || !ChanApi.derivatives) return; + var req = ++snapshotReq; + ChanApi.derivatives({ symbol: currentSymbol() }).then(function (row) { + if (req !== snapshotReq) return; + var chg = Number(row.oi_change_pct); + var fund = Number(row.funding_rate); + var basis = Number(row.basis); + setChip('derivOi', 'OI ' + fmtOi(row.open_interest)); + setChip('derivOiChg', 'Δ ' + fmtChg(row.oi_change_pct), tone(chg)); + setChip('derivFunding', '费率 ' + fmtFunding(row.funding_rate), tone(fund)); + setChip('derivBasis', '基差 ' + fmtBasis(row.basis), tone(basis)); + var src = document.getElementById('derivSrc'); + if (src) src.textContent = row.exchange || ''; + }).catch(function (err) { + if (req !== snapshotReq) return; + console.warn('资金面快照不可用', err); + renderDerivEmpty(); + var src = document.getElementById('derivSrc'); + if (src) src.textContent = '不可用'; + }); + } + + function loadLatest() { + if (!isCrypto() || !window.ChanApi || !ChanApi.sentimentLatest) return; + var req = ++latestReq; + ChanApi.sentimentLatest({ symbol: currentSymbol() }).then(function (payload) { + if (req !== latestReq) return; + var data = (payload && payload.data) || {}; + var taker = data.taker_buy_sell_ratio || {}; + var ls = data.long_short_account_ratio || {}; + var top = data.top_long_short_position_ratio || {}; + var takerN = Number(taker.buy_sell_ratio); + var lsN = Number(ls.long_short_ratio); + var topN = Number(top.long_short_ratio); + setChip('derivTaker', '买卖比 ' + fmtRatio(taker.buy_sell_ratio), isFinite(takerN) ? (takerN >= 1 ? 'up' : 'down') : null); + setChip('derivLs', '多空 ' + fmtRatio(ls.long_short_ratio), isFinite(lsN) ? (lsN >= 1 ? 'up' : 'down') : null); + setChip('derivTop', '大户 ' + fmtRatio(top.long_short_ratio), isFinite(topN) ? (topN >= 1 ? 'up' : 'down') : null); + }).catch(function (err) { + if (req !== latestReq) return; + console.warn('情绪快照不可用', err); + renderSentimentEmpty(); + }); + } + + function sync(opts) { + opts = opts || {}; + var show = isCrypto(); + setVisible(show); + if (!show) return; + loadSnapshot(); + loadLatest(); + if (opts.overlay !== false) loadOverlays(); + } + + return { + sync: sync, + setVisible: setVisible, + loadOiOverlay: loadOverlays, + loadOverlays: loadOverlays + }; +})(); diff --git a/web/static/js/app/macd_ui.js b/web/static/js/app/macd_ui.js index 16b1f91..2432152 100644 --- a/web/static/js/app/macd_ui.js +++ b/web/static/js/app/macd_ui.js @@ -151,7 +151,7 @@ $('#elementTimeframe').change(function() { // 检查选择的元素时间周期是否小于等于主周期 if (compareTimeframes(elementTimeframe, mainTimeframe) > 0) { - alert('元素时间周期必须小于或等于主图表时间周期。'); + alert('次必须小于或等于主。'); setSmallerOrEqualTimeframe(); // 重置为最大的小于等于时间周期 return; } @@ -164,7 +164,7 @@ $('#subSubTimeframe').change(function() { const subSub = $(this).val(); const elementTf = $('#elementTimeframe').val(); if (compareTimeframes(subSub, elementTf) > 0) { - alert('次次周期必须小于或等于次周期。'); + alert('次次必须小于或等于次。'); ensureSubSubLteElement(); return; } diff --git a/web/static/js/app/main.js b/web/static/js/app/main.js index 93da1dd..a0d0883 100644 --- a/web/static/js/app/main.js +++ b/web/static/js/app/main.js @@ -43,6 +43,67 @@ function clearChanMacdMarkers() { } // 清空全局UnitTF标记,避免旧数据残留影响主图合并 window.unittfMarkers = []; + window.unittfMarkersMain = []; + window.unittfMarkersElement = []; + window.unittfMarkersSubSub = []; +} + +function unittfDirSign(dir) { + if (dir === 'ABOVE' || dir === 1 || dir === '1' || dir === true) return 1; + if (dir === 'UNDER' || dir === -1 || dir === '-1') return -1; + const n = Number(dir); + if (n > 0) return 1; + if (n < 0) return -1; + return 0; +} + +function buildUnittfOverlayMarkers(unittfList, opt) { + const markers = []; + if (!Array.isArray(unittfList) || !opt) return markers; + const up = opt.up, down = opt.down, prefix = opt.prefix || 'U'; + const size = opt.size || 0.5; + for (let i = 0; i < unittfList.length; i++) { + const unittf = unittfList[i]; + if (!unittf || !unittf.start_time || unittf.invalid) continue; + const startTime = Math.floor(new Date(unittf.start_time).getTime() / 1000); + if (isNaN(startTime)) continue; + const sign = unittfDirSign(unittf.dir); + const color = sign > 0 ? up : down; + const pos = sign > 0 ? 'aboveBar' : 'belowBar'; + markers.push({ time: startTime, position: pos, color: color, shape: 'circle', text: prefix + i, size: size }); + if (unittf.end_time) { + const endTime = Math.floor(new Date(unittf.end_time).getTime() / 1000); + if (!isNaN(endTime)) { + markers.push({ time: endTime, position: pos, color: color, shape: 'circle', text: prefix + i + 'E', size: size }); + } + } + } + for (let i = 0; i + 1 < unittfList.length; i++) { + const cur = unittfList[i]; + const nxt = unittfList[i + 1]; + if (!cur || !nxt || !cur.end_time || !nxt.start_time) continue; + const tEnd = new Date(cur.end_time).getTime(); + const tStart = new Date(nxt.start_time).getTime(); + if (!isNaN(tEnd) && tEnd === tStart) { + const sign = unittfDirSign(nxt.dir); + markers.push({ + time: Math.floor(tEnd / 1000), + position: sign > 0 ? 'aboveBar' : 'belowBar', + color: sign > 0 ? (opt.boundaryUp || up) : (opt.boundaryDown || down), + shape: 'square', + text: prefix + '↔', + size: 0.6 + }); + } + } + return markers; +} + +function refreshUnittfOverlayFromData(data) { + // U/穿零轴只画在 MACD 副图,不再铺到主图 + window.unittfMarkersMain = []; + window.unittfMarkersElement = []; + window.unittfMarkersSubSub = []; } // 添加所有ChanMACD标记 function addAllChanMacdMarkers(segList, unittfList, histsetList, stateMarkers) { @@ -215,8 +276,7 @@ function addAllChanMacdMarkers(segList, unittfList, histsetList, stateMarkers) { // 保存到全局,供主图与分型一起统一合并绘制(仅在开关开启时) console.log('DEBUG: U 标记数量:', signalMarkers.length); - const allowUMerge = (window.showUOnMain && window.showUOnElement); - window.unittfMarkers = allowUMerge ? [...signalMarkers, ...boundaryMarkers] : []; + window.unittfMarkers = [...signalMarkers, ...boundaryMarkers]; if (uTooltipMarkers.length > 0) { if (window.fxMarkers) { window.fxMarkers = [ ...window.fxMarkers, ...uTooltipMarkers ]; diff --git a/web/static/js/app/ui.js b/web/static/js/app/ui.js index cb4bf0b..8cb4c02 100644 --- a/web/static/js/app/ui.js +++ b/web/static/js/app/ui.js @@ -26,10 +26,10 @@ function loadSymbols() { }); } -// 设置默认时间范围:最近 1 个月 +// 设置默认时间范围:现在倒退 1 周 function setDefaultTimeRange() { const now = new Date(); - const daysBack = 30; + const daysBack = 7; const start = new Date(now.getTime() - (daysBack * 24 * 60 * 60 * 1000)); // 格式化为datetime-local输入框所需的格式 YYYY-MM-DDThh:mm @@ -72,17 +72,20 @@ $(document).ready(function() { window.astockStatusInterval = null; } loadSymbols(); + if (window.ChanDeriv) ChanDeriv.setVisible(true); } else if (dataSource === 'a_stock') { $('#cryptoSymbolContainer').hide(); $('#astockSymbolContainer').show(); loadAStockSymbols(); startAStockStatusUpdater(); + if (window.ChanDeriv) ChanDeriv.setVisible(false); } }); }); // 检查初始数据源设置 const initialDataSource = $('#dataSource').val(); + if (window.ChanDeriv) ChanDeriv.setVisible(initialDataSource !== 'a_stock'); if (initialDataSource === 'a_stock') { $.getJSON('/api/chart_metadata', { source: 'a_stock' }) .done(function(meta) { @@ -404,6 +407,7 @@ function mapTimeframeToInterval(timeframe) { '5m': '5', '15m': '15', '30m': '30', + '45m': '45', '1h': '60', '2h': '120', '4h': '240', @@ -547,6 +551,7 @@ function refreshChart(data, options) { if (currentData && currentData.ema52_dict) { updateEMA52Display(currentData); } + if (window.ChanDeriv) ChanDeriv.sync({ overlay: false }); return; } catch (e) { console.warn('增量刷新失败,回退全量重建:', e); @@ -584,6 +589,7 @@ function refreshChart(data, options) { if (currentData && currentData.ema52_dict) { updateEMA52Display(currentData); } + if (window.ChanDeriv) ChanDeriv.sync({ overlay: true }); } @@ -597,8 +603,8 @@ function refreshChartOnly() { } } -// 绑定主/次/次次周期笔背驰、线段背驰、笔面积、线段面积显示开关 -$('#showMainMacdDiv, #showMainSegMacdDiv, #showElementMacdDiv, #showElementSegMacdDiv, #showSubSubMacdDiv, #showSubSubSegMacdDiv, #showMainBiArea, #showMainSegArea, #showElementBiArea, #showElementSegArea, #showSubSubBiArea, #showSubSubSegArea').change(function() { +// 绑定主/次/次次周期笔面积、线段面积显示开关 +$('#showMainBiArea, #showMainSegArea, #showElementBiArea, #showElementSegArea, #showSubSubBiArea, #showSubSubSegArea').change(function() { updateChartDisplay(); }); @@ -637,6 +643,12 @@ $('#toggleUOnElement').change(function() { }); // 买卖点显示开关 +$('#showOi').change(function() { + if (window.ChanDeriv) ChanDeriv.loadOverlays(); +}); +$('#showDeriv').change(function() { + updateChartDisplay(); +}); $('#showMainBsp').change(function() { updateChartDisplay(); }); @@ -644,11 +656,6 @@ $('#showElementBsp').change(function() { updateChartDisplay(); }); -// 第四类买卖点(B4/S4)显示开关与过滤模式 -$('#showMainFastBsp, #showElementFastBsp, #showSubSubFastBsp, #fastBspFilterMode').change(function() { - updateChartDisplay(); -}); - // 在控制台输出当前显示状态 console.log('当前显示状态:', { 'showOriginalKline': $('#showOriginalKline').is(':checked'), diff --git a/web/templates/index.html b/web/templates/index.html index 326c0b8..8544bcc 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -858,6 +858,38 @@ color: #999; margin-top: 2px; } + .deriv-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 0 0 8px; + padding: 6px 10px; + background: #f8f9fa; + border: 1px solid #e9ecef; + border-radius: 6px; + font-size: 13px; + } + .deriv-bar .deriv-label { + font-weight: 600; + color: #495057; + margin-right: 4px; + } + .deriv-bar .deriv-chip { + font-variant-numeric: tabular-nums; + color: #343a40; + padding: 1px 6px; + border-radius: 4px; + background: #fff; + border: 1px solid #e9ecef; + } + .deriv-bar .deriv-chip.up { color: #198754; } + .deriv-bar .deriv-chip.down { color: #dc3545; } + .deriv-bar .deriv-src { + color: #868e96; + font-size: 12px; + margin-left: auto; + } @@ -935,21 +967,31 @@
- +
- +
- +
- +
+ +
+ + +
+
+ + +
+
{% for value, label in timeframes.items() %} @@ -998,11 +1040,11 @@
- +
- +
@@ -1010,17 +1052,9 @@
- +
-
- - -
-
- - -
@@ -1033,20 +1067,9 @@
-
- - -
-
- -
- +
-
- - -
-
- - -
@@ -1102,13 +1117,9 @@
-
- - -
- +
-
- - -
-
- - -
@@ -1164,16 +1167,23 @@
-
- - -
+
+ 资金面 + OI — + Δ — + 费率 — + 基差 — + 买卖比 — + 多空 — + 大户 — + +
@@ -1326,22 +1336,23 @@
- + + - + - - - - + + + + - - - - - - + + + + + + @@ -1527,7 +1538,7 @@
-
ChanMACD 参数设置
+
MACD 参数设置
diff --git a/web/tests/test_deriv_proxy.py b/web/tests/test_deriv_proxy.py new file mode 100644 index 0000000..2e0cf18 --- /dev/null +++ b/web/tests/test_deriv_proxy.py @@ -0,0 +1,97 @@ +"""资金面中转:Web 只打 data_provider,字段原样回给前端。""" +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +WEB_ROOT = Path(__file__).resolve().parents[1] +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(WEB_ROOT)) + + +SNAPSHOT = { + "exchange": "bitget", + "symbol": "BTC/USDT:USDT", + "timestamp": 1789039236740, + "datetime": "2026-09-10T11:20:36.740000Z", + "funding_rate": 0.0001, + "open_interest": 36207.21, + "oi_change_pct": -0.01, + "basis": -0.03, +} + +OI_HIST = { + "metric": "open_interest_history", + "symbol": "BTC/USDT:USDT", + "period": "15m", + "count": 1, + "data": [ + { + "timestamp": 1789038900000, + "datetime": "2026-09-10T11:15:00Z", + "open_interest_amount": 106162.005, + "open_interest_value": 8269553076.678, + } + ], +} + + +@pytest.fixture +def client(): + from app import create_app + + app = create_app() + app.config["TESTING"] = True + return app.test_client() + + +def test_derivatives_proxy_passthrough(client): + with patch("api.provider.fetch_derivatives", return_value=SNAPSHOT) as mock_fetch: + resp = client.get("/api/derivatives?symbol=BTC/USDT:USDT") + assert resp.status_code == 200 + body = resp.get_json() + assert body["funding_rate"] == 0.0001 + assert body["open_interest"] == 36207.21 + assert body["oi_change_pct"] == -0.01 + assert body["basis"] == -0.03 + mock_fetch.assert_called_once() + assert mock_fetch.call_args[0][0] == "BTC/USDT:USDT" + + +def test_sentiment_metrics_proxy_passthrough(client): + with patch("api.provider.fetch_sentiment_metrics", return_value=OI_HIST) as mock_fetch: + resp = client.get( + "/api/sentiment/metrics?metric=open_interest_history&symbol=BTC/USDT:USDT&limit=1" + ) + assert resp.status_code == 200 + body = resp.get_json() + assert body["metric"] == "open_interest_history" + assert body["data"][0]["open_interest_amount"] == 106162.005 + mock_fetch.assert_called_once() + assert mock_fetch.call_args[0][0] == "open_interest_history" + + +def test_sentiment_metrics_requires_metric(client): + resp = client.get("/api/sentiment/metrics?symbol=BTC/USDT:USDT") + assert resp.status_code == 400 + + +def test_sentiment_latest_proxy_passthrough(client): + latest = { + "symbol": "BTC/USDT:USDT", + "data": { + "taker_buy_sell_ratio": {"buy_sell_ratio": 1.36}, + "long_short_account_ratio": {"long_short_ratio": 1.5}, + "top_long_short_position_ratio": {"long_short_ratio": 2.26}, + }, + } + with patch("api.provider.fetch_sentiment_latest", return_value=latest) as mock_fetch: + resp = client.get("/api/sentiment/latest?symbol=BTC/USDT:USDT") + assert resp.status_code == 200 + body = resp.get_json() + assert body["data"]["taker_buy_sell_ratio"]["buy_sell_ratio"] == 1.36 + mock_fetch.assert_called_once()