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 %} + +
+

行情 K 线

+
+
+ + + + + +
+
+
+
+ {% for p in market_periods %} + + {% endfor %} +
+ +
+
+ + + +
+
+
+
请选择合约并点击「查看」
+
+

数据来源:新浪财经。分时图为当日分钟走势;2分/2小时/周线由基础周期合成。

+
+ + +{% endblock %} + +{% block extra_js %} + + +{% endblock %}