@@ -17,7 +17,7 @@ from flask import (
|
|||||||
)
|
)
|
||||||
from werkzeug.security import check_password_hash, generate_password_hash
|
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 contract_specs import calc_position_metrics
|
||||||
from fee_specs import (
|
from fee_specs import (
|
||||||
calc_fee_breakdown,
|
calc_fee_breakdown,
|
||||||
@@ -381,10 +381,17 @@ def build_market_quote_payload(
|
|||||||
codes = ths_to_codes(symbol)
|
codes = ths_to_codes(symbol)
|
||||||
if codes:
|
if codes:
|
||||||
name = codes.get("name", symbol)
|
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 {
|
return {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"name": name,
|
"name": name,
|
||||||
"price": price,
|
"price": price,
|
||||||
|
"prev_close": prev_close,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -639,6 +646,12 @@ def api_symbol_search():
|
|||||||
return jsonify(search_symbols(q))
|
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")
|
@app.route("/api/key_prices")
|
||||||
@login_required
|
@login_required
|
||||||
def api_key_prices():
|
def api_key_prices():
|
||||||
|
|||||||
+7
-1
@@ -274,13 +274,19 @@ def fetch_market_klines(
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("kline cache fallback failed %s %s: %s", chart_sym, p, 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 {
|
return {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"chart_symbol": chart_sym,
|
"chart_symbol": chart_sym,
|
||||||
"period": p,
|
"period": p,
|
||||||
"chart_type": chart_type,
|
"chart_type": chart_type,
|
||||||
"count": len(bars),
|
"count": len(bars),
|
||||||
"bars": bars_to_api(bars),
|
"bars": api_bars,
|
||||||
|
"prev_close": prev_close,
|
||||||
"source": source,
|
"source": source,
|
||||||
"cached_at": cached_at,
|
"cached_at": cached_at,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,7 +66,23 @@ def _fetch_sina_raw(sina_code: str) -> Optional[dict]:
|
|||||||
return None
|
return None
|
||||||
price = float(parts[8])
|
price = float(parts[8])
|
||||||
volume = float(parts[14]) if len(parts) > 14 and parts[14] else 0
|
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:
|
except Exception as exc:
|
||||||
logger.debug("sina fetch failed %s: %s", sina_code, exc)
|
logger.debug("sina fetch failed %s: %s", sina_code, exc)
|
||||||
return None
|
return None
|
||||||
|
|||||||
+393
-93
@@ -1,13 +1,16 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var chartEl = document.getElementById('market-chart');
|
var chartEl = document.getElementById('market-chart');
|
||||||
var emptyEl = document.getElementById('market-chart-empty');
|
var emptyEl = document.getElementById('market-chart-empty');
|
||||||
var wrapEl = chartEl && chartEl.parentElement;
|
var wrapEl = document.getElementById('market-chart-wrap');
|
||||||
var chart = null;
|
var chart = null;
|
||||||
var currentPeriod = '15m';
|
var currentPeriod = '15m';
|
||||||
var klineSource = null;
|
var klineSource = null;
|
||||||
var streamActive = false;
|
var streamActive = false;
|
||||||
var reconnectTimer = null;
|
var reconnectTimer = null;
|
||||||
var lastData = null;
|
var lastData = null;
|
||||||
|
var lastPrevClose = null;
|
||||||
|
var chartOpts = { prevClose: false, ma: false, gapDay: false };
|
||||||
|
var dataZoomBound = false;
|
||||||
|
|
||||||
function getSymbol() {
|
function getSymbol() {
|
||||||
var hidden = document.getElementById('market-symbol-hidden');
|
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)',
|
area: dark ? 'rgba(76,194,255,0.12)' : 'rgba(37,99,235,0.1)',
|
||||||
slider: dark ? '#1e2640' : '#cbd5e1',
|
slider: dark ? '#1e2640' : '#cbd5e1',
|
||||||
sliderFill: dark ? '#4cc2ff' : '#2563eb',
|
sliderFill: dark ? '#4cc2ff' : '#2563eb',
|
||||||
|
ma21: dark ? '#ffb347' : '#d97706',
|
||||||
|
ma55: dark ? '#c084fc' : '#7c3aed',
|
||||||
|
prevClose: dark ? '#fbbf24' : '#b45309',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +63,148 @@
|
|||||||
return false;
|
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() {
|
function initChart() {
|
||||||
if (!chartEl || !window.echarts) return;
|
if (!chartEl || !window.echarts) return;
|
||||||
chart = echarts.init(chartEl);
|
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) {
|
function getDataZoom(c, preserve) {
|
||||||
var defStart = currentPeriod === 'timeshare' ? 60 : 75;
|
var defStart = getDefaultZoomStart();
|
||||||
var zoom = [
|
var zoom = [
|
||||||
{
|
{
|
||||||
type: 'inside',
|
type: 'inside',
|
||||||
@@ -116,19 +284,61 @@
|
|||||||
return zoom;
|
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) {
|
function renderChart(data, preserveZoom) {
|
||||||
if (!chart) return;
|
if (!chart) return;
|
||||||
lastData = data;
|
lastData = data;
|
||||||
|
if (data.prev_close != null) lastPrevClose = data.prev_close;
|
||||||
|
|
||||||
var c = themeColors();
|
var c = themeColors();
|
||||||
var bars = data.bars || [];
|
var bars = data.bars || [];
|
||||||
var times = bars.map(function (b) { return b.time; });
|
var times = bars.map(function (b) { return b.time; });
|
||||||
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
|
var isLine = data.chart_type === 'line' || data.period === 'timeshare';
|
||||||
|
var gapDay = chartOpts.gapDay;
|
||||||
var dataZoom = getDataZoom(c, preserveZoom);
|
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 = [
|
var grids = [
|
||||||
{ left: 56, right: 20, top: 44, height: '50%' },
|
{ left: 56, right: 24, top: 44, height: '50%' },
|
||||||
{ left: 56, right: 20, top: '66%', height: '14%' },
|
{ 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 = {
|
var base = {
|
||||||
backgroundColor: c.bg,
|
backgroundColor: c.bg,
|
||||||
animation: false,
|
animation: false,
|
||||||
@@ -136,98 +346,139 @@
|
|||||||
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||||||
dataZoom: dataZoom,
|
dataZoom: dataZoom,
|
||||||
grid: grids,
|
grid: grids,
|
||||||
xAxis: [
|
xAxis: [xAxis0, xAxis1],
|
||||||
{ 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 } } },
|
|
||||||
],
|
|
||||||
yAxis: [
|
yAxis: [
|
||||||
{ scale: true, gridIndex: 0, splitLine: { lineStyle: { color: c.grid } }, axisLabel: { color: c.text } },
|
{ 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 },
|
{ scale: true, gridIndex: 1, splitLine: { show: false }, axisLabel: { color: c.text, fontSize: 10 }, splitNumber: 2 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLine) {
|
var series = [];
|
||||||
var closes = bars.map(function (b) { return b.close; });
|
var mainMark = {
|
||||||
var vols = bars.map(function (b) { return b.volume; });
|
markPoint: buildHLMarkPoint(hl, c),
|
||||||
chart.setOption(Object.assign(base, {
|
};
|
||||||
series: [
|
if (showPrev) mainMark.markLine = buildPrevCloseMarkLine(prevCloseVal, c);
|
||||||
{
|
if (chartOpts.ma && crosses.length) {
|
||||||
name: '价格',
|
var crossMp = buildMaCrossMarkPoint(crosses, bars, c);
|
||||||
type: 'line',
|
mainMark.markPoint.data = mainMark.markPoint.data.concat(crossMp.data);
|
||||||
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('<br/>');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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('<br/>');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
chart.setOption(Object.assign(base, { series: series }), true);
|
||||||
|
|
||||||
var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period);
|
var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period);
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
title: { text: title, left: 12, top: 8, textStyle: { color: c.title, fontSize: 13, fontWeight: 600 } },
|
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) {
|
function periodLabel(key) {
|
||||||
@@ -239,15 +490,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hideEmptyOverlay() {
|
function hideEmptyOverlay() {
|
||||||
if (emptyEl) emptyEl.style.display = '';
|
|
||||||
if (wrapEl) wrapEl.classList.add('has-data');
|
if (wrapEl) wrapEl.classList.add('has-data');
|
||||||
}
|
}
|
||||||
|
|
||||||
function showEmptyOverlay(text) {
|
function showEmptyOverlay(text) {
|
||||||
if (emptyEl) {
|
if (emptyEl) emptyEl.textContent = text;
|
||||||
emptyEl.textContent = text;
|
|
||||||
emptyEl.style.display = 'flex';
|
|
||||||
}
|
|
||||||
if (wrapEl) wrapEl.classList.remove('has-data');
|
if (wrapEl) wrapEl.classList.remove('has-data');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,8 +504,14 @@
|
|||||||
btn.disabled = on;
|
btn.disabled = on;
|
||||||
btn.textContent = on ? '连接中…' : '查看';
|
btn.textContent = on ? '连接中…' : '查看';
|
||||||
}
|
}
|
||||||
if (on) showEmptyOverlay('连接中…');
|
if (!wrapEl) return;
|
||||||
else if (lastData) hideEmptyOverlay();
|
if (on && !lastData) {
|
||||||
|
wrapEl.classList.add('loading');
|
||||||
|
showEmptyOverlay('请选择合约并点击「查看」');
|
||||||
|
} else {
|
||||||
|
wrapEl.classList.remove('loading');
|
||||||
|
if (lastData) hideEmptyOverlay();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRefreshHint(disconnected) {
|
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) {
|
function applyQuote(data) {
|
||||||
var priceEl = document.getElementById('market-quote-price');
|
var priceEl = document.getElementById('market-quote-price');
|
||||||
var nameEl = document.getElementById('market-quote-name');
|
var nameEl = document.getElementById('market-quote-name');
|
||||||
@@ -291,6 +554,11 @@
|
|||||||
if (priceEl) {
|
if (priceEl) {
|
||||||
priceEl.textContent = data.price != null ? Number(data.price).toFixed(2) : '—';
|
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() {
|
function stopKlineStream() {
|
||||||
@@ -340,6 +608,7 @@
|
|||||||
hideEmptyOverlay();
|
hideEmptyOverlay();
|
||||||
renderChart(data, lastData !== null);
|
renderChart(data, lastData !== null);
|
||||||
updateQuoteMeta(data);
|
updateQuoteMeta(data);
|
||||||
|
if (data.prev_close != null) updatePrevCloseDisplay(data.prev_close);
|
||||||
updateRefreshHint(false);
|
updateRefreshHint(false);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) { /* ignore */ }
|
} catch (err) { /* ignore */ }
|
||||||
@@ -392,8 +661,7 @@
|
|||||||
|
|
||||||
function resetDataZoom() {
|
function resetDataZoom() {
|
||||||
if (!chart) return;
|
if (!chart) return;
|
||||||
var start = currentPeriod === 'timeshare' ? 60 : 75;
|
chart.dispatchAction({ type: 'dataZoom', start: getDefaultZoomStart(), end: 100 });
|
||||||
chart.dispatchAction({ type: 'dataZoom', start: start, end: 100 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindPeriodTabs() {
|
function bindPeriodTabs() {
|
||||||
@@ -418,10 +686,35 @@
|
|||||||
if (zoomReset) zoomReset.addEventListener('click', resetDataZoom);
|
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 () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
initChart();
|
initChart();
|
||||||
bindPeriodTabs();
|
bindPeriodTabs();
|
||||||
bindZoomButtons();
|
bindZoomButtons();
|
||||||
|
bindChartOptions();
|
||||||
|
|
||||||
var active = document.querySelector('.period-tab.active');
|
var active = document.querySelector('.period-tab.active');
|
||||||
if (active) currentPeriod = active.getAttribute('data-period') || '15m';
|
if (active) currentPeriod = active.getAttribute('data-period') || '15m';
|
||||||
@@ -431,6 +724,13 @@
|
|||||||
|
|
||||||
var hidden = document.getElementById('market-symbol-hidden');
|
var hidden = document.getElementById('market-symbol-hidden');
|
||||||
var input = document.getElementById('market-symbol-input');
|
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 (hidden && hidden.value) {
|
||||||
if (input && !input.value) input.value = hidden.value;
|
if (input && !input.value) input.value = hidden.value;
|
||||||
loadKline(true);
|
loadKline(true);
|
||||||
|
|||||||
+96
-11
@@ -9,6 +9,16 @@
|
|||||||
return item.input_label || (item.name + ' ' + item.ths_code);
|
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) {
|
function initSymbolInput(wrapper) {
|
||||||
const input = wrapper.querySelector('.symbol-input');
|
const input = wrapper.querySelector('.symbol-input');
|
||||||
const hiddenThs = wrapper.querySelector('input[name="symbol"]');
|
const hiddenThs = wrapper.querySelector('input[name="symbol"]');
|
||||||
@@ -17,9 +27,12 @@
|
|||||||
const hiddenSina = wrapper.querySelector('input[name="sina_code"]');
|
const hiddenSina = wrapper.querySelector('input[name="sina_code"]');
|
||||||
const dropdown = wrapper.querySelector('.symbol-dropdown');
|
const dropdown = wrapper.querySelector('.symbol-dropdown');
|
||||||
const selectedEl = wrapper.querySelector('.symbol-selected');
|
const selectedEl = wrapper.querySelector('.symbol-selected');
|
||||||
|
const isMarketPicker = wrapper.classList.contains('market-symbol-wrap');
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let abortCtrl = null;
|
let abortCtrl = null;
|
||||||
const cache = new Map();
|
const cache = new Map();
|
||||||
|
let mainsCache = null;
|
||||||
|
let mainsLoading = false;
|
||||||
|
|
||||||
function hideDropdown() {
|
function hideDropdown() {
|
||||||
dropdown.classList.remove('show');
|
dropdown.classList.remove('show');
|
||||||
@@ -34,6 +47,28 @@
|
|||||||
if (hiddenSina) hiddenSina.value = item.sina_code || '';
|
if (hiddenSina) hiddenSina.value = item.sina_code || '';
|
||||||
selectedEl.textContent = formatSub(item);
|
selectedEl.textContent = formatSub(item);
|
||||||
hideDropdown();
|
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 += ' <span class="near-expiry-tag">临期</span>';
|
||||||
|
}
|
||||||
|
div.innerHTML = label +
|
||||||
|
'<div class="sub">' + formatSub(item) + '</div>';
|
||||||
|
div.addEventListener('mousedown', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectItem(item);
|
||||||
|
});
|
||||||
|
return div;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderItems(items) {
|
function renderItems(items) {
|
||||||
@@ -42,20 +77,57 @@
|
|||||||
dropdown.innerHTML = '<div class="symbol-option">无匹配,可输入同花顺代码如 ag2608</div>';
|
dropdown.innerHTML = '<div class="symbol-option">无匹配,可输入同花顺代码如 ag2608</div>';
|
||||||
} else {
|
} else {
|
||||||
items.forEach(function (item) {
|
items.forEach(function (item) {
|
||||||
const div = document.createElement('div');
|
dropdown.appendChild(buildOptionEl(item));
|
||||||
div.className = 'symbol-option';
|
|
||||||
div.innerHTML = item.display +
|
|
||||||
'<div class="sub">' + formatSub(item) + '</div>';
|
|
||||||
div.addEventListener('mousedown', function (e) {
|
|
||||||
e.preventDefault();
|
|
||||||
selectItem(item);
|
|
||||||
});
|
|
||||||
dropdown.appendChild(div);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
dropdown.classList.add('show');
|
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 = '<div class="symbol-option">无匹配品种,可输入合约代码如 ag2608</div>';
|
||||||
|
}
|
||||||
|
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) {
|
function search(q) {
|
||||||
if (cache.has(q)) {
|
if (cache.has(q)) {
|
||||||
renderItems(cache.get(q));
|
renderItems(cache.get(q));
|
||||||
@@ -87,12 +159,20 @@
|
|||||||
selectedEl.textContent = '';
|
selectedEl.textContent = '';
|
||||||
const q = input.value.trim();
|
const q = input.value.trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
hideDropdown();
|
if (isMarketPicker) {
|
||||||
|
showMarketMains('');
|
||||||
|
} else {
|
||||||
|
hideDropdown();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(function () {
|
timer = setTimeout(function () {
|
||||||
search(q);
|
if (isMarketPicker) {
|
||||||
|
showMarketMains(q);
|
||||||
|
} else {
|
||||||
|
search(q);
|
||||||
|
}
|
||||||
}, 120);
|
}, 120);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,6 +182,10 @@
|
|||||||
|
|
||||||
input.addEventListener('focus', function () {
|
input.addEventListener('focus', function () {
|
||||||
const q = input.value.trim();
|
const q = input.value.trim();
|
||||||
|
if (isMarketPicker) {
|
||||||
|
showMarketMains(q);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (q && !hiddenThs.value) {
|
if (q && !hiddenThs.value) {
|
||||||
search(q);
|
search(q);
|
||||||
}
|
}
|
||||||
@@ -113,6 +197,7 @@
|
|||||||
|
|
||||||
document.querySelectorAll('form').forEach(function (form) {
|
document.querySelectorAll('form').forEach(function (form) {
|
||||||
if (!form.querySelector('.symbol-wrap')) return;
|
if (!form.querySelector('.symbol-wrap')) return;
|
||||||
|
if (form.id === 'market-form') return;
|
||||||
form.addEventListener('submit', function (e) {
|
form.addEventListener('submit', function (e) {
|
||||||
const ths = form.querySelector('input[name="symbol"]');
|
const ths = form.querySelector('input[name="symbol"]');
|
||||||
const market = form.querySelector('input[name="market_code"]');
|
const market = form.querySelector('input[name="market_code"]');
|
||||||
|
|||||||
+66
@@ -5,6 +5,7 @@
|
|||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections import defaultdict
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -64,6 +65,7 @@ PRODUCTS = [
|
|||||||
{"name": "中证1000", "ths": "IM", "sina": "IM", "exchange": "中金所", "ex": "CFFEX"},
|
{"name": "中证1000", "ths": "IM", "sina": "IM", "exchange": "中金所", "ex": "CFFEX"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
EXCHANGE_ORDER = ["上期所", "上期能源", "大商所", "郑商所", "中金所"]
|
||||||
_MAIN_CACHE: dict[str, tuple[float, dict]] = {}
|
_MAIN_CACHE: dict[str, tuple[float, dict]] = {}
|
||||||
_CACHE_TTL = 300
|
_CACHE_TTL = 300
|
||||||
_main_index_lock = threading.Lock()
|
_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
|
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:
|
def _make_symbol_item(product: dict, year: int, month: int, volume: float) -> dict:
|
||||||
ths = build_ths_code(product, year, month)
|
ths = build_ths_code(product, year, month)
|
||||||
name = product["name"]
|
name = product["name"]
|
||||||
@@ -238,6 +281,7 @@ def _enrich_item(item: dict) -> dict:
|
|||||||
out = dict(item)
|
out = dict(item)
|
||||||
if not out.get("input_label"):
|
if not out.get("input_label"):
|
||||||
out["input_label"] = f"{out.get('name', '')} {out.get('ths_code', '')}".strip()
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -351,6 +395,28 @@ def search_symbols(query: str) -> list:
|
|||||||
return results
|
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()
|
_start_warm_thread()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -294,6 +294,19 @@
|
|||||||
.symbol-option{padding:.65rem 1rem;cursor:pointer;font-size:.85rem;border-bottom:1px solid var(--table-border)}
|
.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:hover{background:var(--list-item-bg)}
|
||||||
.symbol-option .sub{font-size:.75rem;color:var(--text-muted);margin-top:2px}
|
.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}
|
.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{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}
|
.check-row label{display:flex;align-items:center;gap:.4rem;font-size:.85rem;color:var(--text-muted);cursor:pointer}
|
||||||
|
|||||||
+38
-7
@@ -6,7 +6,7 @@
|
|||||||
<h2>行情 K 线</h2>
|
<h2>行情 K 线</h2>
|
||||||
<form class="market-toolbar" id="market-form" onsubmit="return false;">
|
<form class="market-toolbar" id="market-form" onsubmit="return false;">
|
||||||
<div class="symbol-wrap market-symbol-wrap">
|
<div class="symbol-wrap market-symbol-wrap">
|
||||||
<input type="text" class="symbol-input" id="market-symbol-input" placeholder="输入品种或合约,如 ag2608" autocomplete="off" value="{{ symbol }}">
|
<input type="text" class="symbol-input" id="market-symbol-input" placeholder="点击选择主力合约,或输入搜索" autocomplete="off" value="{{ symbol }}">
|
||||||
<input type="hidden" name="symbol" id="market-symbol-hidden" value="{{ symbol }}">
|
<input type="hidden" name="symbol" id="market-symbol-hidden" value="{{ symbol }}">
|
||||||
<input type="hidden" name="symbol_name" id="market-symbol-name">
|
<input type="hidden" name="symbol_name" id="market-symbol-name">
|
||||||
<input type="hidden" name="market_code" id="market-market-code">
|
<input type="hidden" name="market_code" id="market-market-code">
|
||||||
@@ -24,9 +24,15 @@
|
|||||||
<div class="market-quote" id="market-quote">
|
<div class="market-quote" id="market-quote">
|
||||||
<span class="market-quote-name" id="market-quote-name">—</span>
|
<span class="market-quote-name" id="market-quote-name">—</span>
|
||||||
<span class="market-quote-price" id="market-quote-price">—</span>
|
<span class="market-quote-price" id="market-quote-price">—</span>
|
||||||
|
<span class="market-quote-prev text-muted" id="market-quote-prev"></span>
|
||||||
<span class="market-quote-meta text-muted" id="market-quote-meta"></span>
|
<span class="market-quote-meta text-muted" id="market-quote-meta"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="market-chart-toolbar">
|
<div class="market-chart-toolbar">
|
||||||
|
<div class="market-chart-options">
|
||||||
|
<label class="chart-opt"><input type="checkbox" id="chart-opt-prev-close">昨收线</label>
|
||||||
|
<label class="chart-opt"><input type="checkbox" id="chart-opt-ma">均线 21/55</label>
|
||||||
|
<label class="chart-opt"><input type="checkbox" id="chart-opt-gap-day">间隔日</label>
|
||||||
|
</div>
|
||||||
<div class="market-chart-zoom">
|
<div class="market-chart-zoom">
|
||||||
<button type="button" class="chart-zoom-btn" id="chart-zoom-in" title="放大">+</button>
|
<button type="button" class="chart-zoom-btn" id="chart-zoom-in" title="放大">+</button>
|
||||||
<button type="button" class="chart-zoom-btn" id="chart-zoom-out" title="缩小">-</button>
|
<button type="button" class="chart-zoom-btn" id="chart-zoom-out" title="缩小">-</button>
|
||||||
@@ -34,11 +40,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<span class="market-refresh-hint text-muted" id="market-refresh-hint"></span>
|
<span class="market-refresh-hint text-muted" id="market-refresh-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="market-chart-wrap">
|
<div class="market-chart-wrap" id="market-chart-wrap">
|
||||||
<div id="market-chart" class="market-chart" aria-label="K线图"></div>
|
<div id="market-chart" class="market-chart" aria-label="K线图"></div>
|
||||||
<div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div>
|
<div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div>
|
||||||
|
<div class="market-chart-loading" id="market-chart-loading">连接中…</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint">数据来源:新浪财经。K 线由后台自动刷新并经 SSE 推送到前端;支持滚轮/拖拽缩放。</p>
|
<p class="hint">数据来源:新浪财经。K 线由后台自动刷新并经 SSE 推送到前端;支持滚轮/拖拽缩放。可视区内自动标注最高/最低价。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -47,8 +54,11 @@
|
|||||||
.market-toolbar{
|
.market-toolbar{
|
||||||
display:flex;flex-wrap:wrap;gap:.65rem;align-items:flex-end;
|
display:flex;flex-wrap:wrap;gap:.65rem;align-items:flex-end;
|
||||||
margin-bottom:.75rem;position:relative;z-index:2;
|
margin-bottom:.75rem;position:relative;z-index:2;
|
||||||
|
min-height:2.5rem;
|
||||||
}
|
}
|
||||||
.market-symbol-wrap{flex:1;min-width:200px;max-width:360px;z-index:3}
|
.market-symbol-wrap{flex:1;min-width:200px;max-width:360px;z-index:3}
|
||||||
|
.market-symbol-wrap .symbol-dropdown{max-height:min(70vh,420px)}
|
||||||
|
.market-symbol-wrap .symbol-selected{display:none}
|
||||||
.market-period-tabs{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center}
|
.market-period-tabs{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center}
|
||||||
.period-tab{
|
.period-tab{
|
||||||
padding:.4rem .65rem;border-radius:999px;
|
padding:.4rem .65rem;border-radius:999px;
|
||||||
@@ -66,13 +76,22 @@
|
|||||||
display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem 1rem;
|
display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem 1rem;
|
||||||
margin-bottom:.75rem;padding:.65rem .85rem;
|
margin-bottom:.75rem;padding:.65rem .85rem;
|
||||||
background:var(--card-inner);border-radius:10px;border:1px solid var(--card-border);
|
background:var(--card-inner);border-radius:10px;border:1px solid var(--card-border);
|
||||||
|
min-height:2.75rem;
|
||||||
}
|
}
|
||||||
.market-quote-name{font-weight:600;color:var(--text-title)}
|
.market-quote-name{font-weight:600;color:var(--text-title)}
|
||||||
.market-quote-price{font-size:1.35rem;font-weight:700;color:var(--accent);font-variant-numeric:tabular-nums}
|
.market-quote-price{font-size:1.35rem;font-weight:700;color:var(--accent);font-variant-numeric:tabular-nums}
|
||||||
|
.market-quote-prev{font-size:.78rem}
|
||||||
.market-chart-toolbar{
|
.market-chart-toolbar{
|
||||||
display:flex;align-items:center;justify-content:space-between;gap:.75rem;
|
display:flex;align-items:center;justify-content:space-between;gap:.75rem;
|
||||||
margin-bottom:.5rem;flex-wrap:wrap;
|
margin-bottom:.5rem;flex-wrap:wrap;
|
||||||
|
min-height:2rem;
|
||||||
}
|
}
|
||||||
|
.market-chart-options{display:flex;flex-wrap:wrap;gap:.5rem .85rem;align-items:center}
|
||||||
|
.chart-opt{
|
||||||
|
display:flex;align-items:center;gap:.35rem;font-size:.78rem;
|
||||||
|
color:var(--text-muted);cursor:pointer;user-select:none;
|
||||||
|
}
|
||||||
|
.chart-opt input{width:auto;margin:0;cursor:pointer}
|
||||||
.market-chart-zoom{display:flex;gap:.35rem;align-items:center}
|
.market-chart-zoom{display:flex;gap:.35rem;align-items:center}
|
||||||
.chart-zoom-btn{
|
.chart-zoom-btn{
|
||||||
width:32px;height:32px;padding:0;border-radius:8px;
|
width:32px;height:32px;padding:0;border-radius:8px;
|
||||||
@@ -84,20 +103,32 @@
|
|||||||
.market-refresh-hint{font-size:.72rem}
|
.market-refresh-hint{font-size:.72rem}
|
||||||
.market-chart-wrap{
|
.market-chart-wrap{
|
||||||
position:relative;border-radius:12px;border:1px solid var(--card-border);
|
position:relative;border-radius:12px;border:1px solid var(--card-border);
|
||||||
background:var(--card-inner);min-height:420px;
|
background:var(--card-inner);
|
||||||
|
height:min(62vh,520px);min-height:360px;
|
||||||
}
|
}
|
||||||
.market-chart{width:100%;height:min(62vh,520px);min-height:360px}
|
.market-chart{width:100%;height:100%}
|
||||||
.market-chart-empty{
|
.market-chart-empty,
|
||||||
|
.market-chart-loading{
|
||||||
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
||||||
color:var(--text-muted);font-size:.9rem;pointer-events:none;
|
color:var(--text-muted);font-size:.9rem;pointer-events:none;
|
||||||
}
|
}
|
||||||
|
.market-chart-loading{display:none}
|
||||||
|
html[data-theme="light"] .market-chart-loading{background:rgba(244,247,252,.75)}
|
||||||
.market-chart-wrap.has-data .market-chart-empty{display:none}
|
.market-chart-wrap.has-data .market-chart-empty{display:none}
|
||||||
|
.market-chart-wrap.loading .market-chart-loading{
|
||||||
|
display:flex;background:rgba(10,12,20,.35);
|
||||||
|
}
|
||||||
|
html[data-theme="light"] .market-chart-wrap.loading .market-chart-loading{background:rgba(244,247,252,.75)}
|
||||||
|
.market-chart-wrap.loading .market-chart-empty{display:none}
|
||||||
@media(max-width:767px){
|
@media(max-width:767px){
|
||||||
.market-toolbar{align-items:stretch}
|
.market-toolbar{align-items:stretch}
|
||||||
.market-symbol-wrap{max-width:none}
|
.market-symbol-wrap{max-width:none}
|
||||||
.market-period-tabs{order:3;width:100%}
|
.market-period-tabs{order:3;width:100%}
|
||||||
#market-load-btn{order:4;width:100%}
|
#market-load-btn{order:4;width:100%}
|
||||||
.market-chart{min-height:300px;height:50vh}
|
.market-chart-wrap{min-height:300px;height:50vh}
|
||||||
|
.market-chart-toolbar{flex-direction:column;align-items:stretch}
|
||||||
|
.market-chart-options{order:1}
|
||||||
|
.market-chart-zoom{order:2}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user