diff --git a/app.py b/app.py
index ed55e6f..64b2f2e 100644
--- a/app.py
+++ b/app.py
@@ -30,7 +30,7 @@ from fee_specs import (
from fee_sync import sync_fees_from_akshare
from contract_profile import get_contract_profile
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
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
@@ -1297,6 +1297,66 @@ def api_stats_refresh():
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")
@login_required
def contract_profile_page():
diff --git a/kline_chart.py b/kline_chart.py
index 73bafc8..21c411a 100644
--- a/kline_chart.py
+++ b/kline_chart.py
@@ -24,6 +24,19 @@ PERIOD_MINUTES = {
"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]:
"""ag2608 -> AG2608(新浪 K 线接口合约代码)。"""
@@ -57,15 +70,29 @@ def _parse_jsonp(text: str) -> Optional[list]:
def fetch_sina_klines(symbol: str, period: str) -> list:
- """拉取新浪期货分钟 K 线。"""
+ """拉取新浪期货 K 线(原始 bar 列表)。"""
chart_sym = ths_to_sina_chart_symbol(symbol)
if not chart_sym:
return []
- if period == "1d":
+ p = (period or "").lower()
+ if p in ("1d", "d"):
return _fetch_sina_daily(chart_sym)
- typ = PERIOD_MINUTES.get(period)
- if not typ:
- return []
+ if p == "w":
+ return _weekly_from_daily(_fetch_sina_daily(chart_sym))
+ 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")
url = (
"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"},
)
bars = _parse_jsonp(resp.text)
- return bars or []
+ return _normalize_bars(bars or [])
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 []
+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:
url = (
"https://stock2.finance.sina.com.cn/futures/api/json.php/"
@@ -93,22 +239,34 @@ def _fetch_sina_daily(chart_sym: str) -> list:
try:
resp = requests.get(url, timeout=20, headers={"Referer": "https://finance.sina.com.cn"})
raw = resp.json()
- if not raw:
- return []
- out = []
- for row in raw:
- 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
+ if raw and isinstance(raw, list):
+ bars = _normalize_bars(raw)
+ if bars:
+ return bars
except Exception as 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]:
diff --git a/static/js/market.js b/static/js/market.js
new file mode 100644
index 0000000..f3b0022
--- /dev/null
+++ b/static/js/market.js
@@ -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('
');
+ },
+ },
+ 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();
+ }
+ });
+})();
diff --git a/templates/base.html b/templates/base.html
index 0c6da80..b73c8c3 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -454,6 +454,7 @@
开单计划
关键位监控
持仓监控
+ 行情K线
交易记录与复盘
统计分析
手续费配置
diff --git a/templates/market.html b/templates/market.html
new file mode 100644
index 0000000..cf6325b
--- /dev/null
+++ b/templates/market.html
@@ -0,0 +1,83 @@
+{% extends "base.html" %}
+{% block title %}行情K线 - 国内期货监控系统{% endblock %}
+{% block content %}
+
+
数据来源:新浪财经。分时图为当日分钟走势;2分/2小时/周线由基础周期合成。
+