新增行情K线页,支持分时与多周期图表
扩展新浪K线拉取与合成逻辑,提供 ECharts 交互图表及实时报价 API。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,7 +30,7 @@ from fee_specs import (
|
|||||||
from fee_sync import sync_fees_from_akshare
|
from fee_sync import sync_fees_from_akshare
|
||||||
from contract_profile import get_contract_profile
|
from contract_profile import get_contract_profile
|
||||||
from stats_engine import STATS_VIEWS, load_stats_cache, refresh_stats_cache
|
from stats_engine import STATS_VIEWS, load_stats_cache, refresh_stats_cache
|
||||||
from kline_chart import generate_review_kline_chart
|
from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS
|
||||||
from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label
|
from market import get_price as market_get_price, set_ths_refresh_token, get_quote_source_label
|
||||||
|
|
||||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||||
@@ -1297,6 +1297,66 @@ def api_stats_refresh():
|
|||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/market")
|
||||||
|
@login_required
|
||||||
|
def market_page():
|
||||||
|
symbol = request.args.get("symbol", "").strip()
|
||||||
|
period = request.args.get("period", "15m").strip()
|
||||||
|
valid = {p["key"] for p in MARKET_PERIODS}
|
||||||
|
if period not in valid:
|
||||||
|
period = "15m"
|
||||||
|
return render_template(
|
||||||
|
"market.html",
|
||||||
|
symbol=symbol,
|
||||||
|
period=period,
|
||||||
|
market_periods=MARKET_PERIODS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/kline")
|
||||||
|
@login_required
|
||||||
|
def api_kline():
|
||||||
|
symbol = request.args.get("symbol", "").strip()
|
||||||
|
period = request.args.get("period", "15m").strip()
|
||||||
|
if not symbol:
|
||||||
|
return jsonify({"error": "请提供合约代码"}), 400
|
||||||
|
try:
|
||||||
|
data = fetch_market_klines(symbol, period)
|
||||||
|
except Exception as exc:
|
||||||
|
app.logger.warning("kline api failed: %s", exc)
|
||||||
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
if not data.get("chart_symbol"):
|
||||||
|
return jsonify({"error": "无法识别合约代码"}), 400
|
||||||
|
if not data.get("bars"):
|
||||||
|
return jsonify({"error": "未获取到K线数据,请稍后重试或更换合约"}), 404
|
||||||
|
return jsonify(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/market_quote")
|
||||||
|
@login_required
|
||||||
|
def api_market_quote():
|
||||||
|
symbol = request.args.get("symbol", "").strip()
|
||||||
|
market_code = request.args.get("market_code", "").strip()
|
||||||
|
sina_code = request.args.get("sina_code", "").strip()
|
||||||
|
if not symbol and not market_code:
|
||||||
|
return jsonify({"error": "请提供合约"}), 400
|
||||||
|
if not market_code or not sina_code:
|
||||||
|
codes = ths_to_codes(symbol)
|
||||||
|
if codes:
|
||||||
|
market_code = codes.get("market_code", "") or market_code
|
||||||
|
sina_code = codes.get("sina_code", "") or sina_code
|
||||||
|
price = market_get_price(market_code, sina_code)
|
||||||
|
name = symbol
|
||||||
|
codes = ths_to_codes(symbol)
|
||||||
|
if codes:
|
||||||
|
name = codes.get("name", symbol)
|
||||||
|
return jsonify({
|
||||||
|
"symbol": symbol,
|
||||||
|
"name": name,
|
||||||
|
"price": price,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/contract")
|
@app.route("/contract")
|
||||||
@login_required
|
@login_required
|
||||||
def contract_profile_page():
|
def contract_profile_page():
|
||||||
|
|||||||
+179
-21
@@ -24,6 +24,19 @@ PERIOD_MINUTES = {
|
|||||||
"4h": "240",
|
"4h": "240",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MARKET_PERIODS = [
|
||||||
|
{"key": "timeshare", "label": "分时"},
|
||||||
|
{"key": "1m", "label": "1分"},
|
||||||
|
{"key": "2m", "label": "2分"},
|
||||||
|
{"key": "5m", "label": "5分"},
|
||||||
|
{"key": "15m", "label": "15分"},
|
||||||
|
{"key": "1h", "label": "1小时"},
|
||||||
|
{"key": "2h", "label": "2小时"},
|
||||||
|
{"key": "4h", "label": "4小时"},
|
||||||
|
{"key": "d", "label": "日线"},
|
||||||
|
{"key": "w", "label": "周线"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def ths_to_sina_chart_symbol(symbol: str) -> Optional[str]:
|
def ths_to_sina_chart_symbol(symbol: str) -> Optional[str]:
|
||||||
"""ag2608 -> AG2608(新浪 K 线接口合约代码)。"""
|
"""ag2608 -> AG2608(新浪 K 线接口合约代码)。"""
|
||||||
@@ -57,15 +70,29 @@ def _parse_jsonp(text: str) -> Optional[list]:
|
|||||||
|
|
||||||
|
|
||||||
def fetch_sina_klines(symbol: str, period: str) -> list:
|
def fetch_sina_klines(symbol: str, period: str) -> list:
|
||||||
"""拉取新浪期货分钟 K 线。"""
|
"""拉取新浪期货 K 线(原始 bar 列表)。"""
|
||||||
chart_sym = ths_to_sina_chart_symbol(symbol)
|
chart_sym = ths_to_sina_chart_symbol(symbol)
|
||||||
if not chart_sym:
|
if not chart_sym:
|
||||||
return []
|
return []
|
||||||
if period == "1d":
|
p = (period or "").lower()
|
||||||
|
if p in ("1d", "d"):
|
||||||
return _fetch_sina_daily(chart_sym)
|
return _fetch_sina_daily(chart_sym)
|
||||||
typ = PERIOD_MINUTES.get(period)
|
if p == "w":
|
||||||
if not typ:
|
return _weekly_from_daily(_fetch_sina_daily(chart_sym))
|
||||||
return []
|
if p == "timeshare":
|
||||||
|
bars = _fetch_few_min_line(chart_sym, "1")
|
||||||
|
return _timeshare_session(bars)
|
||||||
|
if p == "2m":
|
||||||
|
return _aggregate_bars(_fetch_few_min_line(chart_sym, "1"), 2)
|
||||||
|
if p == "2h":
|
||||||
|
return _aggregate_bars(_fetch_few_min_line(chart_sym, "60"), 2)
|
||||||
|
typ = PERIOD_MINUTES.get(p)
|
||||||
|
if typ:
|
||||||
|
return _fetch_few_min_line(chart_sym, typ)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_few_min_line(chart_sym: str, typ: str) -> list:
|
||||||
ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
|
ts = datetime.now(TZ).strftime("%Y%m%d%H%M%S")
|
||||||
url = (
|
url = (
|
||||||
"https://stock2.finance.sina.com.cn/futures/api/jsonp.php/"
|
"https://stock2.finance.sina.com.cn/futures/api/jsonp.php/"
|
||||||
@@ -79,12 +106,131 @@ def fetch_sina_klines(symbol: str, period: str) -> list:
|
|||||||
headers={"Referer": "https://finance.sina.com.cn"},
|
headers={"Referer": "https://finance.sina.com.cn"},
|
||||||
)
|
)
|
||||||
bars = _parse_jsonp(resp.text)
|
bars = _parse_jsonp(resp.text)
|
||||||
return bars or []
|
return _normalize_bars(bars or [])
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("fetch kline failed %s %s: %s", chart_sym, period, exc)
|
logger.warning("fetch kline failed %s %s: %s", chart_sym, typ, exc)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_bars(raw: list) -> list:
|
||||||
|
out = []
|
||||||
|
for row in raw:
|
||||||
|
if isinstance(row, list) and len(row) >= 5:
|
||||||
|
out.append({
|
||||||
|
"d": str(row[0]),
|
||||||
|
"o": float(row[1]),
|
||||||
|
"h": float(row[2]),
|
||||||
|
"l": float(row[3]),
|
||||||
|
"c": float(row[4]),
|
||||||
|
"v": float(row[5]) if len(row) > 5 and row[5] else 0.0,
|
||||||
|
})
|
||||||
|
elif isinstance(row, dict) and row.get("d"):
|
||||||
|
out.append({
|
||||||
|
"d": str(row["d"]),
|
||||||
|
"o": float(row.get("o", 0) or 0),
|
||||||
|
"h": float(row.get("h", 0) or 0),
|
||||||
|
"l": float(row.get("l", 0) or 0),
|
||||||
|
"c": float(row.get("c", 0) or 0),
|
||||||
|
"v": float(row.get("v", 0) or 0),
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _aggregate_bars(bars: list, n: int) -> list:
|
||||||
|
if n <= 1 or not bars:
|
||||||
|
return bars
|
||||||
|
out = []
|
||||||
|
chunk: list = []
|
||||||
|
for bar in bars:
|
||||||
|
chunk.append(bar)
|
||||||
|
if len(chunk) >= n:
|
||||||
|
out.append(_merge_bars(chunk))
|
||||||
|
chunk = []
|
||||||
|
if chunk:
|
||||||
|
out.append(_merge_bars(chunk))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_bars(chunk: list) -> dict:
|
||||||
|
return {
|
||||||
|
"d": chunk[0]["d"],
|
||||||
|
"o": chunk[0]["o"],
|
||||||
|
"h": max(b["h"] for b in chunk),
|
||||||
|
"l": min(b["l"] for b in chunk),
|
||||||
|
"c": chunk[-1]["c"],
|
||||||
|
"v": sum(b.get("v", 0) for b in chunk),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _weekly_from_daily(daily: list) -> list:
|
||||||
|
if not daily:
|
||||||
|
return []
|
||||||
|
buckets: dict[tuple, list] = {}
|
||||||
|
for bar in daily:
|
||||||
|
dt = _bar_datetime(bar)
|
||||||
|
if not dt:
|
||||||
|
continue
|
||||||
|
iso = dt.isocalendar()
|
||||||
|
key = (iso[0], iso[1])
|
||||||
|
buckets.setdefault(key, []).append(bar)
|
||||||
|
out = []
|
||||||
|
for key in sorted(buckets.keys()):
|
||||||
|
chunk = buckets[key]
|
||||||
|
out.append(_merge_bars(chunk))
|
||||||
|
out[-1]["d"] = chunk[-1]["d"]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _timeshare_session(bars: list) -> list:
|
||||||
|
if not bars:
|
||||||
|
return []
|
||||||
|
today = datetime.now(TZ).date()
|
||||||
|
session = []
|
||||||
|
for bar in bars:
|
||||||
|
dt = _bar_datetime(bar)
|
||||||
|
if dt and dt.date() == today:
|
||||||
|
session.append(bar)
|
||||||
|
if session:
|
||||||
|
return session[-480:]
|
||||||
|
return bars[-480:]
|
||||||
|
|
||||||
|
|
||||||
|
def bars_to_api(bars: list) -> list[dict]:
|
||||||
|
"""转为前端图表 JSON。"""
|
||||||
|
result = []
|
||||||
|
for bar in bars:
|
||||||
|
dt = _bar_datetime(bar)
|
||||||
|
ts = int(dt.timestamp() * 1000) if dt else None
|
||||||
|
result.append({
|
||||||
|
"time": bar["d"],
|
||||||
|
"timestamp": ts,
|
||||||
|
"open": bar["o"],
|
||||||
|
"high": bar["h"],
|
||||||
|
"low": bar["l"],
|
||||||
|
"close": bar["c"],
|
||||||
|
"volume": bar.get("v", 0),
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_market_klines(symbol: str, period: str) -> dict:
|
||||||
|
chart_sym = ths_to_sina_chart_symbol(symbol)
|
||||||
|
p = (period or "15m").lower()
|
||||||
|
if p == "timeshare":
|
||||||
|
chart_type = "line"
|
||||||
|
else:
|
||||||
|
chart_type = "candle"
|
||||||
|
bars = fetch_sina_klines(symbol, p)
|
||||||
|
return {
|
||||||
|
"symbol": symbol,
|
||||||
|
"chart_symbol": chart_sym,
|
||||||
|
"period": p,
|
||||||
|
"chart_type": chart_type,
|
||||||
|
"count": len(bars),
|
||||||
|
"bars": bars_to_api(bars),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _fetch_sina_daily(chart_sym: str) -> list:
|
def _fetch_sina_daily(chart_sym: str) -> list:
|
||||||
url = (
|
url = (
|
||||||
"https://stock2.finance.sina.com.cn/futures/api/json.php/"
|
"https://stock2.finance.sina.com.cn/futures/api/json.php/"
|
||||||
@@ -93,22 +239,34 @@ def _fetch_sina_daily(chart_sym: str) -> list:
|
|||||||
try:
|
try:
|
||||||
resp = requests.get(url, timeout=20, headers={"Referer": "https://finance.sina.com.cn"})
|
resp = requests.get(url, timeout=20, headers={"Referer": "https://finance.sina.com.cn"})
|
||||||
raw = resp.json()
|
raw = resp.json()
|
||||||
if not raw:
|
if raw and isinstance(raw, list):
|
||||||
return []
|
bars = _normalize_bars(raw)
|
||||||
out = []
|
if bars:
|
||||||
for row in raw:
|
return bars
|
||||||
if isinstance(row, list) and len(row) >= 5:
|
|
||||||
out.append({
|
|
||||||
"d": row[0],
|
|
||||||
"o": row[1],
|
|
||||||
"h": row[2],
|
|
||||||
"l": row[3],
|
|
||||||
"c": row[4],
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("fetch daily kline failed %s: %s", chart_sym, exc)
|
logger.warning("fetch daily kline failed %s: %s", chart_sym, exc)
|
||||||
return []
|
return _daily_from_minutes(chart_sym)
|
||||||
|
|
||||||
|
|
||||||
|
def _daily_from_minutes(chart_sym: str) -> list:
|
||||||
|
"""合约日线接口无数据时,由 60 分钟 K 线按日合成。"""
|
||||||
|
bars_60 = _fetch_few_min_line(chart_sym, "60")
|
||||||
|
if not bars_60:
|
||||||
|
bars_60 = _fetch_few_min_line(chart_sym, "240")
|
||||||
|
buckets: dict[str, list] = {}
|
||||||
|
for bar in bars_60:
|
||||||
|
dt = _bar_datetime(bar)
|
||||||
|
if not dt:
|
||||||
|
continue
|
||||||
|
key = dt.strftime("%Y-%m-%d")
|
||||||
|
buckets.setdefault(key, []).append(bar)
|
||||||
|
out = []
|
||||||
|
for day in sorted(buckets.keys()):
|
||||||
|
chunk = buckets[day]
|
||||||
|
merged = _merge_bars(chunk)
|
||||||
|
merged["d"] = day + " 15:00:00"
|
||||||
|
out.append(merged)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _parse_dt(value: str) -> Optional[datetime]:
|
def _parse_dt(value: str) -> Optional[datetime]:
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
(function () {
|
||||||
|
var chartEl = document.getElementById('market-chart');
|
||||||
|
var emptyEl = document.getElementById('market-chart-empty');
|
||||||
|
var wrapEl = chartEl && chartEl.parentElement;
|
||||||
|
var chart = null;
|
||||||
|
var currentPeriod = '15m';
|
||||||
|
var quoteTimer = null;
|
||||||
|
|
||||||
|
function getSymbol() {
|
||||||
|
var hidden = document.getElementById('market-symbol-hidden');
|
||||||
|
var input = document.getElementById('market-symbol-input');
|
||||||
|
if (hidden && hidden.value) return hidden.value.trim();
|
||||||
|
if (input && input.value) return input.value.trim();
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarketCodes() {
|
||||||
|
return {
|
||||||
|
symbol: getSymbol(),
|
||||||
|
market_code: (document.getElementById('market-market-code') || {}).value || '',
|
||||||
|
sina_code: (document.getElementById('market-sina-code') || {}).value || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeColors() {
|
||||||
|
var dark = document.documentElement.getAttribute('data-theme') !== 'light';
|
||||||
|
return {
|
||||||
|
bg: dark ? '#0a0c14' : '#f4f7fc',
|
||||||
|
text: dark ? '#a8b0c8' : '#5c6578',
|
||||||
|
title: dark ? '#e8eaf6' : '#1a2233',
|
||||||
|
grid: dark ? '#1a2038' : '#e2e8f0',
|
||||||
|
up: dark ? '#4cd97f' : '#15803d',
|
||||||
|
down: dark ? '#ff6b7a' : '#dc2626',
|
||||||
|
line: dark ? '#4cc2ff' : '#2563eb',
|
||||||
|
area: dark ? 'rgba(76,194,255,0.12)' : 'rgba(37,99,235,0.1)',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initChart() {
|
||||||
|
if (!chartEl || !window.echarts) return;
|
||||||
|
chart = echarts.init(chartEl);
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
if (chart) chart.resize();
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('[data-theme-pick]')) {
|
||||||
|
setTimeout(function () {
|
||||||
|
if (chart && lastData) renderChart(lastData);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastData = null;
|
||||||
|
|
||||||
|
function renderChart(data) {
|
||||||
|
if (!chart) return;
|
||||||
|
lastData = data;
|
||||||
|
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';
|
||||||
|
|
||||||
|
if (isLine) {
|
||||||
|
var closes = bars.map(function (b) { return b.close; });
|
||||||
|
var vols = bars.map(function (b) { return b.volume; });
|
||||||
|
chart.setOption({
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
animation: false,
|
||||||
|
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||||
|
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||||||
|
grid: [
|
||||||
|
{ left: 56, right: 16, top: 40, height: '58%' },
|
||||||
|
{ left: 56, right: 16, top: '72%', height: '18%' },
|
||||||
|
],
|
||||||
|
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 } } },
|
||||||
|
],
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
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, i) {
|
||||||
|
var up = b.close >= b.open;
|
||||||
|
return {
|
||||||
|
value: b.volume,
|
||||||
|
itemStyle: { color: up ? c.up : c.down, opacity: 0.65 },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
chart.setOption({
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
animation: false,
|
||||||
|
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/>');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||||||
|
grid: [
|
||||||
|
{ left: 56, right: 16, top: 40, height: '58%' },
|
||||||
|
{ left: 56, right: 16, top: '72%', height: '18%' },
|
||||||
|
],
|
||||||
|
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 } } },
|
||||||
|
],
|
||||||
|
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 },
|
||||||
|
],
|
||||||
|
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 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 } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodLabel(key) {
|
||||||
|
var tabs = document.querySelectorAll('.period-tab');
|
||||||
|
for (var i = 0; i < tabs.length; i++) {
|
||||||
|
if (tabs[i].getAttribute('data-period') === key) return tabs[i].textContent;
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(on) {
|
||||||
|
var btn = document.getElementById('market-load-btn');
|
||||||
|
if (btn) {
|
||||||
|
btn.disabled = on;
|
||||||
|
btn.textContent = on ? '加载中…' : '查看';
|
||||||
|
}
|
||||||
|
if (emptyEl && on) {
|
||||||
|
emptyEl.textContent = '加载中…';
|
||||||
|
emptyEl.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKline() {
|
||||||
|
var symbol = getSymbol();
|
||||||
|
if (!symbol) {
|
||||||
|
alert('请先选择或输入合约代码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
if (wrapEl) wrapEl.classList.remove('has-data');
|
||||||
|
|
||||||
|
var url = '/api/kline?symbol=' + encodeURIComponent(symbol) + '&period=' + encodeURIComponent(currentPeriod);
|
||||||
|
fetch(url)
|
||||||
|
.then(function (r) {
|
||||||
|
return r.json().then(function (j) { return { ok: r.ok, data: j }; });
|
||||||
|
})
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(res.data.error || '加载失败');
|
||||||
|
}
|
||||||
|
if (wrapEl) wrapEl.classList.add('has-data');
|
||||||
|
renderChart(res.data);
|
||||||
|
updateQuoteMeta(res.data);
|
||||||
|
startQuotePoll();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.textContent = err.message || '加载失败';
|
||||||
|
emptyEl.style.display = 'flex';
|
||||||
|
}
|
||||||
|
if (wrapEl) wrapEl.classList.remove('has-data');
|
||||||
|
})
|
||||||
|
.finally(function () { setLoading(false); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuoteMeta(data) {
|
||||||
|
var meta = document.getElementById('market-quote-meta');
|
||||||
|
if (meta) {
|
||||||
|
meta.textContent = data.count ? ('共 ' + data.count + ' 根 · ' + periodLabel(data.period)) : '';
|
||||||
|
}
|
||||||
|
var nameEl = document.getElementById('market-quote-name');
|
||||||
|
var hiddenName = document.getElementById('market-symbol-name');
|
||||||
|
if (nameEl) {
|
||||||
|
nameEl.textContent = (hiddenName && hiddenName.value) || data.symbol || '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadQuote() {
|
||||||
|
var codes = getMarketCodes();
|
||||||
|
if (!codes.symbol) return;
|
||||||
|
var q = 'symbol=' + encodeURIComponent(codes.symbol);
|
||||||
|
if (codes.market_code) q += '&market_code=' + encodeURIComponent(codes.market_code);
|
||||||
|
if (codes.sina_code) q += '&sina_code=' + encodeURIComponent(codes.sina_code);
|
||||||
|
fetch('/api/market_quote?' + q)
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var priceEl = document.getElementById('market-quote-price');
|
||||||
|
var nameEl = document.getElementById('market-quote-name');
|
||||||
|
if (nameEl && data.name) nameEl.textContent = data.name + ' ' + (data.symbol || '');
|
||||||
|
if (priceEl) {
|
||||||
|
priceEl.textContent = data.price != null ? Number(data.price).toFixed(2) : '—';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { /* ignore */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startQuotePoll() {
|
||||||
|
if (quoteTimer) clearInterval(quoteTimer);
|
||||||
|
loadQuote();
|
||||||
|
quoteTimer = setInterval(loadQuote, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindPeriodTabs() {
|
||||||
|
var tabs = document.getElementById('market-period-tabs');
|
||||||
|
if (!tabs) return;
|
||||||
|
tabs.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.period-tab');
|
||||||
|
if (!btn) return;
|
||||||
|
tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); });
|
||||||
|
btn.classList.add('active');
|
||||||
|
currentPeriod = btn.getAttribute('data-period') || '15m';
|
||||||
|
if (getSymbol()) loadKline();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
initChart();
|
||||||
|
bindPeriodTabs();
|
||||||
|
|
||||||
|
var active = document.querySelector('.period-tab.active');
|
||||||
|
if (active) currentPeriod = active.getAttribute('data-period') || '15m';
|
||||||
|
|
||||||
|
var loadBtn = document.getElementById('market-load-btn');
|
||||||
|
if (loadBtn) loadBtn.addEventListener('click', loadKline);
|
||||||
|
|
||||||
|
var hidden = document.getElementById('market-symbol-hidden');
|
||||||
|
var input = document.getElementById('market-symbol-input');
|
||||||
|
if (hidden && hidden.value) {
|
||||||
|
if (input && !input.value) input.value = hidden.value;
|
||||||
|
loadKline();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -454,6 +454,7 @@
|
|||||||
<a href="{{ url_for('plans') }}" class="{% if request.endpoint == 'plans' %}active{% endif %}">开单计划</a>
|
<a href="{{ url_for('plans') }}" class="{% if request.endpoint == 'plans' %}active{% endif %}">开单计划</a>
|
||||||
<a href="{{ url_for('keys') }}" class="{% if request.endpoint == 'keys' %}active{% endif %}">关键位监控</a>
|
<a href="{{ url_for('keys') }}" class="{% if request.endpoint == 'keys' %}active{% endif %}">关键位监控</a>
|
||||||
<a href="{{ url_for('positions') }}" class="{% if request.endpoint == 'positions' %}active{% endif %}">持仓监控</a>
|
<a href="{{ url_for('positions') }}" class="{% if request.endpoint == 'positions' %}active{% endif %}">持仓监控</a>
|
||||||
|
<a href="{{ url_for('market_page') }}" class="{% if request.endpoint == 'market_page' %}active{% endif %}">行情K线</a>
|
||||||
<a href="{{ url_for('records') }}" class="{% if request.endpoint in ('records', 'trades') %}active{% endif %}">交易记录与复盘</a>
|
<a href="{{ url_for('records') }}" class="{% if request.endpoint in ('records', 'trades') %}active{% endif %}">交易记录与复盘</a>
|
||||||
<a href="{{ url_for('stats') }}" class="{% if request.endpoint == 'stats' %}active{% endif %}">统计分析</a>
|
<a href="{{ url_for('stats') }}" class="{% if request.endpoint == 'stats' %}active{% endif %}">统计分析</a>
|
||||||
<a href="{{ url_for('fees') }}" class="{% if request.endpoint == 'fees' %}active{% endif %}">手续费配置</a>
|
<a href="{{ url_for('fees') }}" class="{% if request.endpoint == 'fees' %}active{% endif %}">手续费配置</a>
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}行情K线 - 国内期货监控系统{% endblock %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="card market-card">
|
||||||
|
<h2>行情 K 线</h2>
|
||||||
|
<form class="market-toolbar" id="market-form" onsubmit="return false;">
|
||||||
|
<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="hidden" name="symbol" id="market-symbol-hidden" value="{{ symbol }}">
|
||||||
|
<input type="hidden" name="symbol_name" id="market-symbol-name">
|
||||||
|
<input type="hidden" name="market_code" id="market-market-code">
|
||||||
|
<input type="hidden" name="sina_code" id="market-sina-code">
|
||||||
|
<div class="symbol-dropdown"></div>
|
||||||
|
<div class="symbol-selected" id="market-symbol-selected"></div>
|
||||||
|
</div>
|
||||||
|
<div class="market-period-tabs" id="market-period-tabs">
|
||||||
|
{% for p in market_periods %}
|
||||||
|
<button type="button" class="period-tab{% if p.key == period %} active{% endif %}" data-period="{{ p.key }}">{{ p.label }}</button>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-primary" id="market-load-btn">查看</button>
|
||||||
|
</form>
|
||||||
|
<div class="market-quote" id="market-quote">
|
||||||
|
<span class="market-quote-name" id="market-quote-name">—</span>
|
||||||
|
<span class="market-quote-price" id="market-quote-price">—</span>
|
||||||
|
<span class="market-quote-meta text-muted" id="market-quote-meta"></span>
|
||||||
|
</div>
|
||||||
|
<div class="market-chart-wrap">
|
||||||
|
<div id="market-chart" class="market-chart" aria-label="K线图"></div>
|
||||||
|
<div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div>
|
||||||
|
</div>
|
||||||
|
<p class="hint">数据来源:新浪财经。分时图为当日分钟走势;2分/2小时/周线由基础周期合成。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.market-card h2{margin-bottom:.75rem}
|
||||||
|
.market-toolbar{display:flex;flex-wrap:wrap;gap:.65rem;align-items:flex-end;margin-bottom:.75rem}
|
||||||
|
.market-symbol-wrap{flex:1;min-width:200px;max-width:360px}
|
||||||
|
.market-period-tabs{display:flex;flex-wrap:wrap;gap:.35rem;align-items:center}
|
||||||
|
.period-tab{
|
||||||
|
padding:.4rem .65rem;border-radius:999px;
|
||||||
|
border:1px solid var(--input-border);
|
||||||
|
background:var(--toggle-bg);color:var(--text-muted);
|
||||||
|
font-size:.78rem;cursor:pointer;width:auto;white-space:nowrap;
|
||||||
|
}
|
||||||
|
.period-tab:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
|
.period-tab.active{
|
||||||
|
background:linear-gradient(135deg,var(--accent),var(--accent-2));
|
||||||
|
border-color:transparent;color:#fff;
|
||||||
|
}
|
||||||
|
#market-load-btn{width:auto;padding:.55rem 1.25rem;font-size:.85rem}
|
||||||
|
.market-quote{
|
||||||
|
display:flex;flex-wrap:wrap;align-items:baseline;gap:.5rem 1rem;
|
||||||
|
margin-bottom:.75rem;padding:.65rem .85rem;
|
||||||
|
background:var(--card-inner);border-radius:10px;border:1px solid var(--card-border);
|
||||||
|
}
|
||||||
|
.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-chart-wrap{
|
||||||
|
position:relative;border-radius:12px;border:1px solid var(--card-border);
|
||||||
|
background:var(--card-inner);min-height:420px;
|
||||||
|
}
|
||||||
|
.market-chart{width:100%;height:min(62vh,520px);min-height:360px}
|
||||||
|
.market-chart-empty{
|
||||||
|
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
||||||
|
color:var(--text-muted);font-size:.9rem;pointer-events:none;
|
||||||
|
}
|
||||||
|
.market-chart-wrap.has-data .market-chart-empty{display:none}
|
||||||
|
@media(max-width:767px){
|
||||||
|
.market-toolbar{align-items:stretch}
|
||||||
|
.market-symbol-wrap{max-width:none}
|
||||||
|
.market-period-tabs{order:3;width:100%}
|
||||||
|
#market-load-btn{order:4;width:100%}
|
||||||
|
.market-chart{min-height:300px;height:50vh}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/market.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user