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 +
+ '