Web 加上资金面/情绪叠图,并收紧默认图面。

默认主/次/次次改为 45m/15m/5m、开始时间一周;SD/CD 按 hist 摆位置和箭头;去掉笔线段背驰与第四类勾选。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-09-12 02:25:44 +08:00
co-authored by Cursor
parent b301ad22c9
commit c22cd48f36
24 changed files with 1200 additions and 306 deletions
+7
View File
@@ -0,0 +1,7 @@
# 已 superseded
v0 改走免费交易所,由 **data_provider** 拉,chan 只从中转取。
见 [PROMOTE_deriv_relay.md](./PROMOTE_deriv_relay.md)。
CoinGlass 付费档等看完交易所效果再开,不要和本阶段混做。
+94
View File
@@ -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 线不受影响。
+73
View File
@@ -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
+2
View File
@@ -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
+3
View File
@@ -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,
+31
View File
@@ -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})
+1
View File
@@ -46,6 +46,7 @@ DEFAULT_TIMEFRAME_LABELS = OrderedDict([
("5m", "5分钟"),
("15m", "15分钟"),
("30m", "30分钟"),
("45m", "45分钟"),
("1h", "1小时"),
("2h", "2小时"),
("4h", "4小时"),
+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、次 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
+27
View File
@@ -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', {
+32 -13
View File
@@ -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) {}
+16 -16
View File
@@ -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(`当前显示的是<strong>${selectedPeriod} (${timeframe})</strong> 数据`);
@@ -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(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 数据`);
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 数据`);
} else if (useElementPeriod && data.element_kline_data && data.element_kline_data.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 数据`);
$('#dataSourceText').html(`当前显示的是<strong> (${elementTimeframe})</strong> 数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 数据`);
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 数据`);
}
});
$('#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(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 笔数据`);
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 笔数据`);
} else if (useElementPeriod && data.element_bi_list && data.element_bi_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 笔数据`);
$('#dataSourceText').html(`当前显示的是<strong> (${elementTimeframe})</strong> 笔数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 笔数据`);
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 笔数据`);
}
});
$('#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(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 线段数据`);
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 线段数据`);
} else if (useElementPeriod && data.element_seg_list && data.element_seg_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 线段数据`);
$('#dataSourceText').html(`当前显示的是<strong> (${elementTimeframe})</strong> 线段数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 线段数据`);
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 线段数据`);
}
});
$('#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(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 中枢数据`);
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 中枢数据`);
} else if (useElementPeriod && data.element_zs_list && data.element_zs_list.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 中枢数据`);
$('#dataSourceText').html(`当前显示的是<strong> (${elementTimeframe})</strong> 中枢数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 中枢数据`);
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 中枢数据`);
}
});
$('#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(`当前显示的是<strong>次次周期 (${subSubTimeframe})</strong> 买卖点数据`);
$('#dataSourceText').html(`当前显示的是<strong>次次 (${subSubTimeframe})</strong> 买卖点数据`);
} else if (useElementPeriod && data.element_trade_points && data.element_trade_points.length > 0) {
$('#dataSourceText').html(`当前显示的是<strong>小周期 (${elementTimeframe})</strong> 买卖点数据`);
$('#dataSourceText').html(`当前显示的是<strong> (${elementTimeframe})</strong> 买卖点数据`);
} else {
$('#dataSourceText').html(`当前显示的是<strong>主周期 (${mainTimeframe})</strong> 买卖点数据`);
$('#dataSourceText').html(`当前显示的是<strong>主 (${mainTimeframe})</strong> 买卖点数据`);
}
});
+1
View File
@@ -18,6 +18,7 @@ function initTradingView(symbol, timeframe) {
chartTvRenderChan(ctx);
chartTvRenderOverlays(ctx);
chartTvFinalize(ctx);
if (window.ChanDeriv) ChanDeriv.loadOverlays();
console.log('图表初始化完成');
} catch (e) {
+19 -2
View File
@@ -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) {}
});
+69 -22
View File
@@ -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 });
+1 -1
View File
@@ -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();
+32 -140
View File
@@ -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: `<div style="color: ${strengthColor}; font-weight: bold;">
周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
强度分数: ${fx.fx_strength}分<br>
强度等级: ${fx.fx_strength_level}<br>
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
@@ -2007,7 +1902,7 @@ function chartTvRenderOverlays(ctx) {
const fxMarker = {
time: timestamp,
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
强度分数: ${fx.fx_strength}分<br>
强度等级: ${fx.fx_strength_level}<br>
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
@@ -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: `<div style="color: ${strengthColor}; font-weight: bold;">
小周期${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
${fx.is_bottom ? '底分型' : '顶分型'}(合): ${fx.fx_type}<br>
强度分数: ${fx.fx_strength}分<br>
强度等级: ${fx.fx_strength_level}<br>
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
@@ -2196,7 +2091,7 @@ function chartTvRenderOverlays(ctx) {
const elementFxMarker = {
time: timestamp,
tooltip: `<div style="color: ${strengthColor}; font-weight: bold;">
小周期${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
${fx.is_bottom ? '底分型' : '顶分型'}(原): ${fx.fx_type}<br>
强度分数: ${fx.fx_strength}分<br>
强度等级: ${fx.fx_strength_level}<br>
是否强分型: ${fx.is_strong_fx ? '是' : '否'}<br>
@@ -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, '');
+95 -26
View File
@@ -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;
}
+3 -3
View File
@@ -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
};
+436
View File
@@ -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
};
})();
+2 -2
View File
@@ -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;
}
+62 -2
View File
@@ -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 ];
+16 -9
View File
@@ -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'),
+77 -66
View File
@@ -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;
}
</style>
</head>
<body>
@@ -935,21 +967,31 @@
<!-- 添加K线周期切换 -->
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="klinePeriod" id="mainPeriodKline" checked>
<label class="form-check-label" for="mainPeriodKline">周期</label>
<label class="form-check-label" for="mainPeriodKline"></label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="klinePeriod" id="elementPeriodKline">
<label class="form-check-label" for="elementPeriodKline">小周期</label>
<label class="form-check-label" for="elementPeriodKline"></label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="klinePeriod" id="subSubPeriodKline">
<label class="form-check-label" for="subSubPeriodKline">次次周期</label>
<label class="form-check-label" for="subSubPeriodKline">次次</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMacd" checked>
<label class="form-check-label" for="showMacd">ChanMACD</label>
<label class="form-check-label" for="showMacd">MACD</label>
<button class="btn btn-sm btn-outline-secondary ms-1 p-0" onclick="showMacdConfig()" style="width:22px;height:22px;line-height:1;font-size:12px;" title="MACD参数设置"></button>
</div>
<span id="showSentimentWrap" class="d-inline-flex align-items-center">
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showOi" checked>
<label class="form-check-label" for="showOi">OI</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showDeriv" checked>
<label class="form-check-label" for="showDeriv">衍生品</label>
</div>
</span>
<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;">
@@ -977,7 +1019,7 @@
</div>
</div>
<div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">周期:</label>
<label class="form-label me-0 mb-0">主:</label>
<div class="form-check form-check-inline">
<select id="timeframe" class="form-select form-select-sm me-2" style="width: 100px;">
{% for value, label in timeframes.items() %}
@@ -998,11 +1040,11 @@
<label class="form-check-label" for="showMainZs">SEG中枢</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainBiZs">
<input class="form-check-input" type="checkbox" id="showMainBiZs" checked>
<label class="form-check-label" for="showMainBiZs">BI中枢</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showKlcFxType" checked>
<input class="form-check-input" type="checkbox" id="showKlcFxType">
<label class="form-check-label" for="showKlcFxType">KLC分型</label>
</div>
<div class="form-check form-check-inline">
@@ -1010,17 +1052,9 @@
<label class="form-check-label" for="showMainTrend">Trend</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="toggleUOnMain">
<input class="form-check-input" type="checkbox" id="toggleUOnMain" checked>
<label class="form-check-label" for="toggleUOnMain">显示U</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainMacdDiv">
<label class="form-check-label" for="showMainMacdDiv">笔背驰</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainSegMacdDiv">
<label class="form-check-label" for="showMainSegMacdDiv">线段背驰</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainBiArea">
<label class="form-check-label" for="showMainBiArea">笔面积</label>
@@ -1033,20 +1067,9 @@
<input class="form-check-input" type="checkbox" id="showMainBsp">
<label class="form-check-label" for="showMainBsp">买卖点</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showMainFastBsp">
<label class="form-check-label" for="showMainFastBsp">第四类</label>
</div>
<div class="form-check form-check-inline">
<select id="fastBspFilterMode" class="form-select form-select-sm" style="width: 130px;"
title="深色为区间套(大级别分型同向)+中枢顺向推进都满足的信号,浅色为未通过过滤">
<option value="all" selected>第四类:全部</option>
<option value="filtered">第四类:仅过滤后</option>
</select>
</div>
</div>
<div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">周期:</label>
<label class="form-label me-0 mb-0">次:</label>
<div class="form-check form-check-inline">
<select id="elementTimeframe" class="form-select form-select-sm me-2" style="width: 100px;">
{% for value, label in timeframes.items() %}
@@ -1082,14 +1105,6 @@
<input class="form-check-input" type="checkbox" id="toggleUOnElement">
<label class="form-check-label" for="toggleUOnElement">显示U</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementMacdDiv">
<label class="form-check-label" for="showElementMacdDiv">笔背驰</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementSegMacdDiv">
<label class="form-check-label" for="showElementSegMacdDiv">线段背驰</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementBiArea">
<label class="form-check-label" for="showElementBiArea">笔面积</label>
@@ -1102,13 +1117,9 @@
<input class="form-check-input" type="checkbox" id="showElementBsp">
<label class="form-check-label" for="showElementBsp">买卖点</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showElementFastBsp">
<label class="form-check-label" for="showElementFastBsp">第四类</label>
</div>
</div>
<div class="d-flex align-items-center mt-1">
<label class="form-label me-0 mb-0">次次周期:</label>
<label class="form-label me-0 mb-0">次次:</label>
<div class="form-check form-check-inline">
<select id="subSubTimeframe" class="form-select form-select-sm me-2" style="width: 100px;">
{% for value, label in timeframes.items() %}
@@ -1144,14 +1155,6 @@
<input class="form-check-input" type="checkbox" id="toggleUOnSubSub">
<label class="form-check-label" for="toggleUOnSubSub">显示U</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubMacdDiv">
<label class="form-check-label" for="showSubSubMacdDiv">笔背驰</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubSegMacdDiv">
<label class="form-check-label" for="showSubSubSegMacdDiv">线段背驰</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubBiArea">
<label class="form-check-label" for="showSubSubBiArea">笔面积</label>
@@ -1164,16 +1167,23 @@
<input class="form-check-input" type="checkbox" id="showSubSubBsp">
<label class="form-check-label" for="showSubSubBsp">买卖点</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" id="showSubSubFastBsp">
<label class="form-check-label" for="showSubSubFastBsp">第四类</label>
</div>
</div>
</div>
</div>
</div>
<div id="loadingIndicator" style="display:none;"></div>
<div id="derivBar" class="deriv-bar">
<span class="deriv-label">资金面</span>
<span class="deriv-chip" id="derivOi">OI —</span>
<span class="deriv-chip" id="derivOiChg">Δ —</span>
<span class="deriv-chip" id="derivFunding">费率 —</span>
<span class="deriv-chip" id="derivBasis">基差 —</span>
<span class="deriv-chip" id="derivTaker">买卖比 —</span>
<span class="deriv-chip" id="derivLs">多空 —</span>
<span class="deriv-chip" id="derivTop">大户 —</span>
<span class="deriv-src" id="derivSrc"></span>
</div>
<div class="chart-container">
<div id="tradingview_chart"></div>
@@ -1326,22 +1336,23 @@
</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') }}?v=20260808i"></script>
<script defer src="{{ url_for('static', filename='js/app/api_client.js') }}?v=20260910c"></script>
<script defer src="{{ url_for('static', filename='js/app/deriv_ui.js') }}?v=20260911i"></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=20260910a"></script>
<script defer src="{{ url_for('static', filename='js/app/macd_ui.js') }}?v=20260911j"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_format.js') }}?v=20260810e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260810e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_lifecycle.js') }}?v=20260810e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260810e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260901e"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_view.js') }}?v=20260912a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_lifecycle.js') }}?v=20260910c"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_shell.js') }}?v=20260911l"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_indicators.js') }}?v=20260911n"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_chan.js') }}?v=20260910a"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260910d"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260810a"></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=20260901e"></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=20260910c"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_overlays.js') }}?v=20260911j"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv_finalize.js') }}?v=20260910c"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tv.js') }}?v=20260910c"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_sync.js') }}?v=20260910c"></script>
<script defer src="{{ url_for('static', filename='js/app/chart_tables.js') }}?v=20260911j"></script>
<script defer src="{{ url_for('static', filename='js/app/ui.js') }}?v=20260912a"></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=20260901d"></script>
@@ -1527,7 +1538,7 @@
<!-- MACD参数配置弹窗 -->
<div id="macdConfigModal" class="macd-config-modal">
<div class="macd-config-content">
<div class="macd-config-title">ChanMACD 参数设置</div>
<div class="macd-config-title">MACD 参数设置</div>
<div class="macd-config-form">
<div class="macd-config-group">
<label class="macd-config-label">快线周期 (Fast)</label>
+97
View File
@@ -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()