From 404872007f68f236743c7042fe9a113fb75ed56e Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 15 Jun 2026 18:23:39 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=8C=E6=83=85K=E7=BA=BF=EF=BC=9A=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E4=B8=BB=E5=8A=9B=E9=80=89=E6=8B=A9=E3=80=81=E5=9B=BE?= =?UTF-8?q?=E8=A1=A8=E6=8C=87=E6=A0=87=E4=B8=8E=E5=B8=83=E5=B1=80=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app.py | 15 +- kline_chart.py | 8 +- market.py | 18 +- static/js/market.js | 486 ++++++++++++++++++++++++++++++++++-------- static/js/symbol.js | 107 +++++++++- symbols.py | 66 ++++++ templates/base.html | 13 ++ templates/market.html | 45 +++- 8 files changed, 644 insertions(+), 114 deletions(-) diff --git a/app.py b/app.py index f6dc65c..8cb94ad 100644 --- a/app.py +++ b/app.py @@ -17,7 +17,7 @@ from flask import ( ) from werkzeug.security import check_password_hash, generate_password_hash -from symbols import search_symbols, ths_to_codes +from symbols import search_symbols, ths_to_codes, list_main_contracts_grouped from contract_specs import calc_position_metrics from fee_specs import ( calc_fee_breakdown, @@ -381,10 +381,17 @@ def build_market_quote_payload( codes = ths_to_codes(symbol) if codes: name = codes.get("name", symbol) + prev_close = None + if sina_code: + from market import fetch_raw_for_volume + raw = fetch_raw_for_volume(sina_code) + if raw and raw.get("prev_close") is not None: + prev_close = raw["prev_close"] return { "symbol": symbol, "name": name, "price": price, + "prev_close": prev_close, } @@ -639,6 +646,12 @@ def api_symbol_search(): return jsonify(search_symbols(q)) +@app.route("/api/symbols/mains") +@login_required +def api_symbols_mains(): + return jsonify(list_main_contracts_grouped()) + + @app.route("/api/key_prices") @login_required def api_key_prices(): diff --git a/kline_chart.py b/kline_chart.py index 245f080..6d6f237 100644 --- a/kline_chart.py +++ b/kline_chart.py @@ -274,13 +274,19 @@ def fetch_market_klines( except Exception as exc: logger.warning("kline cache fallback failed %s %s: %s", chart_sym, p, exc) + api_bars = bars_to_api(bars) + prev_close = None + if len(api_bars) >= 2: + prev_close = api_bars[-2]["close"] + return { "symbol": symbol, "chart_symbol": chart_sym, "period": p, "chart_type": chart_type, "count": len(bars), - "bars": bars_to_api(bars), + "bars": api_bars, + "prev_close": prev_close, "source": source, "cached_at": cached_at, } diff --git a/market.py b/market.py index a753e7d..2874af8 100644 --- a/market.py +++ b/market.py @@ -66,7 +66,23 @@ def _fetch_sina_raw(sina_code: str) -> Optional[dict]: return None price = float(parts[8]) volume = float(parts[14]) if len(parts) > 14 and parts[14] else 0 - return {"name": parts[0], "price": price, "volume": volume} + prev_close = None + if len(parts) > 9 and parts[9]: + try: + prev_close = float(parts[9]) + except ValueError: + pass + if prev_close is None and len(parts) > 2 and parts[2]: + try: + prev_close = float(parts[2]) + except ValueError: + pass + return { + "name": parts[0], + "price": price, + "volume": volume, + "prev_close": prev_close, + } except Exception as exc: logger.debug("sina fetch failed %s: %s", sina_code, exc) return None diff --git a/static/js/market.js b/static/js/market.js index 95e1fe2..4f383a7 100644 --- a/static/js/market.js +++ b/static/js/market.js @@ -1,13 +1,16 @@ (function () { var chartEl = document.getElementById('market-chart'); var emptyEl = document.getElementById('market-chart-empty'); - var wrapEl = chartEl && chartEl.parentElement; + var wrapEl = document.getElementById('market-chart-wrap'); var chart = null; var currentPeriod = '15m'; 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; function getSymbol() { var hidden = document.getElementById('market-symbol-hidden'); @@ -38,6 +41,9 @@ 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', + ma55: dark ? '#c084fc' : '#7c3aed', + prevClose: dark ? '#fbbf24' : '#b45309', }; } @@ -57,6 +63,148 @@ return false; } + function calcMA(period, closes) { + var result = []; + for (var i = 0; i < closes.length; i++) { + if (i < period - 1) { + result.push(null); + continue; + } + var sum = 0; + for (var j = 0; j < period; j++) sum += closes[i - j]; + result.push(+(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' }); + } + 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 }; + } + return { start: getDefaultZoomStart(), end: 100 }; + } + + 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); @@ -72,8 +220,28 @@ }); } + 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); + var c = themeColors(); + chart.setOption({ + series: [{ id: 'main', markPoint: buildHLMarkPoint(hl, c) }], + }); + } + function getDataZoom(c, preserve) { - var defStart = currentPeriod === 'timeshare' ? 60 : 75; + var defStart = getDefaultZoomStart(); var zoom = [ { type: 'inside', @@ -116,19 +284,61 @@ return zoom; } + 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 renderChart(data, preserveZoom) { if (!chart) 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 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 grids = [ - { left: 56, right: 20, top: 44, height: '50%' }, - { left: 56, right: 20, top: '66%', height: '14%' }, + { left: 56, right: 24, top: 44, height: '50%' }, + { left: 56, right: 24, top: '66%', height: '14%' }, ]; + 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 } }, + }; + var xAxis1 = { + type: xAxisType, + gridIndex: 1, + boundaryGap: gapDay ? false : true, + axisLabel: { show: false }, + axisLine: { lineStyle: { color: c.grid } }, + }; + if (!gapDay) { + xAxis0.data = times; + xAxis1.data = times; + } + var base = { backgroundColor: c.bg, animation: false, @@ -136,98 +346,139 @@ axisPointer: { link: [{ xAxisIndex: 'all' }] }, dataZoom: dataZoom, grid: grids, - xAxis: [ - { type: 'category', data: times, gridIndex: 0, axisLabel: { color: c.text, fontSize: 10 }, axisLine: { lineStyle: { color: c.grid } } }, - { type: 'category', data: times, gridIndex: 1, axisLabel: { show: false }, axisLine: { lineStyle: { color: c.grid } } }, - ], + xAxis: [xAxis0, xAxis1], yAxis: [ { scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } }, { scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 }, ], }; - if (isLine) { - var closes = bars.map(function (b) { return b.close; }); - var vols = bars.map(function (b) { return b.volume; }); - chart.setOption(Object.assign(base, { - series: [ - { - name: '价格', - type: 'line', - data: closes, - smooth: true, - showSymbol: false, - lineStyle: { width: 1.5, color: c.line }, - areaStyle: { color: c.area }, - xAxisIndex: 0, - yAxisIndex: 0, - }, - { - name: '成交量', - type: 'bar', - data: vols, - itemStyle: { color: c.area }, - xAxisIndex: 1, - yAxisIndex: 1, - }, - ], - }), true); - } else { - var candle = bars.map(function (b) { return [b.open, b.close, b.low, b.high]; }); - var vols = bars.map(function (b) { - var up = b.close >= b.open; - return { - value: b.volume, - itemStyle: { color: up ? c.up : c.down, opacity: 0.65 }, - }; - }); - chart.setOption(Object.assign(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]; - return [ - b.time, - '开 ' + b.open, - '高 ' + b.high, - '低 ' + b.low, - '收 ' + b.close, - '量 ' + b.volume, - ].join('
'); - }, - }, - series: [ - { - name: 'K线', - type: 'candlestick', - data: candle, - itemStyle: { - color: c.up, - color0: c.down, - borderColor: c.up, - borderColor0: c.down, - }, - xAxisIndex: 0, - yAxisIndex: 0, - }, - { - name: '成交量', - type: 'bar', - data: vols, - xAxisIndex: 1, - yAxisIndex: 1, - }, - ], - }), true); + 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, + }); + } 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, + 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 }; + } + 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('
'); + }, + }; + } + + chart.setOption(Object.assign(base, { series: series }), true); + 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 }, }); + + bindDataZoomHL(); } function periodLabel(key) { @@ -239,15 +490,11 @@ } function hideEmptyOverlay() { - if (emptyEl) emptyEl.style.display = ''; if (wrapEl) wrapEl.classList.add('has-data'); } function showEmptyOverlay(text) { - if (emptyEl) { - emptyEl.textContent = text; - emptyEl.style.display = 'flex'; - } + if (emptyEl) emptyEl.textContent = text; if (wrapEl) wrapEl.classList.remove('has-data'); } @@ -257,8 +504,14 @@ btn.disabled = on; btn.textContent = on ? '连接中…' : '查看'; } - if (on) showEmptyOverlay('连接中…'); - else if (lastData) hideEmptyOverlay(); + if (!wrapEl) return; + if (on && !lastData) { + wrapEl.classList.add('loading'); + showEmptyOverlay('请选择合约并点击「查看」'); + } else { + wrapEl.classList.remove('loading'); + if (lastData) hideEmptyOverlay(); + } } function updateRefreshHint(disconnected) { @@ -284,6 +537,16 @@ } } + function updatePrevCloseDisplay(val) { + var prevEl = document.getElementById('market-quote-prev'); + if (!prevEl) return; + if (val != null && !isNaN(Number(val))) { + prevEl.textContent = '昨收 ' + Number(val).toFixed(2); + } else { + prevEl.textContent = ''; + } + } + function applyQuote(data) { var priceEl = document.getElementById('market-quote-price'); var nameEl = document.getElementById('market-quote-name'); @@ -291,6 +554,11 @@ if (priceEl) { priceEl.textContent = data.price != null ? Number(data.price).toFixed(2) : '—'; } + if (data.prev_close != null) { + lastPrevClose = data.prev_close; + updatePrevCloseDisplay(data.prev_close); + if (chartOpts.prevClose && lastData) renderChart(lastData, true); + } } function stopKlineStream() { @@ -340,6 +608,7 @@ hideEmptyOverlay(); renderChart(data, lastData !== null); updateQuoteMeta(data); + if (data.prev_close != null) updatePrevCloseDisplay(data.prev_close); updateRefreshHint(false); setLoading(false); } catch (err) { /* ignore */ } @@ -392,8 +661,7 @@ function resetDataZoom() { if (!chart) return; - var start = currentPeriod === 'timeshare' ? 60 : 75; - chart.dispatchAction({ type: 'dataZoom', start: start, end: 100 }); + chart.dispatchAction({ type: 'dataZoom', start: getDefaultZoomStart(), end: 100 }); } function bindPeriodTabs() { @@ -418,10 +686,35 @@ if (zoomReset) zoomReset.addEventListener('click', resetDataZoom); } + function bindChartOptions() { + var prevCb = document.getElementById('chart-opt-prev-close'); + var maCb = document.getElementById('chart-opt-ma'); + var gapCb = document.getElementById('chart-opt-gap-day'); + if (prevCb) { + prevCb.addEventListener('change', function () { + chartOpts.prevClose = prevCb.checked; + if (lastData) renderChart(lastData, true); + }); + } + if (maCb) { + maCb.addEventListener('change', function () { + chartOpts.ma = maCb.checked; + if (lastData) renderChart(lastData, true); + }); + } + if (gapCb) { + gapCb.addEventListener('change', function () { + chartOpts.gapDay = gapCb.checked; + if (lastData) renderChart(lastData, false); + }); + } + } + document.addEventListener('DOMContentLoaded', function () { initChart(); bindPeriodTabs(); bindZoomButtons(); + bindChartOptions(); var active = document.querySelector('.period-tab.active'); if (active) currentPeriod = active.getAttribute('data-period') || '15m'; @@ -431,6 +724,13 @@ var hidden = document.getElementById('market-symbol-hidden'); var input = document.getElementById('market-symbol-input'); + if (input) { + input.addEventListener('symbol-selected', function () { + lastPrevClose = null; + updatePrevCloseDisplay(null); + loadKline(true); + }); + } if (hidden && hidden.value) { if (input && !input.value) input.value = hidden.value; loadKline(true); diff --git a/static/js/symbol.js b/static/js/symbol.js index 3919478..0f5ecd7 100644 --- a/static/js/symbol.js +++ b/static/js/symbol.js @@ -9,6 +9,16 @@ return item.input_label || (item.name + ' ' + item.ths_code); } + function itemMatchesQuery(item, qLower) { + if (!qLower) return true; + var hay = ( + item.name + ' ' + item.ths_code + ' ' + + (item.display || '') + ' ' + (item.contract || '') + ' ' + + (item.exchange || '') + ).toLowerCase(); + return hay.indexOf(qLower) >= 0; + } + function initSymbolInput(wrapper) { const input = wrapper.querySelector('.symbol-input'); const hiddenThs = wrapper.querySelector('input[name="symbol"]'); @@ -17,9 +27,12 @@ const hiddenSina = wrapper.querySelector('input[name="sina_code"]'); const dropdown = wrapper.querySelector('.symbol-dropdown'); const selectedEl = wrapper.querySelector('.symbol-selected'); + const isMarketPicker = wrapper.classList.contains('market-symbol-wrap'); let timer = null; let abortCtrl = null; const cache = new Map(); + let mainsCache = null; + let mainsLoading = false; function hideDropdown() { dropdown.classList.remove('show'); @@ -34,6 +47,28 @@ if (hiddenSina) hiddenSina.value = item.sina_code || ''; selectedEl.textContent = formatSub(item); hideDropdown(); + if (isMarketPicker) { + input.dispatchEvent(new CustomEvent('symbol-selected', { detail: item })); + } + } + + function buildOptionEl(item) { + const div = document.createElement('div'); + div.className = 'symbol-option'; + if (item.near_expiry) { + div.classList.add('near-expiry'); + } + var label = item.display || (item.name + ' ' + item.ths_code); + if (item.near_expiry) { + label += ' 临期'; + } + div.innerHTML = label + + '
' + formatSub(item) + '
'; + div.addEventListener('mousedown', function (e) { + e.preventDefault(); + selectItem(item); + }); + return div; } function renderItems(items) { @@ -42,20 +77,57 @@ dropdown.innerHTML = '
无匹配,可输入同花顺代码如 ag2608
'; } else { items.forEach(function (item) { - const div = document.createElement('div'); - div.className = 'symbol-option'; - div.innerHTML = item.display + - '
' + formatSub(item) + '
'; - div.addEventListener('mousedown', function (e) { - e.preventDefault(); - selectItem(item); - }); - dropdown.appendChild(div); + dropdown.appendChild(buildOptionEl(item)); }); } dropdown.classList.add('show'); } + function renderGrouped(groups, filterQ) { + dropdown.innerHTML = ''; + const qLower = (filterQ || '').trim().toLowerCase(); + let any = false; + groups.forEach(function (group) { + const items = group.items.filter(function (item) { + return itemMatchesQuery(item, qLower); + }); + if (!items.length) return; + any = true; + const head = document.createElement('div'); + head.className = 'symbol-group-head'; + head.textContent = group.category; + dropdown.appendChild(head); + items.forEach(function (item) { + dropdown.appendChild(buildOptionEl(item)); + }); + }); + if (!any) { + dropdown.innerHTML = '
无匹配品种,可输入合约代码如 ag2608
'; + } + dropdown.classList.add('show'); + } + + function showMarketMains(filterQ) { + if (mainsCache) { + renderGrouped(mainsCache, filterQ); + return; + } + if (mainsLoading) return; + mainsLoading = true; + fetch('/api/symbols/mains') + .then(function (r) { return r.json(); }) + .then(function (groups) { + mainsCache = groups; + renderGrouped(groups, filterQ); + }) + .catch(function () { + hideDropdown(); + }) + .finally(function () { + mainsLoading = false; + }); + } + function search(q) { if (cache.has(q)) { renderItems(cache.get(q)); @@ -87,12 +159,20 @@ selectedEl.textContent = ''; const q = input.value.trim(); if (!q) { - hideDropdown(); + if (isMarketPicker) { + showMarketMains(''); + } else { + hideDropdown(); + } return; } clearTimeout(timer); timer = setTimeout(function () { - search(q); + if (isMarketPicker) { + showMarketMains(q); + } else { + search(q); + } }, 120); }); @@ -102,6 +182,10 @@ input.addEventListener('focus', function () { const q = input.value.trim(); + if (isMarketPicker) { + showMarketMains(q); + return; + } if (q && !hiddenThs.value) { search(q); } @@ -113,6 +197,7 @@ document.querySelectorAll('form').forEach(function (form) { if (!form.querySelector('.symbol-wrap')) return; + if (form.id === 'market-form') return; form.addEventListener('submit', function (e) { const ths = form.querySelector('input[name="symbol"]'); const market = form.querySelector('input[name="market_code"]'); diff --git a/symbols.py b/symbols.py index a8a19b8..b35d699 100644 --- a/symbols.py +++ b/symbols.py @@ -5,6 +5,7 @@ import re import threading import time +from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import date from typing import Optional @@ -64,6 +65,7 @@ PRODUCTS = [ {"name": "中证1000", "ths": "IM", "sina": "IM", "exchange": "中金所", "ex": "CFFEX"}, ] +EXCHANGE_ORDER = ["上期所", "上期能源", "大商所", "郑商所", "中金所"] _MAIN_CACHE: dict[str, tuple[float, dict]] = {} _CACHE_TTL = 300 _main_index_lock = threading.Lock() @@ -167,6 +169,47 @@ def ths_to_sina_code(ths_code: str) -> Optional[str]: return codes["sina_code"] if codes else None +def parse_contract_year_month(ths_code: str) -> Optional[tuple[int, int]]: + """从同花顺合约代码解析交割年月。""" + code = (ths_code or "").strip() + if not code or "888" in code: + return None + m4 = re.match(r"^([A-Za-z]+)(\d{4})$", code) + if m4: + digits = m4.group(2) + year = 2000 + int(digits[:2]) + month = int(digits[2:]) + if 1 <= month <= 12: + return year, month + m3 = re.match(r"^([A-Za-z]+)(\d{3})$", code) + if m3: + letters, digits = m3.group(1), m3.group(2) + month = int(digits[1:]) + if not 1 <= month <= 12: + return None + y_digit = int(digits[0]) + year = date.today().year + decade = year // 10 * 10 + candidate = decade + y_digit + if candidate < year - 1: + candidate += 10 + product = _find_product_by_letters(letters) + if product: + return candidate, month + return None + + +def is_near_expiry_main(ths_code: str) -> bool: + """主力合约交割月为当月或下月时视为临期。""" + ym = parse_contract_year_month(ths_code) + if not ym: + return False + cy, cm = ym + today = date.today() + months_ahead = (cy - today.year) * 12 + (cm - today.month) + return months_ahead <= 1 + + def _make_symbol_item(product: dict, year: int, month: int, volume: float) -> dict: ths = build_ths_code(product, year, month) name = product["name"] @@ -238,6 +281,7 @@ def _enrich_item(item: dict) -> dict: out = dict(item) if not out.get("input_label"): out["input_label"] = f"{out.get('name', '')} {out.get('ths_code', '')}".strip() + out["near_expiry"] = is_near_expiry_main(out.get("ths_code", "")) return out @@ -351,6 +395,28 @@ def search_symbols(query: str) -> list: return results +def list_main_contracts_grouped() -> list[dict]: + """按交易所分类返回全部品种主力合约(行情页下拉用)。""" + with _main_index_lock: + index = dict(_main_index) + index_ready = bool(index) + + buckets: dict[str, list] = defaultdict(list) + for p in PRODUCTS: + main = index.get(p["sina"]) + if not main and not index_ready: + main = _stub_main_contract(p) + if main: + buckets[p["exchange"]].append(_enrich_item(main)) + + groups: list[dict] = [] + for cat in EXCHANGE_ORDER: + items = buckets.get(cat) + if items: + groups.append({"category": cat, "items": items}) + return groups + + _start_warm_thread() diff --git a/templates/base.html b/templates/base.html index 498e86c..a640df9 100644 --- a/templates/base.html +++ b/templates/base.html @@ -294,6 +294,19 @@ .symbol-option{padding:.65rem 1rem;cursor:pointer;font-size:.85rem;border-bottom:1px solid var(--table-border)} .symbol-option:hover{background:var(--list-item-bg)} .symbol-option .sub{font-size:.75rem;color:var(--text-muted);margin-top:2px} + .symbol-option.near-expiry{color:#ff6b7a} + html[data-theme="light"] .symbol-option.near-expiry{color:#dc2626} + .symbol-option.near-expiry .sub{color:inherit;opacity:.85} + .near-expiry-tag{ + font-size:.68rem;padding:.1rem .35rem;border-radius:4px; + background:rgba(255,107,122,.15);color:inherit;font-weight:600; + } + html[data-theme="light"] .near-expiry-tag{background:rgba(220,38,38,.12)} + .symbol-group-head{ + padding:.4rem .85rem;font-size:.72rem;font-weight:600; + color:var(--text-muted);background:var(--card-inner); + border-bottom:1px solid var(--table-border);position:sticky;top:0; + } .symbol-selected{font-size:.75rem;color:var(--accent);margin-top:4px} .check-row{display:flex;flex-wrap:wrap;gap:1rem;margin:.75rem 0} .check-row label{display:flex;align-items:center;gap:.4rem;font-size:.85rem;color:var(--text-muted);cursor:pointer} diff --git a/templates/market.html b/templates/market.html index cfefc56..5683946 100644 --- a/templates/market.html +++ b/templates/market.html @@ -6,7 +6,7 @@

行情 K 线

- + @@ -24,9 +24,15 @@
+
+
+ + + +
@@ -34,11 +40,12 @@
-
+
请选择合约并点击「查看」
+
连接中…
-

数据来源:新浪财经。K 线由后台自动刷新并经 SSE 推送到前端;支持滚轮/拖拽缩放。

+

数据来源:新浪财经。K 线由后台自动刷新并经 SSE 推送到前端;支持滚轮/拖拽缩放。可视区内自动标注最高/最低价。

{% endblock %}