K线本地缓存、图表交互优化与交易记录表格修复
新增 kline_store 优先读本地库;修复加载中遮挡、支持缩放与交易时段刷新;修复交易记录操作列被裁切。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,6 +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_store import ensure_kline_tables
|
||||||
from kline_chart import generate_review_kline_chart, fetch_market_klines, MARKET_PERIODS
|
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
|
||||||
|
|
||||||
@@ -303,6 +304,7 @@ def init_db():
|
|||||||
(key TEXT PRIMARY KEY,
|
(key TEXT PRIMARY KEY,
|
||||||
data_json TEXT NOT NULL,
|
data_json TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL)''')
|
updated_at TEXT NOT NULL)''')
|
||||||
|
ensure_kline_tables(conn)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -1321,7 +1323,7 @@ def api_kline():
|
|||||||
if not symbol:
|
if not symbol:
|
||||||
return jsonify({"error": "请提供合约代码"}), 400
|
return jsonify({"error": "请提供合约代码"}), 400
|
||||||
try:
|
try:
|
||||||
data = fetch_market_klines(symbol, period)
|
data = fetch_market_klines(symbol, period, DB_PATH)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
app.logger.warning("kline api failed: %s", exc)
|
app.logger.warning("kline api failed: %s", exc)
|
||||||
return jsonify({"error": str(exc)}), 500
|
return jsonify({"error": str(exc)}), 500
|
||||||
|
|||||||
+52
-2
@@ -3,6 +3,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
@@ -10,6 +11,7 @@ from zoneinfo import ZoneInfo
|
|||||||
import requests
|
import requests
|
||||||
|
|
||||||
from symbols import ths_to_codes
|
from symbols import ths_to_codes
|
||||||
|
from kline_store import ensure_kline_tables, get_cached_entry, save_bars
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
TZ = ZoneInfo("Asia/Shanghai")
|
TZ = ZoneInfo("Asia/Shanghai")
|
||||||
@@ -213,14 +215,60 @@ def bars_to_api(bars: list) -> list[dict]:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def fetch_market_klines(symbol: str, period: str) -> dict:
|
def fetch_market_klines(symbol: str, period: str, db_path: Optional[str] = None) -> dict:
|
||||||
chart_sym = ths_to_sina_chart_symbol(symbol)
|
chart_sym = ths_to_sina_chart_symbol(symbol)
|
||||||
p = (period or "15m").lower()
|
p = (period or "15m").lower()
|
||||||
if p == "timeshare":
|
if p == "timeshare":
|
||||||
chart_type = "line"
|
chart_type = "line"
|
||||||
else:
|
else:
|
||||||
chart_type = "candle"
|
chart_type = "candle"
|
||||||
bars = fetch_sina_klines(symbol, p)
|
|
||||||
|
bars: list = []
|
||||||
|
source = "remote"
|
||||||
|
cached_at = None
|
||||||
|
|
||||||
|
if db_path and chart_sym:
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cached = get_cached_entry(conn, chart_sym, p)
|
||||||
|
conn.close()
|
||||||
|
if cached and cached.get("fresh"):
|
||||||
|
bars = cached["bars"]
|
||||||
|
source = "local"
|
||||||
|
cached_at = cached.get("updated_at")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("kline cache read failed %s %s: %s", chart_sym, p, exc)
|
||||||
|
|
||||||
|
if not bars:
|
||||||
|
remote_bars = fetch_sina_klines(symbol, p)
|
||||||
|
if remote_bars:
|
||||||
|
bars = remote_bars
|
||||||
|
source = "remote"
|
||||||
|
if db_path and chart_sym:
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
ensure_kline_tables(conn)
|
||||||
|
save_bars(conn, chart_sym, p, remote_bars)
|
||||||
|
meta = conn.execute(
|
||||||
|
"SELECT updated_at FROM kline_meta WHERE chart_symbol=? AND period=?",
|
||||||
|
(chart_sym, p),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
cached_at = meta[0] if meta else None
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("kline cache write failed %s %s: %s", chart_sym, p, exc)
|
||||||
|
elif db_path and chart_sym:
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
cached = get_cached_entry(conn, chart_sym, p)
|
||||||
|
conn.close()
|
||||||
|
if cached and cached.get("bars"):
|
||||||
|
bars = cached["bars"]
|
||||||
|
source = "local"
|
||||||
|
cached_at = cached.get("updated_at")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("kline cache fallback failed %s %s: %s", chart_sym, p, exc)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"symbol": symbol,
|
"symbol": symbol,
|
||||||
"chart_symbol": chart_sym,
|
"chart_symbol": chart_sym,
|
||||||
@@ -228,6 +276,8 @@ def fetch_market_klines(symbol: str, period: str) -> dict:
|
|||||||
"chart_type": chart_type,
|
"chart_type": chart_type,
|
||||||
"count": len(bars),
|
"count": len(bars),
|
||||||
"bars": bars_to_api(bars),
|
"bars": bars_to_api(bars),
|
||||||
|
"source": source,
|
||||||
|
"cached_at": cached_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
"""K 线本地 SQLite 缓存。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
TZ = ZoneInfo("Asia/Shanghai")
|
||||||
|
|
||||||
|
REFRESH_SECONDS = {
|
||||||
|
"timeshare": 30,
|
||||||
|
"1m": 30,
|
||||||
|
"2m": 30,
|
||||||
|
"5m": 60,
|
||||||
|
"15m": 60,
|
||||||
|
"1h": 120,
|
||||||
|
"2h": 120,
|
||||||
|
"4h": 180,
|
||||||
|
"d": 300,
|
||||||
|
"w": 600,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_kline_tables(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS kline_bars (
|
||||||
|
chart_symbol TEXT NOT NULL,
|
||||||
|
period TEXT NOT NULL,
|
||||||
|
bar_time TEXT NOT NULL,
|
||||||
|
open REAL NOT NULL,
|
||||||
|
high REAL NOT NULL,
|
||||||
|
low REAL NOT NULL,
|
||||||
|
close REAL NOT NULL,
|
||||||
|
volume REAL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (chart_symbol, period, bar_time)
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""CREATE TABLE IF NOT EXISTS kline_meta (
|
||||||
|
chart_symbol TEXT NOT NULL,
|
||||||
|
period TEXT NOT NULL,
|
||||||
|
bar_count INTEGER DEFAULT 0,
|
||||||
|
last_bar_time TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (chart_symbol, period)
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_kline_bars_sym_period "
|
||||||
|
"ON kline_bars(chart_symbol, period, bar_time)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_updated_at(value: str) -> Optional[datetime]:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.strip()).replace(tzinfo=TZ)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_cache_fresh(period: str, updated_at: str) -> bool:
|
||||||
|
dt = _parse_updated_at(updated_at)
|
||||||
|
if not dt:
|
||||||
|
return False
|
||||||
|
ttl = REFRESH_SECONDS.get((period or "").lower(), 60)
|
||||||
|
return datetime.now(TZ) - dt < timedelta(seconds=ttl)
|
||||||
|
|
||||||
|
|
||||||
|
def load_bars(conn: sqlite3.Connection, chart_symbol: str, period: str) -> list[dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT bar_time, open, high, low, close, volume
|
||||||
|
FROM kline_bars
|
||||||
|
WHERE chart_symbol=? AND period=?
|
||||||
|
ORDER BY bar_time ASC""",
|
||||||
|
(chart_symbol, period),
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"d": row[0],
|
||||||
|
"o": float(row[1]),
|
||||||
|
"h": float(row[2]),
|
||||||
|
"l": float(row[3]),
|
||||||
|
"c": float(row[4]),
|
||||||
|
"v": float(row[5] or 0),
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def load_meta(conn: sqlite3.Connection, chart_symbol: str, period: str) -> Optional[dict]:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT bar_count, last_bar_time, updated_at FROM kline_meta "
|
||||||
|
"WHERE chart_symbol=? AND period=?",
|
||||||
|
(chart_symbol, period),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"bar_count": row[0],
|
||||||
|
"last_bar_time": row[1],
|
||||||
|
"updated_at": row[2],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_bars(conn: sqlite3.Connection, chart_symbol: str, period: str, bars: list[dict]) -> int:
|
||||||
|
if not bars:
|
||||||
|
return 0
|
||||||
|
ensure_kline_tables(conn)
|
||||||
|
now = datetime.now(TZ).isoformat(timespec="seconds")
|
||||||
|
for bar in bars:
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO kline_bars
|
||||||
|
(chart_symbol, period, bar_time, open, high, low, close, volume, updated_at)
|
||||||
|
VALUES (?,?,?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(chart_symbol, period, bar_time) DO UPDATE SET
|
||||||
|
open=excluded.open,
|
||||||
|
high=excluded.high,
|
||||||
|
low=excluded.low,
|
||||||
|
close=excluded.close,
|
||||||
|
volume=excluded.volume,
|
||||||
|
updated_at=excluded.updated_at""",
|
||||||
|
(
|
||||||
|
chart_symbol,
|
||||||
|
period,
|
||||||
|
str(bar["d"]),
|
||||||
|
float(bar["o"]),
|
||||||
|
float(bar["h"]),
|
||||||
|
float(bar["l"]),
|
||||||
|
float(bar["c"]),
|
||||||
|
float(bar.get("v") or 0),
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
last_time = str(bars[-1]["d"])
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO kline_meta (chart_symbol, period, bar_count, last_bar_time, updated_at)
|
||||||
|
VALUES (?,?,?,?,?)
|
||||||
|
ON CONFLICT(chart_symbol, period) DO UPDATE SET
|
||||||
|
bar_count=excluded.bar_count,
|
||||||
|
last_bar_time=excluded.last_bar_time,
|
||||||
|
updated_at=excluded.updated_at""",
|
||||||
|
(chart_symbol, period, len(bars), last_time, now),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return len(bars)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cached_entry(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
chart_symbol: str,
|
||||||
|
period: str,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
if not chart_symbol:
|
||||||
|
return None
|
||||||
|
ensure_kline_tables(conn)
|
||||||
|
meta = load_meta(conn, chart_symbol, period)
|
||||||
|
bars = load_bars(conn, chart_symbol, period)
|
||||||
|
if not bars:
|
||||||
|
return None
|
||||||
|
updated_at = meta["updated_at"] if meta else ""
|
||||||
|
return {
|
||||||
|
"bars": bars,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"fresh": is_cache_fresh(period, updated_at),
|
||||||
|
}
|
||||||
+220
-55
@@ -5,6 +5,11 @@
|
|||||||
var chart = null;
|
var chart = null;
|
||||||
var currentPeriod = '15m';
|
var currentPeriod = '15m';
|
||||||
var quoteTimer = null;
|
var quoteTimer = null;
|
||||||
|
var klineTimer = null;
|
||||||
|
var lastData = null;
|
||||||
|
var klineLoading = false;
|
||||||
|
|
||||||
|
var FAST_PERIODS = ['timeshare', '1m', '2m', '5m', '15m', '1h', '2h', '4h'];
|
||||||
|
|
||||||
function getSymbol() {
|
function getSymbol() {
|
||||||
var hidden = document.getElementById('market-symbol-hidden');
|
var hidden = document.getElementById('market-symbol-hidden');
|
||||||
@@ -33,9 +38,40 @@
|
|||||||
down: dark ? '#ff6b7a' : '#dc2626',
|
down: dark ? '#ff6b7a' : '#dc2626',
|
||||||
line: dark ? '#4cc2ff' : '#2563eb',
|
line: dark ? '#4cc2ff' : '#2563eb',
|
||||||
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',
|
||||||
|
sliderFill: dark ? '#4cc2ff' : '#2563eb',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTradingSession() {
|
||||||
|
var d = new Date();
|
||||||
|
var wd = d.getDay();
|
||||||
|
if (wd === 0) return false;
|
||||||
|
if (wd === 6 && d.getHours() < 21) return false;
|
||||||
|
var t = d.getHours() * 60 + d.getMinutes();
|
||||||
|
function inRange(sh, sm, eh, em) {
|
||||||
|
return t >= sh * 60 + sm && t < eh * 60 + em;
|
||||||
|
}
|
||||||
|
if (inRange(9, 0, 11, 30)) return true;
|
||||||
|
if (inRange(13, 30, 15, 0)) return true;
|
||||||
|
if (inRange(21, 0, 24, 0)) return true;
|
||||||
|
if (inRange(0, 0, 2, 30)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function klinePollMs() {
|
||||||
|
if (!isTradingSession()) return 0;
|
||||||
|
if (currentPeriod === 'timeshare' || FAST_PERIODS.indexOf(currentPeriod) >= 0) {
|
||||||
|
return 1000;
|
||||||
|
}
|
||||||
|
if (currentPeriod === 'd' || currentPeriod === 'w') return 30000;
|
||||||
|
return 5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotePollMs() {
|
||||||
|
return isTradingSession() ? 1000 : 10000;
|
||||||
|
}
|
||||||
|
|
||||||
function initChart() {
|
function initChart() {
|
||||||
if (!chartEl || !window.echarts) return;
|
if (!chartEl || !window.echarts) return;
|
||||||
chart = echarts.init(chartEl);
|
chart = echarts.init(chartEl);
|
||||||
@@ -45,34 +81,76 @@
|
|||||||
document.addEventListener('click', function (e) {
|
document.addEventListener('click', function (e) {
|
||||||
if (e.target.closest('[data-theme-pick]')) {
|
if (e.target.closest('[data-theme-pick]')) {
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
if (chart && lastData) renderChart(lastData);
|
if (chart && lastData) renderChart(lastData, true);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var lastData = null;
|
function getDataZoom(c, preserve) {
|
||||||
|
var defStart = currentPeriod === 'timeshare' ? 60 : 75;
|
||||||
|
var zoom = [
|
||||||
|
{
|
||||||
|
type: 'inside',
|
||||||
|
xAxisIndex: [0, 1],
|
||||||
|
start: defStart,
|
||||||
|
end: 100,
|
||||||
|
zoomOnMouseWheel: true,
|
||||||
|
moveOnMouseMove: true,
|
||||||
|
moveOnMouseWheel: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'slider',
|
||||||
|
xAxisIndex: [0, 1],
|
||||||
|
start: defStart,
|
||||||
|
end: 100,
|
||||||
|
height: 22,
|
||||||
|
bottom: 4,
|
||||||
|
borderColor: c.grid,
|
||||||
|
backgroundColor: c.bg,
|
||||||
|
fillerColor: c.area,
|
||||||
|
handleStyle: { color: c.sliderFill },
|
||||||
|
dataBackground: {
|
||||||
|
lineStyle: { color: c.grid },
|
||||||
|
areaStyle: { color: c.area },
|
||||||
|
},
|
||||||
|
textStyle: { color: c.text, fontSize: 10 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (preserve && chart) {
|
||||||
|
var opt = chart.getOption();
|
||||||
|
if (opt && opt.dataZoom) {
|
||||||
|
opt.dataZoom.forEach(function (z, i) {
|
||||||
|
if (zoom[i] && z.start != null && z.end != null) {
|
||||||
|
zoom[i].start = z.start;
|
||||||
|
zoom[i].end = z.end;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return zoom;
|
||||||
|
}
|
||||||
|
|
||||||
function renderChart(data) {
|
function renderChart(data, preserveZoom) {
|
||||||
if (!chart) return;
|
if (!chart) return;
|
||||||
lastData = data;
|
lastData = data;
|
||||||
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 dataZoom = getDataZoom(c, preserveZoom);
|
||||||
|
var grids = [
|
||||||
|
{ left: 56, right: 20, top: 44, height: '50%' },
|
||||||
|
{ left: 56, right: 20, top: '66%', height: '14%' },
|
||||||
|
];
|
||||||
|
|
||||||
if (isLine) {
|
var base = {
|
||||||
var closes = bars.map(function (b) { return b.close; });
|
|
||||||
var vols = bars.map(function (b) { return b.volume; });
|
|
||||||
chart.setOption({
|
|
||||||
backgroundColor: c.bg,
|
backgroundColor: c.bg,
|
||||||
animation: false,
|
animation: false,
|
||||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
|
||||||
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
axisPointer: { link: [{ xAxisIndex: 'all' }] },
|
||||||
grid: [
|
dataZoom: dataZoom,
|
||||||
{ left: 56, right: 16, top: 40, height: '58%' },
|
grid: grids,
|
||||||
{ left: 56, right: 16, top: '72%', height: '18%' },
|
|
||||||
],
|
|
||||||
xAxis: [
|
xAxis: [
|
||||||
{ type: 'category', data: times, gridIndex: 0, axisLabel: { color: c.text, fontSize: 10 }, axisLine: { lineStyle: { color: c.grid } } },
|
{ 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 } } },
|
{ type: 'category', data: times, gridIndex: 1, axisLabel: { show: false }, axisLine: { lineStyle: { color: c.grid } } },
|
||||||
@@ -81,6 +159,12 @@
|
|||||||
{ 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 closes = bars.map(function (b) { return b.close; });
|
||||||
|
var vols = bars.map(function (b) { return b.volume; });
|
||||||
|
chart.setOption(Object.assign(base, {
|
||||||
series: [
|
series: [
|
||||||
{
|
{
|
||||||
name: '价格',
|
name: '价格',
|
||||||
@@ -102,19 +186,17 @@
|
|||||||
yAxisIndex: 1,
|
yAxisIndex: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}, true);
|
}), true);
|
||||||
} else {
|
} else {
|
||||||
var candle = bars.map(function (b) { return [b.open, b.close, b.low, b.high]; });
|
var candle = bars.map(function (b) { return [b.open, b.close, b.low, b.high]; });
|
||||||
var vols = bars.map(function (b, i) {
|
var vols = bars.map(function (b) {
|
||||||
var up = b.close >= b.open;
|
var up = b.close >= b.open;
|
||||||
return {
|
return {
|
||||||
value: b.volume,
|
value: b.volume,
|
||||||
itemStyle: { color: up ? c.up : c.down, opacity: 0.65 },
|
itemStyle: { color: up ? c.up : c.down, opacity: 0.65 },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
chart.setOption({
|
chart.setOption(Object.assign(base, {
|
||||||
backgroundColor: c.bg,
|
|
||||||
animation: false,
|
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
axisPointer: { type: 'cross' },
|
axisPointer: { type: 'cross' },
|
||||||
@@ -132,19 +214,6 @@
|
|||||||
].join('<br/>');
|
].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: [
|
series: [
|
||||||
{
|
{
|
||||||
name: 'K线',
|
name: 'K线',
|
||||||
@@ -167,11 +236,13 @@
|
|||||||
yAxisIndex: 1,
|
yAxisIndex: 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}, true);
|
}), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
var title = (data.chart_symbol || data.symbol || '') + ' · ' + periodLabel(data.period);
|
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 } } });
|
chart.setOption({
|
||||||
|
title: { text: title, left: 12, top: 8, textStyle: { color: c.title, fontSize: 13, fontWeight: 600 } },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function periodLabel(key) {
|
function periodLabel(key) {
|
||||||
@@ -182,26 +253,61 @@
|
|||||||
return key;
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hideEmptyOverlay() {
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.style.display = '';
|
||||||
|
}
|
||||||
|
if (wrapEl) wrapEl.classList.add('has-data');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmptyOverlay(text) {
|
||||||
|
if (emptyEl) {
|
||||||
|
emptyEl.textContent = text;
|
||||||
|
emptyEl.style.display = 'flex';
|
||||||
|
}
|
||||||
|
if (wrapEl) wrapEl.classList.remove('has-data');
|
||||||
|
}
|
||||||
|
|
||||||
function setLoading(on) {
|
function setLoading(on) {
|
||||||
var btn = document.getElementById('market-load-btn');
|
var btn = document.getElementById('market-load-btn');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.disabled = on;
|
btn.disabled = on;
|
||||||
btn.textContent = on ? '加载中…' : '查看';
|
btn.textContent = on ? '加载中…' : '查看';
|
||||||
}
|
}
|
||||||
if (emptyEl && on) {
|
if (on) {
|
||||||
emptyEl.textContent = '加载中…';
|
showEmptyOverlay('加载中…');
|
||||||
emptyEl.style.display = 'flex';
|
} else if (lastData) {
|
||||||
|
hideEmptyOverlay();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadKline() {
|
function updateRefreshHint() {
|
||||||
var symbol = getSymbol();
|
var el = document.getElementById('market-refresh-hint');
|
||||||
if (!symbol) {
|
if (!el) return;
|
||||||
alert('请先选择或输入合约代码');
|
if (!getSymbol()) {
|
||||||
|
el.textContent = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
if (isTradingSession()) {
|
||||||
if (wrapEl) wrapEl.classList.remove('has-data');
|
var ms = klinePollMs();
|
||||||
|
var src = lastData && lastData.source === 'local' ? ' · 本地缓存' : '';
|
||||||
|
el.textContent = ms === 1000
|
||||||
|
? '交易中 · 1秒刷新' + src
|
||||||
|
: '交易中 · 自动刷新' + src;
|
||||||
|
} else {
|
||||||
|
el.textContent = '非交易时段 · 暂停高频刷新';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadKline(silent) {
|
||||||
|
var symbol = getSymbol();
|
||||||
|
if (!symbol) {
|
||||||
|
if (!silent) alert('请先选择或输入合约代码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (klineLoading) return;
|
||||||
|
klineLoading = true;
|
||||||
|
if (!silent) setLoading(true);
|
||||||
|
|
||||||
var url = '/api/kline?symbol=' + encodeURIComponent(symbol) + '&period=' + encodeURIComponent(currentPeriod);
|
var url = '/api/kline?symbol=' + encodeURIComponent(symbol) + '&period=' + encodeURIComponent(currentPeriod);
|
||||||
fetch(url)
|
fetch(url)
|
||||||
@@ -209,22 +315,23 @@
|
|||||||
return r.json().then(function (j) { return { ok: r.ok, data: j }; });
|
return r.json().then(function (j) { return { ok: r.ok, data: j }; });
|
||||||
})
|
})
|
||||||
.then(function (res) {
|
.then(function (res) {
|
||||||
if (!res.ok) {
|
if (!res.ok) throw new Error(res.data.error || '加载失败');
|
||||||
throw new Error(res.data.error || '加载失败');
|
hideEmptyOverlay();
|
||||||
}
|
renderChart(res.data, silent);
|
||||||
if (wrapEl) wrapEl.classList.add('has-data');
|
|
||||||
renderChart(res.data);
|
|
||||||
updateQuoteMeta(res.data);
|
updateQuoteMeta(res.data);
|
||||||
startQuotePoll();
|
updateRefreshHint();
|
||||||
|
if (!quoteTimer) startQuotePoll();
|
||||||
|
if (!klineTimer) startKlinePoll();
|
||||||
})
|
})
|
||||||
.catch(function (err) {
|
.catch(function (err) {
|
||||||
if (emptyEl) {
|
if (!silent) {
|
||||||
emptyEl.textContent = err.message || '加载失败';
|
showEmptyOverlay(err.message || '加载失败');
|
||||||
emptyEl.style.display = 'flex';
|
|
||||||
}
|
}
|
||||||
if (wrapEl) wrapEl.classList.remove('has-data');
|
|
||||||
})
|
})
|
||||||
.finally(function () { setLoading(false); });
|
.finally(function () {
|
||||||
|
klineLoading = false;
|
||||||
|
if (!silent) setLoading(false);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateQuoteMeta(data) {
|
function updateQuoteMeta(data) {
|
||||||
@@ -261,7 +368,48 @@
|
|||||||
function startQuotePoll() {
|
function startQuotePoll() {
|
||||||
if (quoteTimer) clearInterval(quoteTimer);
|
if (quoteTimer) clearInterval(quoteTimer);
|
||||||
loadQuote();
|
loadQuote();
|
||||||
quoteTimer = setInterval(loadQuote, 5000);
|
var ms = quotePollMs();
|
||||||
|
if (ms > 0) quoteTimer = setInterval(loadQuote, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startKlinePoll() {
|
||||||
|
if (klineTimer) clearInterval(klineTimer);
|
||||||
|
var ms = klinePollMs();
|
||||||
|
if (ms > 0 && getSymbol()) {
|
||||||
|
klineTimer = setInterval(function () {
|
||||||
|
loadKline(true);
|
||||||
|
updateRefreshHint();
|
||||||
|
}, ms);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restartPollers() {
|
||||||
|
startQuotePoll();
|
||||||
|
startKlinePoll();
|
||||||
|
updateRefreshHint();
|
||||||
|
}
|
||||||
|
|
||||||
|
function shiftDataZoom(delta) {
|
||||||
|
if (!chart) return;
|
||||||
|
var opt = chart.getOption();
|
||||||
|
if (!opt || !opt.dataZoom || !opt.dataZoom.length) return;
|
||||||
|
var z = opt.dataZoom[0];
|
||||||
|
var span = (z.end - z.start) || 20;
|
||||||
|
var newSpan = Math.max(5, Math.min(100, span + delta));
|
||||||
|
var center = (z.start + z.end) / 2;
|
||||||
|
var start = Math.max(0, center - newSpan / 2);
|
||||||
|
var end = Math.min(100, center + newSpan / 2);
|
||||||
|
if (end - start < newSpan) {
|
||||||
|
if (start === 0) end = newSpan;
|
||||||
|
else start = end - newSpan;
|
||||||
|
}
|
||||||
|
chart.dispatchAction({ type: 'dataZoom', start: start, end: end });
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetDataZoom() {
|
||||||
|
if (!chart) return;
|
||||||
|
var start = currentPeriod === 'timeshare' ? 60 : 75;
|
||||||
|
chart.dispatchAction({ type: 'dataZoom', start: start, end: 100 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindPeriodTabs() {
|
function bindPeriodTabs() {
|
||||||
@@ -273,25 +421,42 @@
|
|||||||
tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); });
|
tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); });
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
currentPeriod = btn.getAttribute('data-period') || '15m';
|
currentPeriod = btn.getAttribute('data-period') || '15m';
|
||||||
if (getSymbol()) loadKline();
|
restartPollers();
|
||||||
|
if (getSymbol()) loadKline(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bindZoomButtons() {
|
||||||
|
var zoomIn = document.getElementById('chart-zoom-in');
|
||||||
|
var zoomOut = document.getElementById('chart-zoom-out');
|
||||||
|
var zoomReset = document.getElementById('chart-zoom-reset');
|
||||||
|
if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-12); });
|
||||||
|
if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(12); });
|
||||||
|
if (zoomReset) zoomReset.addEventListener('click', resetDataZoom);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
initChart();
|
initChart();
|
||||||
bindPeriodTabs();
|
bindPeriodTabs();
|
||||||
|
bindZoomButtons();
|
||||||
|
|
||||||
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';
|
||||||
|
|
||||||
var loadBtn = document.getElementById('market-load-btn');
|
var loadBtn = document.getElementById('market-load-btn');
|
||||||
if (loadBtn) loadBtn.addEventListener('click', loadKline);
|
if (loadBtn) loadBtn.addEventListener('click', function () {
|
||||||
|
restartPollers();
|
||||||
|
loadKline(false);
|
||||||
|
});
|
||||||
|
|
||||||
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 (hidden && hidden.value) {
|
if (hidden && hidden.value) {
|
||||||
if (input && !input.value) input.value = hidden.value;
|
if (input && !input.value) input.value = hidden.value;
|
||||||
loadKline();
|
restartPollers();
|
||||||
|
loadKline(false);
|
||||||
|
} else {
|
||||||
|
updateRefreshHint();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+27
-10
@@ -388,21 +388,38 @@
|
|||||||
.pos-del{font-size:.75rem;padding:.35rem .65rem}
|
.pos-del{font-size:.75rem;padding:.35rem .65rem}
|
||||||
.trade-toolbar{display:flex;align-items:center;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
|
.trade-toolbar{display:flex;align-items:center;gap:1rem;margin-bottom:1rem;flex-wrap:wrap}
|
||||||
.trade-switch-label{
|
.trade-switch-label{
|
||||||
display:flex;align-items:center;gap:.35rem;
|
display:flex;align-items:center;gap:.4rem;
|
||||||
font-size:.68rem;color:var(--text-muted);
|
font-size:.78rem;color:var(--text-muted);
|
||||||
white-space:nowrap;margin-bottom:.65rem;cursor:pointer;
|
white-space:normal;margin-bottom:.65rem;cursor:pointer;
|
||||||
|
line-height:1.45;max-width:100%;
|
||||||
}
|
}
|
||||||
.trade-switch-label span{line-height:1}
|
.trade-switch-label span{line-height:1.45;color:var(--text-muted)}
|
||||||
.trade-switch-label input{flex-shrink:0}
|
.trade-switch-label input{flex-shrink:0;width:auto}
|
||||||
.trade-table-wrap{overflow-x:auto}
|
.trade-table-wrap{
|
||||||
.trade-table{font-size:.8rem}
|
overflow:auto;
|
||||||
.trade-table th{font-size:.75rem;padding:.55rem .45rem}
|
max-height:420px;
|
||||||
.trade-table td{padding:.45rem .4rem;vertical-align:middle}
|
width:100%;
|
||||||
|
-webkit-overflow-scrolling:touch;
|
||||||
|
border-radius:10px;
|
||||||
|
border:1px solid var(--table-border);
|
||||||
|
background:var(--card-inner);
|
||||||
|
}
|
||||||
|
.trade-table{font-size:.8rem;width:max-content;min-width:100%;table-layout:auto}
|
||||||
|
.trade-table th{font-size:.75rem;padding:.55rem .45rem;white-space:nowrap;background:var(--card-inner)}
|
||||||
|
.trade-table td{padding:.45rem .4rem;vertical-align:middle;white-space:nowrap;background:var(--card-inner)}
|
||||||
|
.trade-table th:last-child,
|
||||||
|
.trade-table td:last-child{
|
||||||
|
position:sticky;right:0;z-index:3;
|
||||||
|
box-shadow:-6px 0 10px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
.trade-table thead th:last-child{z-index:4}
|
||||||
.trade-table input,.trade-table select{
|
.trade-table input,.trade-table select{
|
||||||
padding:.35rem .45rem;font-size:.78rem;border-radius:6px;width:100%;min-width:0;
|
padding:.35rem .45rem;font-size:.78rem;border-radius:6px;width:100%;min-width:0;
|
||||||
}
|
}
|
||||||
.trade-table .cell-readonly{color:var(--text-primary)}
|
.trade-table .cell-readonly{color:var(--text-primary)}
|
||||||
.trade-actions{display:flex;gap:.35rem;flex-wrap:wrap}
|
.records-trade-card{overflow:visible}
|
||||||
|
.records-trade-card .card-body{overflow:visible}
|
||||||
|
.trade-actions{display:flex;gap:.35rem;flex-wrap:wrap;align-items:center;min-width:148px}
|
||||||
.trade-actions a,.trade-actions button{font-size:.72rem;padding:.3rem .55rem;border-radius:6px;text-decoration:none;border:none;cursor:pointer}
|
.trade-actions a,.trade-actions button{font-size:.72rem;padding:.3rem .55rem;border-radius:6px;text-decoration:none;border:none;cursor:pointer}
|
||||||
.btn-fill{background:var(--dir-bg);color:var(--accent)}
|
.btn-fill{background:var(--dir-bg);color:var(--accent)}
|
||||||
.btn-verify{background:var(--nav-active);color:#fff}
|
.btn-verify{background:var(--nav-active);color:#fff}
|
||||||
|
|||||||
+22
-1
@@ -26,11 +26,19 @@
|
|||||||
<span class="market-quote-price" id="market-quote-price">—</span>
|
<span class="market-quote-price" id="market-quote-price">—</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-zoom">
|
||||||
|
<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 chart-zoom-reset" id="chart-zoom-reset">重置</button>
|
||||||
|
</div>
|
||||||
|
<span class="market-refresh-hint text-muted" id="market-refresh-hint"></span>
|
||||||
|
</div>
|
||||||
<div class="market-chart-wrap">
|
<div class="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>
|
</div>
|
||||||
<p class="hint">数据来源:新浪财经。分时图为当日分钟走势;2分/2小时/周线由基础周期合成。</p>
|
<p class="hint">数据来源:新浪财经。支持滚轮/拖拽缩放 K 线;交易时段内行情与 K 线约 1 秒刷新。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -61,6 +69,19 @@
|
|||||||
}
|
}
|
||||||
.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-chart-toolbar{
|
||||||
|
display:flex;align-items:center;justify-content:space-between;gap:.75rem;
|
||||||
|
margin-bottom:.5rem;flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
.market-chart-zoom{display:flex;gap:.35rem;align-items:center}
|
||||||
|
.chart-zoom-btn{
|
||||||
|
width:32px;height:32px;padding:0;border-radius:8px;
|
||||||
|
border:1px solid var(--input-border);background:var(--toggle-bg);
|
||||||
|
color:var(--text-primary);font-size:1rem;line-height:1;cursor:pointer;
|
||||||
|
}
|
||||||
|
.chart-zoom-btn:hover{border-color:var(--accent);color:var(--accent)}
|
||||||
|
.chart-zoom-reset{width:auto;padding:0 .65rem;font-size:.75rem}
|
||||||
|
.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);min-height:420px;
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}交易记录与复盘 - 国内期货监控系统{% endblock %}
|
{% block title %}交易记录与复盘 - 国内期货监控系统{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card" style="margin-bottom:1.25rem">
|
<div class="card records-trade-card" style="margin-bottom:1.25rem">
|
||||||
<h2>交易记录</h2>
|
<h2>交易记录</h2>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<label class="trade-switch-label">
|
<label class="trade-switch-label">
|
||||||
<input type="checkbox" id="trade-edit-switch">
|
<input type="checkbox" id="trade-edit-switch">
|
||||||
<span>修改/核对开关(开启后可编辑关键字段)</span>
|
<span>修改/核对开关(开启后可编辑关键字段)</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="trade-table-wrap card-scroll">
|
<div class="trade-table-wrap">
|
||||||
<table class="trade-table">
|
<table class="trade-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user