From 32f1fa2c669bed3caa675b0b05729578401ab32b Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 25 Jun 2026 12:33:49 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20TradingView=20K=E7=BA=BF=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=93=81=E7=A7=8D=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E4=B8=BA=E7=A9=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 行情页改用 Lightweight Charts 标准蜡烛图(红跌绿涨) - 修复 fee_rates 缺 source 列导致推荐刷新失败 - 空缓存自动重试,持仓页实时兜底计算推荐列表 Co-authored-by: Cursor --- fee_specs.py | 21 ++ install_trading.py | 30 +- kline_chart.py | 37 +- product_recommend.py | 45 ++- recommend_store.py | 37 +- recommend_stream.py | 7 +- static/js/market.js | 802 +++++++++++++++++------------------------- templates/market.html | 6 +- 8 files changed, 458 insertions(+), 527 deletions(-) diff --git a/fee_specs.py b/fee_specs.py index a67db0c..2853c34 100644 --- a/fee_specs.py +++ b/fee_specs.py @@ -37,6 +37,26 @@ def _get_db(): return connect_db() +def ensure_fee_rates_schema(conn=None) -> None: + """补齐 fee_rates 表结构(旧库可能缺少 source 列)。""" + close = False + if conn is None: + conn = _get_db() + close = True + try: + for sql in ( + "ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'", + ): + try: + conn.execute(sql) + except sqlite3.OperationalError: + pass + conn.commit() + finally: + if close: + conn.close() + + def get_setting(key: str, default: str = "") -> str: conn = _get_db() row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() @@ -111,6 +131,7 @@ def get_fee_spec(ths_code: str, *, trading_mode: str = "simulation") -> dict: mult = get_contract_spec(ths_code)["mult"] conn = _get_db() + ensure_fee_rates_schema(conn) row = conn.execute( "SELECT * FROM fee_rates WHERE product=? AND source='ctp'", (product,), diff --git a/install_trading.py b/install_trading.py index 44c7997..85394c4 100644 --- a/install_trading.py +++ b/install_trading.py @@ -22,9 +22,9 @@ from position_sizing import ( ) from recommend_store import ( load_recommend_cache, + recommend_cache_needs_refresh, recommend_payload, refresh_recommend_cache, - rows_missing_max_lots, ) from recommend_stream import recommend_hub, start_recommend_worker from ctp_reconnect import start_ctp_reconnect_worker @@ -407,11 +407,31 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se sizing = get_sizing_mode(get_setting) max_pct = get_max_margin_pct(get_setting) rec_loaded = load_recommend_cache(conn) - if rec_loaded.get("stale") or rows_missing_max_lots(rec_loaded.get("rows") or []): - refresh_recommend_cache( - conn, capital, _main_quote, trading_mode=mode, max_margin_pct=max_pct, - ) + if recommend_cache_needs_refresh(rec_loaded, capital=capital): + try: + refresh_recommend_cache( + conn, capital, _main_quote, trading_mode=mode, max_margin_pct=max_pct, + ) + except Exception as exc: + logger.warning("positions recommend refresh failed: %s", exc) rec_cache = recommend_payload(conn, live_capital=capital, max_margin_pct=max_pct) + if not rec_cache.get("rows") and capital > 0: + try: + from product_recommend import list_product_recommendations + from recommend_store import enrich_recommend_rows, filter_affordable_recommendations + + live_rows = filter_affordable_recommendations( + list_product_recommendations( + capital, _main_quote, max_margin_pct=max_pct, trading_mode=mode, + ) + ) + if live_rows: + rec_cache["rows"] = enrich_recommend_rows( + live_rows, capital, max_margin_pct=max_pct, + ) + rec_cache["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + except Exception as exc: + logger.warning("positions recommend live fallback failed: %s", exc) return render_template( "trade.html", trading_mode=mode, diff --git a/kline_chart.py b/kline_chart.py index 0a6952a..c5b7be8 100644 --- a/kline_chart.py +++ b/kline_chart.py @@ -220,20 +220,39 @@ def _timeshare_session(bars: list) -> list: def bars_to_api(bars: list) -> list[dict]: - """转为前端图表 JSON。""" - result = [] + """转为前端图表 JSON(去重、排序、数值规范化)。""" + result: list[dict] = [] + seen: dict[int, dict] = {} for bar in bars: dt = _bar_datetime(bar) ts = int(dt.timestamp() * 1000) if dt else None - result.append({ + try: + o = float(bar.get("o") or 0) + h = float(bar.get("h") or o) + l = float(bar.get("l") or o) + c = float(bar.get("c") or o) + v = float(bar.get("v") or 0) + except (TypeError, ValueError): + continue + if h < l: + h, l = l, h + h = max(h, o, c) + l = min(l, o, c) + row = { "time": bar["d"], "timestamp": ts, - "open": bar["o"], - "high": bar["h"], - "low": bar["l"], - "close": bar["c"], - "volume": bar.get("v", 0), - }) + "open": o, + "high": h, + "low": l, + "close": c, + "volume": v, + } + if ts is not None: + seen[ts] = row + else: + result.append(row) + if seen: + result = [seen[k] for k in sorted(seen.keys())] return result diff --git a/product_recommend.py b/product_recommend.py index c54f344..b6c6a6e 100644 --- a/product_recommend.py +++ b/product_recommend.py @@ -1,6 +1,7 @@ """按账户资金推荐可交易品种(期货核心筛选)。""" from __future__ import annotations +import logging import math from concurrent.futures import ThreadPoolExecutor from typing import Callable, Optional @@ -9,6 +10,8 @@ from contract_specs import get_contract_spec from fee_specs import calc_fee_breakdown from symbols import PRODUCTS +logger = logging.getLogger(__name__) + def _letters_from_ths(ths_code: str) -> str: import re @@ -61,9 +64,13 @@ def assess_product_for_capital( ref_sl = round(p - stop_dist, 4) ref_tp = round(p + stop_dist * reward_risk_ratio, 4) fee_ths = ths + "8888" - fee_info = calc_fee_breakdown( - fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode, - ) + try: + fee_info = calc_fee_breakdown( + fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode, + ) + except Exception as exc: + logger.debug("recommend fee calc failed %s: %s", ths, exc) + fee_info = {"open_fee": 0.0, "total_fee": 0.0} can_margin = max_lots >= 1 can_risk = cap > 0 and risk_one_lot <= cap * 0.01 @@ -109,16 +116,28 @@ def list_product_recommendations( def _one(product: dict) -> dict: ths = product["ths"] - quote = quote_fn(ths) or {} - price = quote.get("price") - row = assess_product_for_capital( - product, capital, price, - max_margin_pct=max_margin_pct, - trading_mode=trading_mode, - ) - main_code = (quote.get("ths_code") or "").strip() - row["main_code"] = main_code - return row + try: + quote = quote_fn(ths) or {} + price = quote.get("price") + row = assess_product_for_capital( + product, capital, price, + max_margin_pct=max_margin_pct, + trading_mode=trading_mode, + ) + main_code = (quote.get("ths_code") or "").strip() + row["main_code"] = main_code + return row + except Exception as exc: + logger.warning("recommend product failed %s: %s", ths, exc) + return { + "ths": ths, + "name": product.get("name") or ths, + "exchange": product.get("exchange") or "", + "status": "no_price", + "status_label": "计算失败", + "main_code": "", + "max_lots": 0, + } with ThreadPoolExecutor(max_workers=10) as pool: rows = list(pool.map(_one, PRODUCTS)) diff --git a/recommend_store.py b/recommend_store.py index e8a5bb5..0455cae 100644 --- a/recommend_store.py +++ b/recommend_store.py @@ -2,12 +2,16 @@ from __future__ import annotations import json +import logging import math from datetime import datetime from typing import Callable, Optional +from fee_specs import ensure_fee_rates_schema from product_recommend import list_product_recommendations +logger = logging.getLogger(__name__) + RECOMMEND_CACHE_SQL = """ CREATE TABLE IF NOT EXISTS product_recommend_cache ( id INTEGER PRIMARY KEY CHECK (id = 1), @@ -34,6 +38,22 @@ def rows_missing_max_lots(rows: list[dict]) -> bool: return any("max_lots" not in r for r in rows) +def recommend_cache_needs_refresh( + cached: dict, + *, + capital: float = 0.0, +) -> bool: + """是否需要重新拉行情计算推荐列表。""" + if recommend_cache_stale(cached.get("updated_at")): + return True + rows = cached.get("rows") or [] + if rows_missing_max_lots(rows): + return True + if float(capital or 0) > 0 and not rows: + return True + return False + + def enrich_recommend_rows( rows: list[dict], capital: float, @@ -81,10 +101,19 @@ def refresh_recommend_cache( ) -> list[dict]: """后台拉行情、筛选并写入数据库。""" ensure_recommend_tables(conn) + ensure_fee_rates_schema(conn) all_rows = list_product_recommendations( capital, quote_fn, max_margin_pct=max_margin_pct, trading_mode=trading_mode, ) rows = filter_affordable_recommendations(all_rows) + if not rows and float(capital or 0) > 0: + logger.warning( + "recommend refresh: 0 affordable rows capital=%.2f total=%d no_price=%d blocked=%d", + float(capital or 0), + len(all_rows), + sum(1 for r in all_rows if r.get("status") == "no_price"), + sum(1 for r in all_rows if r.get("status") == "blocked"), + ) now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") conn.execute( """INSERT INTO product_recommend_cache (id, capital, rows_json, updated_at) @@ -142,9 +171,7 @@ def recommend_payload( pct = max(1.0, min(100.0, float(max_margin_pct or 30.0))) payload["capital"] = cap payload["max_margin_pct"] = pct - payload["rows"] = enrich_recommend_rows( - payload.get("rows") or [], - cap, - max_margin_pct=pct, - ) + rows = payload.get("rows") or [] + payload["rows"] = enrich_recommend_rows(rows, cap, max_margin_pct=pct) + payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap) return payload diff --git a/recommend_stream.py b/recommend_stream.py index db6bd62..bb4c56f 100644 --- a/recommend_stream.py +++ b/recommend_stream.py @@ -12,10 +12,9 @@ from db_conn import connect_db from kline_stream import sse_format from recommend_store import ( load_recommend_cache, - recommend_cache_stale, + recommend_cache_needs_refresh, recommend_payload, refresh_recommend_cache, - rows_missing_max_lots, ) logger = logging.getLogger(__name__) @@ -78,9 +77,7 @@ def start_recommend_worker( mode = get_mode_fn() if get_mode_fn else "simulation" max_pct = float(get_max_margin_pct_fn()) if get_max_margin_pct_fn else 30.0 cached = load_recommend_cache(conn) - if recommend_cache_stale(cached.get("updated_at")) or rows_missing_max_lots( - cached.get("rows") or [], - ): + if recommend_cache_needs_refresh(cached, capital=capital): refresh_recommend_cache( conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct, ) diff --git a/static/js/market.js b/static/js/market.js index 6635280..58d2fa8 100644 --- a/static/js/market.js +++ b/static/js/market.js @@ -3,14 +3,36 @@ var emptyEl = document.getElementById('market-chart-empty'); var wrapEl = document.getElementById('market-chart-wrap'); var chart = null; + var candleSeries = null; + var volumeSeries = null; + var areaSeries = null; + var ma21Series = null; + var ma55Series = null; + var prevCloseLine = null; + var resizeObs = null; var currentPeriod = '15m'; + var currentChartMode = ''; var klineSource = null; var streamActive = false; var reconnectTimer = null; var lastData = null; var lastPrevClose = null; var chartOpts = { prevClose: false, ma: false, gapDay: false }; - var dataZoomBound = false; + var followingLatest = true; + var DEFAULT_VISIBLE_BARS = 80; + + var PERIOD_SECONDS = { + timeshare: 60, + '1m': 60, + '2m': 120, + '5m': 300, + '15m': 900, + '1h': 3600, + '2h': 7200, + '4h': 14400, + d: 86400, + w: 604800, + }; function getSymbol() { var hidden = document.getElementById('market-symbol-hidden'); @@ -31,17 +53,14 @@ function themeColors() { var dark = document.documentElement.getAttribute('data-theme') !== 'light'; return { - bg: dark ? '#0a0c14' : '#f4f7fc', + bg: dark ? '#0a0c14' : '#ffffff', text: dark ? '#a8b0c8' : '#5c6578', - title: dark ? '#e8eaf6' : '#1a2233', - grid: dark ? '#1a2038' : '#e2e8f0', - up: dark ? '#4cd97f' : '#15803d', - down: dark ? '#ff6b7a' : '#dc2626', - line: dark ? '#4cc2ff' : '#2563eb', - area: dark ? 'rgba(76,194,255,0.12)' : 'rgba(37,99,235,0.1)', - slider: dark ? '#1e2640' : '#cbd5e1', - sliderFill: dark ? '#4cc2ff' : '#2563eb', - ma21: dark ? '#ffb347' : '#d97706', + grid: dark ? '#1e2640' : '#e8edf5', + up: dark ? '#26a69a' : '#089981', + down: dark ? '#ef5350' : '#f23645', + line: dark ? '#4cc2ff' : '#2962ff', + areaTop: dark ? 'rgba(76,194,255,0.28)' : 'rgba(41,98,255,0.22)', + ma21: dark ? '#ffb347' : '#f7931a', ma55: dark ? '#c084fc' : '#7c3aed', prevClose: dark ? '#fbbf24' : '#b45309', }; @@ -63,484 +82,272 @@ return false; } - function calcMA(period, closes) { - var result = []; - for (var i = 0; i < closes.length; i++) { - if (i < period - 1) { - result.push(null); - continue; + function barUnixTime(bar) { + if (bar.timestamp) return Math.floor(bar.timestamp / 1000); + if (bar.time) { + var d = new Date(String(bar.time).replace(' ', 'T')); + if (!isNaN(d.getTime())) return Math.floor(d.getTime() / 1000); + } + return null; + } + + function prepareBars(bars, periodKey) { + var out = []; + var gapDay = chartOpts.gapDay; + var seen = {}; + var gapBase = null; + var step = PERIOD_SECONDS[periodKey] || 60; + for (var i = 0; i < bars.length; i++) { + var b = bars[i]; + var o = Number(b.open); + var h = Number(b.high); + var l = Number(b.low); + var c = Number(b.close); + if (!isFinite(o) || !isFinite(c)) continue; + if (!isFinite(h)) h = Math.max(o, c); + if (!isFinite(l)) l = Math.min(o, c); + h = Math.max(h, o, c); + l = Math.min(l, o, c); + var t; + if (gapDay) { + if (gapBase == null) { + gapBase = b.timestamp ? Math.floor(b.timestamp / 1000) : 946684800; + } + t = gapBase + out.length * step; + } else { + t = barUnixTime(b); } + if (t == null || seen[t]) continue; + seen[t] = true; + out.push({ + time: t, + open: o, + high: h, + low: l, + close: c, + volume: Number(b.volume) || 0, + rawTime: b.time, + }); + } + return out; + } + + function calcMA(period, bars) { + var result = []; + for (var i = 0; i < bars.length; i++) { + if (i < period - 1) continue; var sum = 0; - for (var j = 0; j < period; j++) sum += closes[i - j]; - result.push(+(sum / period).toFixed(4)); + for (var j = 0; j < period; j++) sum += bars[i - j].close; + result.push({ time: bars[i].time, value: +(sum / period).toFixed(4) }); } return result; } - function findMaCrosses(ma21, ma55) { - var points = []; - for (var i = 1; i < ma21.length; i++) { - if (ma21[i] == null || ma55[i] == null || ma21[i - 1] == null || ma55[i - 1] == null) continue; - var prev = ma21[i - 1] - ma55[i - 1]; - var curr = ma21[i] - ma55[i]; - if (prev <= 0 && curr > 0) points.push({ i: i, type: 'golden' }); - if (prev >= 0 && curr < 0) points.push({ i: i, type: 'death' }); + function destroyChart() { + if (resizeObs) { + resizeObs.disconnect(); + resizeObs = null; } - return points; - } - - function getDefaultZoomStart() { - return currentPeriod === 'timeshare' ? 60 : 75; - } - - function getZoomRange() { - if (!chart) return { start: getDefaultZoomStart(), end: 100 }; - var opt = chart.getOption(); - if (opt && opt.dataZoom && opt.dataZoom.length) { - var z = opt.dataZoom[0]; - if (z.start != null && z.end != null) return { start: z.start, end: z.end }; + if (chart) { + chart.remove(); + chart = null; } - return { start: getDefaultZoomStart(), end: 100 }; + candleSeries = null; + volumeSeries = null; + areaSeries = null; + ma21Series = null; + ma55Series = null; + prevCloseLine = null; + currentChartMode = ''; } - function visibleIndices(bars, zoom) { - var n = bars.length; - if (!n) return { start: 0, end: 0 }; - var start = Math.floor(n * zoom.start / 100); - var end = Math.min(n - 1, Math.ceil(n * zoom.end / 100) - 1); - if (end < start) end = start; - return { start: start, end: end }; - } - - function computeVisibleHL(bars, startIdx, endIdx) { - var maxH = -Infinity; - var minL = Infinity; - var maxI = startIdx; - var minI = startIdx; - for (var i = startIdx; i <= endIdx; i++) { - var b = bars[i]; - if (!b) continue; - if (b.high > maxH) { maxH = b.high; maxI = i; } - if (b.low < minL) { minL = b.low; minI = i; } - } - if (maxH === -Infinity) return null; - return { maxH: maxH, minL: minL, maxI: maxI, minI: minI }; - } - - function buildHLMarkPoint(hl, c) { - if (!hl) return { data: [] }; - return { - symbol: 'circle', - symbolSize: 6, - data: [ - { - name: '高', - xAxis: hl.maxI, - yAxis: hl.maxH, - value: hl.maxH.toFixed(2), - itemStyle: { color: c.up }, - label: { - show: true, - formatter: '高 ' + hl.maxH.toFixed(2), - color: c.up, - fontSize: 11, - position: 'top', - }, - }, - { - name: '低', - xAxis: hl.minI, - yAxis: hl.minL, - value: hl.minL.toFixed(2), - itemStyle: { color: c.down }, - label: { - show: true, - formatter: '低 ' + hl.minL.toFixed(2), - color: c.down, - fontSize: 11, - position: 'bottom', - }, - }, - ], - }; - } - - function buildMaCrossMarkPoint(crosses, bars, c) { - if (!crosses.length) return { data: [] }; - return { - symbol: 'pin', - symbolSize: 28, - data: crosses.map(function (p) { - var b = bars[p.i]; - return { - name: p.type === 'golden' ? '金叉' : '死叉', - xAxis: p.i, - yAxis: b ? b.close : 0, - itemStyle: { color: p.type === 'golden' ? c.up : c.down }, - label: { - show: true, - formatter: p.type === 'golden' ? '金叉' : '死叉', - fontSize: 10, - color: p.type === 'golden' ? c.up : c.down, - }, - }; - }), - }; - } - - function buildPrevCloseMarkLine(prevClose, c) { - if (prevClose == null) return { data: [] }; - var v = Number(prevClose); - return { - silent: true, - symbol: 'none', - lineStyle: { color: c.prevClose, type: 'dashed', width: 1 }, - label: { - show: true, - formatter: '昨收 ' + v.toFixed(2), - color: c.prevClose, - fontSize: 10, - }, - data: [{ yAxis: v }], - }; - } - - function initChart() { - if (!chartEl || !window.echarts) return; - chart = echarts.init(chartEl); - window.addEventListener('resize', function () { - if (chart) chart.resize(); - }); - document.addEventListener('click', function (e) { - if (e.target.closest('[data-theme-pick]')) { - setTimeout(function () { - if (chart && lastData) renderChart(lastData, true); - }, 100); - } - }); - } - - function bindDataZoomHL() { - if (!chart || dataZoomBound) return; - dataZoomBound = true; - chart.on('dataZoom', function () { - updateVisibleHLMark(); - }); - } - - function updateVisibleHLMark() { - if (!chart || !lastData || !lastData.bars) return; - var bars = lastData.bars; - var zoom = getZoomRange(); - var idx = visibleIndices(bars, zoom); - var hl = computeVisibleHL(bars, idx.start, idx.end); + function buildChart(mode) { + destroyChart(); + if (!chartEl || !window.LightweightCharts) return; var c = themeColors(); - chart.setOption({ - series: [{ id: 'main', markPoint: buildHLMarkPoint(hl, c) }], - }); - } - - function getDataZoom(c, preserve) { - var defStart = getDefaultZoomStart(); - var xZoom = { - type: 'inside', - id: 'dzInsideX', - xAxisIndex: [0, 1], - start: defStart, - end: 100, - filterMode: 'none', - zoomOnMouseWheel: true, - moveOnMouseMove: true, - moveOnMouseWheel: false, - preventDefaultMouseMove: true, - minSpan: 2, - }; - var yZoom = { - type: 'inside', - id: 'dzInsideY', - yAxisIndex: [0], - orient: 'vertical', - filterMode: 'none', - zoomOnMouseWheel: true, - moveOnMouseMove: true, - preventDefaultMouseMove: true, - }; - var slider = { - type: 'slider', - id: 'dzSlider', - xAxisIndex: [0, 1], - start: defStart, - end: 100, - height: 22, - bottom: 4, - borderColor: c.grid, - backgroundColor: c.bg, - fillerColor: c.area, - handleStyle: { color: c.sliderFill }, - dataBackground: { - lineStyle: { color: c.grid, opacity: 0.35 }, - areaStyle: { color: c.area }, + var w = chartEl.clientWidth || 600; + var h = chartEl.clientHeight || 400; + chart = LightweightCharts.createChart(chartEl, { + width: w, + height: h, + layout: { + background: { type: 'solid', color: c.bg }, + textColor: c.text, + fontSize: 11, }, - textStyle: { color: c.text, fontSize: 10 }, - filterMode: 'none', - brushSelect: false, - }; - var zoom = [xZoom, yZoom, slider]; - if (preserve && chart) { - var opt = chart.getOption(); - if (opt && opt.dataZoom) { - opt.dataZoom.forEach(function (z) { - if (!z.id) return; - var target = zoom.find(function (t) { return t.id === z.id; }); - if (target && z.start != null && z.end != null) { - target.start = z.start; - target.end = z.end; - } + grid: { + vertLines: { color: c.grid, style: 1 }, + horzLines: { color: c.grid, style: 1 }, + }, + crosshair: { + mode: LightweightCharts.CrosshairMode.Normal, + vertLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid }, + horzLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid }, + }, + rightPriceScale: { + borderColor: c.grid, + scaleMargins: mode === 'line' ? { top: 0.08, bottom: 0.08 } : { top: 0.05, bottom: 0.22 }, + }, + timeScale: { + borderColor: c.grid, + timeVisible: true, + secondsVisible: false, + rightOffset: 8, + barSpacing: 10, + minBarSpacing: 4, + fixLeftEdge: false, + fixRightEdge: false, + }, + handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true }, + handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true }, + localization: { locale: 'zh-CN' }, + }); + + if (mode === 'line') { + areaSeries = chart.addAreaSeries({ + lineColor: c.line, + topColor: c.areaTop, + bottomColor: 'rgba(0,0,0,0)', + lineWidth: 2, + priceLineVisible: false, + lastValueVisible: true, + }); + } else { + candleSeries = chart.addCandlestickSeries({ + upColor: c.up, + downColor: c.down, + borderVisible: true, + borderUpColor: c.up, + borderDownColor: c.down, + wickUpColor: c.up, + wickDownColor: c.down, + priceLineVisible: false, + lastValueVisible: true, + }); + volumeSeries = chart.addHistogramSeries({ + priceFormat: { type: 'volume' }, + priceScaleId: 'volume', + lastValueVisible: false, + priceLineVisible: false, + }); + chart.priceScale('volume').applyOptions({ + scaleMargins: { top: 0.82, bottom: 0 }, + borderVisible: false, + }); + if (chartOpts.ma) { + ma21Series = chart.addLineSeries({ + color: c.ma21, + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: false, + crosshairMarkerVisible: false, + }); + ma55Series = chart.addLineSeries({ + color: c.ma55, + lineWidth: 1, + priceLineVisible: false, + lastValueVisible: false, + crosshairMarkerVisible: false, }); } } - return zoom; + + chart.timeScale().subscribeVisibleLogicalRangeChange(function () { + if (!chart) return; + var range = chart.timeScale().getVisibleLogicalRange(); + if (!range || !lastData || !lastData.preparedBars) return; + var total = lastData.preparedBars.length; + followingLatest = range.to >= total - 2; + }); + + resizeObs = new ResizeObserver(function () { + if (!chart || !chartEl) return; + chart.applyOptions({ width: chartEl.clientWidth, height: chartEl.clientHeight }); + }); + resizeObs.observe(chartEl); + currentChartMode = mode; } - function isFollowingLatest() { - var z = getZoomRange(); - return z.end >= 98; - } - - function mapSeriesData(bars, values, gapDay) { - if (!gapDay) return values; - return bars.map(function (b, i) { - var v = values[i]; - if (v == null || b.timestamp == null) return v; - return [b.timestamp, v]; + function applyPrevCloseLine(price) { + if (!candleSeries || currentChartMode !== 'candle') return; + if (prevCloseLine) { + candleSeries.removePriceLine(prevCloseLine); + prevCloseLine = null; + } + if (!chartOpts.prevClose || price == null || !isFinite(Number(price))) return; + var c = themeColors(); + prevCloseLine = candleSeries.createPriceLine({ + price: Number(price), + color: c.prevClose, + lineWidth: 1, + lineStyle: LightweightCharts.LineStyle.Dashed, + axisLabelVisible: true, + title: '昨收', }); } - function renderChart(data, preserveZoom) { - if (!chart) return; + function setVisibleRange(prepared, preserve) { + if (!chart || !prepared.length) return; + var ts = chart.timeScale(); + if (preserve && followingLatest) { + var span = DEFAULT_VISIBLE_BARS; + try { + var cur = ts.getVisibleLogicalRange(); + if (cur) span = Math.max(20, cur.to - cur.from); + } catch (e) { /* ignore */ } + ts.setVisibleLogicalRange({ + from: Math.max(0, prepared.length - span), + to: prepared.length + 4, + }); + return; + } + if (preserve) return; + var show = Math.min(DEFAULT_VISIBLE_BARS, prepared.length); + ts.setVisibleLogicalRange({ + from: Math.max(0, prepared.length - show), + to: prepared.length + 4, + }); + } + + function renderChart(data, preserveRange) { + if (!chartEl || !window.LightweightCharts) return; lastData = data; if (data.prev_close != null) lastPrevClose = data.prev_close; - var c = themeColors(); - var bars = data.bars || []; - var times = bars.map(function (b) { return b.time; }); var isLine = data.chart_type === 'line' || data.period === 'timeshare'; - var gapDay = chartOpts.gapDay; - var followLatest = preserveZoom && isFollowingLatest(); - var dataZoom = getDataZoom(c, preserveZoom); - var zoom = preserveZoom ? getZoomRange() : { start: dataZoom[0].start, end: dataZoom[0].end }; - var vIdx = visibleIndices(bars, zoom); - var hl = computeVisibleHL(bars, vIdx.start, vIdx.end); - var closes = bars.map(function (b) { return b.close; }); - var ma21 = chartOpts.ma ? calcMA(21, closes) : null; - var ma55 = chartOpts.ma ? calcMA(55, closes) : null; - var crosses = chartOpts.ma ? findMaCrosses(ma21, ma55) : []; - var prevCloseVal = lastPrevClose != null ? lastPrevClose : data.prev_close; - var showPrev = chartOpts.prevClose && prevCloseVal != null; + var mode = isLine ? 'line' : 'candle'; + if (!chart || currentChartMode !== mode) buildChart(mode); + if (!chart) return; - var grids = [ - { left: 56, right: 24, top: 44, height: '50%' }, - { left: 56, right: 24, top: '66%', height: '14%' }, - ]; + var prepared = prepareBars(data.bars || [], data.period || currentPeriod); + data.preparedBars = prepared; + if (!prepared.length) return; - var xAxisType = gapDay ? 'time' : 'category'; - var xAxis0 = { - type: xAxisType, - gridIndex: 0, - boundaryGap: gapDay ? false : true, - axisLabel: { color: c.text, fontSize: 10 }, - axisLine: { lineStyle: { color: c.grid } }, - splitLine: { show: false }, - }; - var xAxis1 = { - type: xAxisType, - gridIndex: 1, - boundaryGap: gapDay ? false : true, - axisLabel: { show: false }, - axisLine: { lineStyle: { color: c.grid } }, - splitLine: { show: false }, - }; - if (!gapDay) { - xAxis0.data = times; - xAxis1.data = times; - } - - var base = { - backgroundColor: c.bg, - animation: false, - tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } }, - axisPointer: { link: [{ xAxisIndex: 'all' }] }, - grid: grids, - xAxis: [xAxis0, xAxis1], - yAxis: [ - { - scale: true, - gridIndex: 0, - splitLine: { show: false }, - axisLabel: { color: c.text }, - }, - { - scale: true, - gridIndex: 1, - splitLine: { show: false }, - axisLabel: { color: c.text, fontSize: 10 }, - splitNumber: 2, - }, - ], - }; - if (!preserveZoom) { - base.dataZoom = dataZoom; - } - - var series = []; - var mainMark = { - markPoint: buildHLMarkPoint(hl, c), - }; - if (showPrev) mainMark.markLine = buildPrevCloseMarkLine(prevCloseVal, c); - if (chartOpts.ma && crosses.length) { - var crossMp = buildMaCrossMarkPoint(crosses, bars, c); - mainMark.markPoint.data = mainMark.markPoint.data.concat(crossMp.data); - } - - if (isLine) { - var lineData = mapSeriesData(bars, closes, gapDay); - series.push({ - id: 'main', - name: '价格', - type: 'line', - data: lineData, - smooth: true, - showSymbol: false, - lineStyle: { width: 1.5, color: c.line }, - areaStyle: { color: c.area }, - xAxisIndex: 0, - yAxisIndex: 0, - markPoint: mainMark.markPoint, - markLine: mainMark.markLine, - }); + if (mode === 'line') { + areaSeries.setData(prepared.map(function (b) { + return { time: b.time, value: b.close }; + })); } else { - var candle = bars.map(function (b) { - if (gapDay && b.timestamp) { - return [b.timestamp, b.open, b.close, b.low, b.high]; - } - return [b.open, b.close, b.low, b.high]; - }); - series.push({ - id: 'main', - name: 'K线', - type: 'candlestick', - data: candle, - barMaxWidth: 14, - barMinWidth: 3, - itemStyle: { - color: c.up, - color0: c.down, - borderColor: c.up, - borderColor0: c.down, - }, - xAxisIndex: 0, - yAxisIndex: 0, - markPoint: mainMark.markPoint, - markLine: mainMark.markLine, - }); - } - - if (chartOpts.ma && ma21 && ma55) { - series.push({ - id: 'ma21', - name: 'MA21', - type: 'line', - data: mapSeriesData(bars, ma21, gapDay), - smooth: true, - showSymbol: false, - lineStyle: { width: 1, color: c.ma21 }, - xAxisIndex: 0, - yAxisIndex: 0, - }); - series.push({ - id: 'ma55', - name: 'MA55', - type: 'line', - data: mapSeriesData(bars, ma55, gapDay), - smooth: true, - showSymbol: false, - lineStyle: { width: 1, color: c.ma55 }, - xAxisIndex: 0, - yAxisIndex: 0, - }); - } - - var vols = bars.map(function (b) { - var up = b.close >= b.open; - var val = { - value: b.volume, - itemStyle: { color: up ? c.up : c.down, opacity: 0.65 }, - }; - if (gapDay && b.timestamp) { - return { value: [b.timestamp, b.volume], itemStyle: val.itemStyle }; + candleSeries.setData(prepared.map(function (b) { + return { time: b.time, open: b.open, high: b.high, low: b.low, close: b.close }; + })); + volumeSeries.setData(prepared.map(function (b) { + var up = b.close >= b.open; + var c = themeColors(); + return { + time: b.time, + value: b.volume, + color: up ? c.up : c.down, + }; + })); + if (chartOpts.ma && ma21Series && ma55Series) { + ma21Series.setData(calcMA(21, prepared)); + ma55Series.setData(calcMA(55, prepared)); } - return val; - }); - series.push({ - id: 'volume', - name: '成交量', - type: 'bar', - data: vols, - xAxisIndex: 1, - yAxisIndex: 1, - }); - - if (!isLine) { - base.tooltip = { - trigger: 'axis', - axisPointer: { type: 'cross' }, - formatter: function (params) { - var idx = params[0] && params[0].dataIndex; - if (idx == null || !bars[idx]) return ''; - var b = bars[idx]; - var lines = [b.time, '开 ' + b.open, '高 ' + b.high, '低 ' + b.low, '收 ' + b.close, '量 ' + b.volume]; - if (ma21 && ma21[idx] != null) lines.push('MA21 ' + ma21[idx]); - if (ma55 && ma55[idx] != null) lines.push('MA55 ' + ma55[idx]); - return lines.join('
'); - }, - }; + applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close); } - if (preserveZoom) { - chart.setOption(Object.assign(base, { series: series }), false); - } else { - chart.setOption(Object.assign(base, { series: series }), true); - dataZoomBound = false; - } - - var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period); - chart.setOption({ - title: { text: title, left: 12, top: 8, textStyle: { color: c.title, fontSize: 13, fontWeight: 600 } }, - legend: chartOpts.ma ? { - data: ['MA21', 'MA55'], - top: 8, - right: 12, - textStyle: { color: c.text, fontSize: 10 }, - } : { show: false }, - }); - - if (followLatest) { - var span = zoom.end - zoom.start; - chart.dispatchAction({ - type: 'dataZoom', - dataZoomIndex: 0, - start: Math.max(0, 100 - span), - end: 100, - }); - chart.dispatchAction({ - type: 'dataZoom', - dataZoomIndex: 2, - start: Math.max(0, 100 - span), - end: 100, - }); - } - - bindDataZoomHL(); + setVisibleRange(prepared, !!preserveRange); } function periodLabel(key) { @@ -603,9 +410,9 @@ src = ' · ' + klineSourceLabel(lastData.source); } if (isTradingSession()) { - el.textContent = '交易中 · 后台刷新 · SSE 推送(约1秒)' + src; + el.textContent = 'TradingView 图表 · 交易中 SSE 推送' + src; } else { - el.textContent = 'SSE 推送 · 非交易时段低频刷新' + src; + el.textContent = 'TradingView 图表 · 非交易时段低频刷新' + src; } } @@ -632,7 +439,9 @@ if (data.prev_close != null) { lastPrevClose = data.prev_close; updatePrevCloseDisplay(data.prev_close); - if (chartOpts.prevClose && lastData) renderChart(lastData, true); + if (chartOpts.prevClose && lastData) { + applyPrevCloseLine(data.prev_close); + } } } @@ -674,6 +483,7 @@ klineSource = new EventSource('/api/kline/stream?' + q); streamActive = true; + followingLatest = true; updateRefreshHint(false); klineSource.addEventListener('kline', function (e) { @@ -725,28 +535,22 @@ function shiftDataZoom(delta) { if (!chart) return; - var opt = chart.getOption(); - if (!opt || !opt.dataZoom || !opt.dataZoom.length) return; - var z = opt.dataZoom[0]; - var span = (z.end - z.start) || 20; - var newSpan = Math.max(5, Math.min(100, span + delta)); - var center = (z.start + z.end) / 2; - var start = Math.max(0, center - newSpan / 2); - var end = Math.min(100, center + newSpan / 2); - if (end - start < newSpan) { - if (start === 0) end = newSpan; - else start = end - newSpan; - } - chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: end }); - chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: end }); + var ts = chart.timeScale(); + var range = ts.getVisibleLogicalRange(); + if (!range) return; + var span = range.to - range.from; + var newSpan = Math.max(15, span + delta); + var center = (range.from + range.to) / 2; + ts.setVisibleLogicalRange({ + from: center - newSpan / 2, + to: center + newSpan / 2, + }); } function resetDataZoom() { - if (!chart) return; - var start = getDefaultZoomStart(); - chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: 100 }); - chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: 100 }); - chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 0, end: 100 }); + if (!chart || !lastData || !lastData.preparedBars) return; + followingLatest = true; + setVisibleRange(lastData.preparedBars, false); } function bindPeriodTabs() { @@ -758,6 +562,7 @@ tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); }); btn.classList.add('active'); currentPeriod = btn.getAttribute('data-period') || '15m'; + followingLatest = true; if (getSymbol()) loadKline(true); }); } @@ -766,8 +571,8 @@ var zoomIn = document.getElementById('chart-zoom-in'); var zoomOut = document.getElementById('chart-zoom-out'); var zoomReset = document.getElementById('chart-zoom-reset'); - if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-12); }); - if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(12); }); + if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-20); }); + if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(20); }); if (zoomReset) zoomReset.addEventListener('click', resetDataZoom); } @@ -778,29 +583,47 @@ if (prevCb) { prevCb.addEventListener('change', function () { chartOpts.prevClose = prevCb.checked; - if (lastData) renderChart(lastData, true); + if (lastData) { + applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : lastData.prev_close); + } }); } if (maCb) { maCb.addEventListener('change', function () { chartOpts.ma = maCb.checked; - if (lastData) renderChart(lastData, true); + if (lastData) { + destroyChart(); + renderChart(lastData, false); + } }); } if (gapCb) { gapCb.addEventListener('change', function () { chartOpts.gapDay = gapCb.checked; + followingLatest = true; if (lastData) renderChart(lastData, false); }); } } document.addEventListener('DOMContentLoaded', function () { - initChart(); + if (!window.LightweightCharts) { + if (emptyEl) emptyEl.textContent = '图表库加载失败,请刷新页面'; + return; + } bindPeriodTabs(); bindZoomButtons(); bindChartOptions(); + document.addEventListener('click', function (e) { + if (e.target.closest('[data-theme-pick]') && lastData) { + setTimeout(function () { + destroyChart(); + renderChart(lastData, false); + }, 80); + } + }); + var active = document.querySelector('.period-tab.active'); if (active) currentPeriod = active.getAttribute('data-period') || '15m'; @@ -812,6 +635,8 @@ if (input) { input.addEventListener('symbol-selected', function () { lastPrevClose = null; + lastData = null; + destroyChart(); updatePrevCloseDisplay(null); loadKline(true); }); @@ -823,6 +648,9 @@ updateRefreshHint(false); } - window.addEventListener('beforeunload', stopKlineStream); + window.addEventListener('beforeunload', function () { + stopKlineStream(); + destroyChart(); + }); }); })(); diff --git a/templates/market.html b/templates/market.html index a9cd180..dfc43ee 100644 --- a/templates/market.html +++ b/templates/market.html @@ -45,7 +45,7 @@
请选择合约并点击「查看」
连接中…
-

数据来源:{% if ctp_connected %}报价来自 CTP;K 线历史由新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时 K 线与报价回退新浪{% endif %}。拖拽左右平移、滚轮缩放;按住图表上下拖动可平移价格轴。可视区内自动标注最高/最低价。

+

图表引擎:TradingView Lightweight Charts(红跌绿涨)。数据来源:{% if ctp_connected %}报价 CTP;K 线历史新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时回退新浪{% endif %}。滚轮缩放、拖拽平移;勾选「间隔日」可压缩夜盘空白。