fix: TradingView K线图表并修复品种推荐为空。

- 行情页改用 Lightweight Charts 标准蜡烛图(红跌绿涨)
- 修复 fee_rates 缺 source 列导致推荐刷新失败
- 空缓存自动重试,持仓页实时兜底计算推荐列表

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-25 12:33:49 +08:00
parent 074551490f
commit 32f1fa2c66
8 changed files with 458 additions and 527 deletions
+21
View File
@@ -37,6 +37,26 @@ def _get_db():
return connect_db() return connect_db()
def ensure_fee_rates_schema(conn=None) -> None:
"""补齐 fee_rates 表结构(旧库可能缺少 source 列)。"""
close = False
if conn is None:
conn = _get_db()
close = True
try:
for sql in (
"ALTER TABLE fee_rates ADD COLUMN source TEXT DEFAULT 'local'",
):
try:
conn.execute(sql)
except sqlite3.OperationalError:
pass
conn.commit()
finally:
if close:
conn.close()
def get_setting(key: str, default: str = "") -> str: def get_setting(key: str, default: str = "") -> str:
conn = _get_db() conn = _get_db()
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone() row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
@@ -111,6 +131,7 @@ def get_fee_spec(ths_code: str, *, trading_mode: str = "simulation") -> dict:
mult = get_contract_spec(ths_code)["mult"] mult = get_contract_spec(ths_code)["mult"]
conn = _get_db() conn = _get_db()
ensure_fee_rates_schema(conn)
row = conn.execute( row = conn.execute(
"SELECT * FROM fee_rates WHERE product=? AND source='ctp'", "SELECT * FROM fee_rates WHERE product=? AND source='ctp'",
(product,), (product,),
+22 -2
View File
@@ -22,9 +22,9 @@ from position_sizing import (
) )
from recommend_store import ( from recommend_store import (
load_recommend_cache, load_recommend_cache,
recommend_cache_needs_refresh,
recommend_payload, recommend_payload,
refresh_recommend_cache, refresh_recommend_cache,
rows_missing_max_lots,
) )
from recommend_stream import recommend_hub, start_recommend_worker from recommend_stream import recommend_hub, start_recommend_worker
from ctp_reconnect import start_ctp_reconnect_worker from ctp_reconnect import start_ctp_reconnect_worker
@@ -407,11 +407,31 @@ def install_trading(app, *, login_required, require_nav, get_db, get_setting, se
sizing = get_sizing_mode(get_setting) sizing = get_sizing_mode(get_setting)
max_pct = get_max_margin_pct(get_setting) max_pct = get_max_margin_pct(get_setting)
rec_loaded = load_recommend_cache(conn) rec_loaded = load_recommend_cache(conn)
if rec_loaded.get("stale") or rows_missing_max_lots(rec_loaded.get("rows") or []): if recommend_cache_needs_refresh(rec_loaded, capital=capital):
try:
refresh_recommend_cache( refresh_recommend_cache(
conn, capital, _main_quote, trading_mode=mode, max_margin_pct=max_pct, conn, capital, _main_quote, trading_mode=mode, max_margin_pct=max_pct,
) )
except Exception as exc:
logger.warning("positions recommend refresh failed: %s", exc)
rec_cache = recommend_payload(conn, live_capital=capital, max_margin_pct=max_pct) rec_cache = recommend_payload(conn, live_capital=capital, max_margin_pct=max_pct)
if not rec_cache.get("rows") and capital > 0:
try:
from product_recommend import list_product_recommendations
from recommend_store import enrich_recommend_rows, filter_affordable_recommendations
live_rows = filter_affordable_recommendations(
list_product_recommendations(
capital, _main_quote, max_margin_pct=max_pct, trading_mode=mode,
)
)
if live_rows:
rec_cache["rows"] = enrich_recommend_rows(
live_rows, capital, max_margin_pct=max_pct,
)
rec_cache["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
except Exception as exc:
logger.warning("positions recommend live fallback failed: %s", exc)
return render_template( return render_template(
"trade.html", "trade.html",
trading_mode=mode, trading_mode=mode,
+28 -9
View File
@@ -220,20 +220,39 @@ def _timeshare_session(bars: list) -> list:
def bars_to_api(bars: list) -> list[dict]: def bars_to_api(bars: list) -> list[dict]:
"""转为前端图表 JSON。""" """转为前端图表 JSON(去重、排序、数值规范化)"""
result = [] result: list[dict] = []
seen: dict[int, dict] = {}
for bar in bars: for bar in bars:
dt = _bar_datetime(bar) dt = _bar_datetime(bar)
ts = int(dt.timestamp() * 1000) if dt else None ts = int(dt.timestamp() * 1000) if dt else None
result.append({ try:
o = float(bar.get("o") or 0)
h = float(bar.get("h") or o)
l = float(bar.get("l") or o)
c = float(bar.get("c") or o)
v = float(bar.get("v") or 0)
except (TypeError, ValueError):
continue
if h < l:
h, l = l, h
h = max(h, o, c)
l = min(l, o, c)
row = {
"time": bar["d"], "time": bar["d"],
"timestamp": ts, "timestamp": ts,
"open": bar["o"], "open": o,
"high": bar["h"], "high": h,
"low": bar["l"], "low": l,
"close": bar["c"], "close": c,
"volume": bar.get("v", 0), "volume": v,
}) }
if ts is not None:
seen[ts] = row
else:
result.append(row)
if seen:
result = [seen[k] for k in sorted(seen.keys())]
return result return result
+19
View File
@@ -1,6 +1,7 @@
"""按账户资金推荐可交易品种(期货核心筛选)。""" """按账户资金推荐可交易品种(期货核心筛选)。"""
from __future__ import annotations from __future__ import annotations
import logging
import math import math
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from typing import Callable, Optional from typing import Callable, Optional
@@ -9,6 +10,8 @@ from contract_specs import get_contract_spec
from fee_specs import calc_fee_breakdown from fee_specs import calc_fee_breakdown
from symbols import PRODUCTS from symbols import PRODUCTS
logger = logging.getLogger(__name__)
def _letters_from_ths(ths_code: str) -> str: def _letters_from_ths(ths_code: str) -> str:
import re import re
@@ -61,9 +64,13 @@ def assess_product_for_capital(
ref_sl = round(p - stop_dist, 4) ref_sl = round(p - stop_dist, 4)
ref_tp = round(p + stop_dist * reward_risk_ratio, 4) ref_tp = round(p + stop_dist * reward_risk_ratio, 4)
fee_ths = ths + "8888" fee_ths = ths + "8888"
try:
fee_info = calc_fee_breakdown( fee_info = calc_fee_breakdown(
fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode, fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode,
) )
except Exception as exc:
logger.debug("recommend fee calc failed %s: %s", ths, exc)
fee_info = {"open_fee": 0.0, "total_fee": 0.0}
can_margin = max_lots >= 1 can_margin = max_lots >= 1
can_risk = cap > 0 and risk_one_lot <= cap * 0.01 can_risk = cap > 0 and risk_one_lot <= cap * 0.01
@@ -109,6 +116,7 @@ def list_product_recommendations(
def _one(product: dict) -> dict: def _one(product: dict) -> dict:
ths = product["ths"] ths = product["ths"]
try:
quote = quote_fn(ths) or {} quote = quote_fn(ths) or {}
price = quote.get("price") price = quote.get("price")
row = assess_product_for_capital( row = assess_product_for_capital(
@@ -119,6 +127,17 @@ def list_product_recommendations(
main_code = (quote.get("ths_code") or "").strip() main_code = (quote.get("ths_code") or "").strip()
row["main_code"] = main_code row["main_code"] = main_code
return row return row
except Exception as exc:
logger.warning("recommend product failed %s: %s", ths, exc)
return {
"ths": ths,
"name": product.get("name") or ths,
"exchange": product.get("exchange") or "",
"status": "no_price",
"status_label": "计算失败",
"main_code": "",
"max_lots": 0,
}
with ThreadPoolExecutor(max_workers=10) as pool: with ThreadPoolExecutor(max_workers=10) as pool:
rows = list(pool.map(_one, PRODUCTS)) rows = list(pool.map(_one, PRODUCTS))
+32 -5
View File
@@ -2,12 +2,16 @@
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import math import math
from datetime import datetime from datetime import datetime
from typing import Callable, Optional from typing import Callable, Optional
from fee_specs import ensure_fee_rates_schema
from product_recommend import list_product_recommendations from product_recommend import list_product_recommendations
logger = logging.getLogger(__name__)
RECOMMEND_CACHE_SQL = """ RECOMMEND_CACHE_SQL = """
CREATE TABLE IF NOT EXISTS product_recommend_cache ( CREATE TABLE IF NOT EXISTS product_recommend_cache (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -34,6 +38,22 @@ def rows_missing_max_lots(rows: list[dict]) -> bool:
return any("max_lots" not in r for r in rows) return any("max_lots" not in r for r in rows)
def recommend_cache_needs_refresh(
cached: dict,
*,
capital: float = 0.0,
) -> bool:
"""是否需要重新拉行情计算推荐列表。"""
if recommend_cache_stale(cached.get("updated_at")):
return True
rows = cached.get("rows") or []
if rows_missing_max_lots(rows):
return True
if float(capital or 0) > 0 and not rows:
return True
return False
def enrich_recommend_rows( def enrich_recommend_rows(
rows: list[dict], rows: list[dict],
capital: float, capital: float,
@@ -81,10 +101,19 @@ def refresh_recommend_cache(
) -> list[dict]: ) -> list[dict]:
"""后台拉行情、筛选并写入数据库。""" """后台拉行情、筛选并写入数据库。"""
ensure_recommend_tables(conn) ensure_recommend_tables(conn)
ensure_fee_rates_schema(conn)
all_rows = list_product_recommendations( all_rows = list_product_recommendations(
capital, quote_fn, max_margin_pct=max_margin_pct, trading_mode=trading_mode, capital, quote_fn, max_margin_pct=max_margin_pct, trading_mode=trading_mode,
) )
rows = filter_affordable_recommendations(all_rows) rows = filter_affordable_recommendations(all_rows)
if not rows and float(capital or 0) > 0:
logger.warning(
"recommend refresh: 0 affordable rows capital=%.2f total=%d no_price=%d blocked=%d",
float(capital or 0),
len(all_rows),
sum(1 for r in all_rows if r.get("status") == "no_price"),
sum(1 for r in all_rows if r.get("status") == "blocked"),
)
now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
conn.execute( conn.execute(
"""INSERT INTO product_recommend_cache (id, capital, rows_json, updated_at) """INSERT INTO product_recommend_cache (id, capital, rows_json, updated_at)
@@ -142,9 +171,7 @@ def recommend_payload(
pct = max(1.0, min(100.0, float(max_margin_pct or 30.0))) pct = max(1.0, min(100.0, float(max_margin_pct or 30.0)))
payload["capital"] = cap payload["capital"] = cap
payload["max_margin_pct"] = pct payload["max_margin_pct"] = pct
payload["rows"] = enrich_recommend_rows( rows = payload.get("rows") or []
payload.get("rows") or [], payload["rows"] = enrich_recommend_rows(rows, cap, max_margin_pct=pct)
cap, payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap)
max_margin_pct=pct,
)
return payload return payload
+2 -5
View File
@@ -12,10 +12,9 @@ from db_conn import connect_db
from kline_stream import sse_format from kline_stream import sse_format
from recommend_store import ( from recommend_store import (
load_recommend_cache, load_recommend_cache,
recommend_cache_stale, recommend_cache_needs_refresh,
recommend_payload, recommend_payload,
refresh_recommend_cache, refresh_recommend_cache,
rows_missing_max_lots,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -78,9 +77,7 @@ def start_recommend_worker(
mode = get_mode_fn() if get_mode_fn else "simulation" mode = get_mode_fn() if get_mode_fn else "simulation"
max_pct = float(get_max_margin_pct_fn()) if get_max_margin_pct_fn else 30.0 max_pct = float(get_max_margin_pct_fn()) if get_max_margin_pct_fn else 30.0
cached = load_recommend_cache(conn) cached = load_recommend_cache(conn)
if recommend_cache_stale(cached.get("updated_at")) or rows_missing_max_lots( if recommend_cache_needs_refresh(cached, capital=capital):
cached.get("rows") or [],
):
refresh_recommend_cache( refresh_recommend_cache(
conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct, conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct,
) )
+316 -488
View File
@@ -3,14 +3,36 @@
var emptyEl = document.getElementById('market-chart-empty'); var emptyEl = document.getElementById('market-chart-empty');
var wrapEl = document.getElementById('market-chart-wrap'); var wrapEl = document.getElementById('market-chart-wrap');
var chart = null; var chart = null;
var candleSeries = null;
var volumeSeries = null;
var areaSeries = null;
var ma21Series = null;
var ma55Series = null;
var prevCloseLine = null;
var resizeObs = null;
var currentPeriod = '15m'; var currentPeriod = '15m';
var currentChartMode = '';
var klineSource = null; var klineSource = null;
var streamActive = false; var streamActive = false;
var reconnectTimer = null; var reconnectTimer = null;
var lastData = null; var lastData = null;
var lastPrevClose = null; var lastPrevClose = null;
var chartOpts = { prevClose: false, ma: false, gapDay: false }; var chartOpts = { prevClose: false, ma: false, gapDay: false };
var dataZoomBound = false; var followingLatest = true;
var DEFAULT_VISIBLE_BARS = 80;
var PERIOD_SECONDS = {
timeshare: 60,
'1m': 60,
'2m': 120,
'5m': 300,
'15m': 900,
'1h': 3600,
'2h': 7200,
'4h': 14400,
d: 86400,
w: 604800,
};
function getSymbol() { function getSymbol() {
var hidden = document.getElementById('market-symbol-hidden'); var hidden = document.getElementById('market-symbol-hidden');
@@ -31,17 +53,14 @@
function themeColors() { function themeColors() {
var dark = document.documentElement.getAttribute('data-theme') !== 'light'; var dark = document.documentElement.getAttribute('data-theme') !== 'light';
return { return {
bg: dark ? '#0a0c14' : '#f4f7fc', bg: dark ? '#0a0c14' : '#ffffff',
text: dark ? '#a8b0c8' : '#5c6578', text: dark ? '#a8b0c8' : '#5c6578',
title: dark ? '#e8eaf6' : '#1a2233', grid: dark ? '#1e2640' : '#e8edf5',
grid: dark ? '#1a2038' : '#e2e8f0', up: dark ? '#26a69a' : '#089981',
up: dark ? '#4cd97f' : '#15803d', down: dark ? '#ef5350' : '#f23645',
down: dark ? '#ff6b7a' : '#dc2626', line: dark ? '#4cc2ff' : '#2962ff',
line: dark ? '#4cc2ff' : '#2563eb', areaTop: dark ? 'rgba(76,194,255,0.28)' : 'rgba(41,98,255,0.22)',
area: dark ? 'rgba(76,194,255,0.12)' : 'rgba(37,99,235,0.1)', ma21: dark ? '#ffb347' : '#f7931a',
slider: dark ? '#1e2640' : '#cbd5e1',
sliderFill: dark ? '#4cc2ff' : '#2563eb',
ma21: dark ? '#ffb347' : '#d97706',
ma55: dark ? '#c084fc' : '#7c3aed', ma55: dark ? '#c084fc' : '#7c3aed',
prevClose: dark ? '#fbbf24' : '#b45309', prevClose: dark ? '#fbbf24' : '#b45309',
}; };
@@ -63,484 +82,272 @@
return false; return false;
} }
function calcMA(period, closes) { function barUnixTime(bar) {
var result = []; if (bar.timestamp) return Math.floor(bar.timestamp / 1000);
for (var i = 0; i < closes.length; i++) { if (bar.time) {
if (i < period - 1) { var d = new Date(String(bar.time).replace(' ', 'T'));
result.push(null); if (!isNaN(d.getTime())) return Math.floor(d.getTime() / 1000);
continue;
} }
return null;
}
function prepareBars(bars, periodKey) {
var out = [];
var gapDay = chartOpts.gapDay;
var seen = {};
var gapBase = null;
var step = PERIOD_SECONDS[periodKey] || 60;
for (var i = 0; i < bars.length; i++) {
var b = bars[i];
var o = Number(b.open);
var h = Number(b.high);
var l = Number(b.low);
var c = Number(b.close);
if (!isFinite(o) || !isFinite(c)) continue;
if (!isFinite(h)) h = Math.max(o, c);
if (!isFinite(l)) l = Math.min(o, c);
h = Math.max(h, o, c);
l = Math.min(l, o, c);
var t;
if (gapDay) {
if (gapBase == null) {
gapBase = b.timestamp ? Math.floor(b.timestamp / 1000) : 946684800;
}
t = gapBase + out.length * step;
} else {
t = barUnixTime(b);
}
if (t == null || seen[t]) continue;
seen[t] = true;
out.push({
time: t,
open: o,
high: h,
low: l,
close: c,
volume: Number(b.volume) || 0,
rawTime: b.time,
});
}
return out;
}
function calcMA(period, bars) {
var result = [];
for (var i = 0; i < bars.length; i++) {
if (i < period - 1) continue;
var sum = 0; var sum = 0;
for (var j = 0; j < period; j++) sum += closes[i - j]; for (var j = 0; j < period; j++) sum += bars[i - j].close;
result.push(+(sum / period).toFixed(4)); result.push({ time: bars[i].time, value: +(sum / period).toFixed(4) });
} }
return result; return result;
} }
function findMaCrosses(ma21, ma55) { function destroyChart() {
var points = []; if (resizeObs) {
for (var i = 1; i < ma21.length; i++) { resizeObs.disconnect();
if (ma21[i] == null || ma55[i] == null || ma21[i - 1] == null || ma55[i - 1] == null) continue; resizeObs = null;
var prev = ma21[i - 1] - ma55[i - 1];
var curr = ma21[i] - ma55[i];
if (prev <= 0 && curr > 0) points.push({ i: i, type: 'golden' });
if (prev >= 0 && curr < 0) points.push({ i: i, type: 'death' });
} }
return points; if (chart) {
chart.remove();
chart = null;
}
candleSeries = null;
volumeSeries = null;
areaSeries = null;
ma21Series = null;
ma55Series = null;
prevCloseLine = null;
currentChartMode = '';
} }
function getDefaultZoomStart() { function buildChart(mode) {
return currentPeriod === 'timeshare' ? 60 : 75; destroyChart();
} if (!chartEl || !window.LightweightCharts) return;
function getZoomRange() {
if (!chart) return { start: getDefaultZoomStart(), end: 100 };
var opt = chart.getOption();
if (opt && opt.dataZoom && opt.dataZoom.length) {
var z = opt.dataZoom[0];
if (z.start != null && z.end != null) return { start: z.start, end: z.end };
}
return { start: getDefaultZoomStart(), end: 100 };
}
function visibleIndices(bars, zoom) {
var n = bars.length;
if (!n) return { start: 0, end: 0 };
var start = Math.floor(n * zoom.start / 100);
var end = Math.min(n - 1, Math.ceil(n * zoom.end / 100) - 1);
if (end < start) end = start;
return { start: start, end: end };
}
function computeVisibleHL(bars, startIdx, endIdx) {
var maxH = -Infinity;
var minL = Infinity;
var maxI = startIdx;
var minI = startIdx;
for (var i = startIdx; i <= endIdx; i++) {
var b = bars[i];
if (!b) continue;
if (b.high > maxH) { maxH = b.high; maxI = i; }
if (b.low < minL) { minL = b.low; minI = i; }
}
if (maxH === -Infinity) return null;
return { maxH: maxH, minL: minL, maxI: maxI, minI: minI };
}
function buildHLMarkPoint(hl, c) {
if (!hl) return { data: [] };
return {
symbol: 'circle',
symbolSize: 6,
data: [
{
name: '高',
xAxis: hl.maxI,
yAxis: hl.maxH,
value: hl.maxH.toFixed(2),
itemStyle: { color: c.up },
label: {
show: true,
formatter: '高 ' + hl.maxH.toFixed(2),
color: c.up,
fontSize: 11,
position: 'top',
},
},
{
name: '低',
xAxis: hl.minI,
yAxis: hl.minL,
value: hl.minL.toFixed(2),
itemStyle: { color: c.down },
label: {
show: true,
formatter: '低 ' + hl.minL.toFixed(2),
color: c.down,
fontSize: 11,
position: 'bottom',
},
},
],
};
}
function buildMaCrossMarkPoint(crosses, bars, c) {
if (!crosses.length) return { data: [] };
return {
symbol: 'pin',
symbolSize: 28,
data: crosses.map(function (p) {
var b = bars[p.i];
return {
name: p.type === 'golden' ? '金叉' : '死叉',
xAxis: p.i,
yAxis: b ? b.close : 0,
itemStyle: { color: p.type === 'golden' ? c.up : c.down },
label: {
show: true,
formatter: p.type === 'golden' ? '金叉' : '死叉',
fontSize: 10,
color: p.type === 'golden' ? c.up : c.down,
},
};
}),
};
}
function buildPrevCloseMarkLine(prevClose, c) {
if (prevClose == null) return { data: [] };
var v = Number(prevClose);
return {
silent: true,
symbol: 'none',
lineStyle: { color: c.prevClose, type: 'dashed', width: 1 },
label: {
show: true,
formatter: '昨收 ' + v.toFixed(2),
color: c.prevClose,
fontSize: 10,
},
data: [{ yAxis: v }],
};
}
function initChart() {
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, true);
}, 100);
}
});
}
function bindDataZoomHL() {
if (!chart || dataZoomBound) return;
dataZoomBound = true;
chart.on('dataZoom', function () {
updateVisibleHLMark();
});
}
function updateVisibleHLMark() {
if (!chart || !lastData || !lastData.bars) return;
var bars = lastData.bars;
var zoom = getZoomRange();
var idx = visibleIndices(bars, zoom);
var hl = computeVisibleHL(bars, idx.start, idx.end);
var c = themeColors(); var c = themeColors();
chart.setOption({ var w = chartEl.clientWidth || 600;
series: [{ id: 'main', markPoint: buildHLMarkPoint(hl, c) }], var h = chartEl.clientHeight || 400;
}); chart = LightweightCharts.createChart(chartEl, {
} width: w,
height: h,
function getDataZoom(c, preserve) { layout: {
var defStart = getDefaultZoomStart(); background: { type: 'solid', color: c.bg },
var xZoom = { textColor: c.text,
type: 'inside', fontSize: 11,
id: 'dzInsideX',
xAxisIndex: [0, 1],
start: defStart,
end: 100,
filterMode: 'none',
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false,
preventDefaultMouseMove: true,
minSpan: 2,
};
var yZoom = {
type: 'inside',
id: 'dzInsideY',
yAxisIndex: [0],
orient: 'vertical',
filterMode: 'none',
zoomOnMouseWheel: true,
moveOnMouseMove: true,
preventDefaultMouseMove: true,
};
var slider = {
type: 'slider',
id: 'dzSlider',
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, opacity: 0.35 },
areaStyle: { color: c.area },
}, },
textStyle: { color: c.text, fontSize: 10 }, grid: {
filterMode: 'none', vertLines: { color: c.grid, style: 1 },
brushSelect: false, horzLines: { color: c.grid, style: 1 },
}; },
var zoom = [xZoom, yZoom, slider]; crosshair: {
if (preserve && chart) { mode: LightweightCharts.CrosshairMode.Normal,
var opt = chart.getOption(); vertLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid },
if (opt && opt.dataZoom) { horzLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid },
opt.dataZoom.forEach(function (z) { },
if (!z.id) return; rightPriceScale: {
var target = zoom.find(function (t) { return t.id === z.id; }); borderColor: c.grid,
if (target && z.start != null && z.end != null) { scaleMargins: mode === 'line' ? { top: 0.08, bottom: 0.08 } : { top: 0.05, bottom: 0.22 },
target.start = z.start; },
target.end = z.end; timeScale: {
} borderColor: c.grid,
timeVisible: true,
secondsVisible: false,
rightOffset: 8,
barSpacing: 10,
minBarSpacing: 4,
fixLeftEdge: false,
fixRightEdge: false,
},
handleScroll: { mouseWheel: true, pressedMouseMove: true, horzTouchDrag: true, vertTouchDrag: true },
handleScale: { axisPressedMouseMove: true, mouseWheel: true, pinch: true },
localization: { locale: 'zh-CN' },
});
if (mode === 'line') {
areaSeries = chart.addAreaSeries({
lineColor: c.line,
topColor: c.areaTop,
bottomColor: 'rgba(0,0,0,0)',
lineWidth: 2,
priceLineVisible: false,
lastValueVisible: true,
});
} else {
candleSeries = chart.addCandlestickSeries({
upColor: c.up,
downColor: c.down,
borderVisible: true,
borderUpColor: c.up,
borderDownColor: c.down,
wickUpColor: c.up,
wickDownColor: c.down,
priceLineVisible: false,
lastValueVisible: true,
});
volumeSeries = chart.addHistogramSeries({
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
lastValueVisible: false,
priceLineVisible: false,
});
chart.priceScale('volume').applyOptions({
scaleMargins: { top: 0.82, bottom: 0 },
borderVisible: false,
});
if (chartOpts.ma) {
ma21Series = chart.addLineSeries({
color: c.ma21,
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
});
ma55Series = chart.addLineSeries({
color: c.ma55,
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
crosshairMarkerVisible: false,
}); });
} }
} }
return zoom;
}
function isFollowingLatest() { chart.timeScale().subscribeVisibleLogicalRangeChange(function () {
var z = getZoomRange();
return z.end >= 98;
}
function mapSeriesData(bars, values, gapDay) {
if (!gapDay) return values;
return bars.map(function (b, i) {
var v = values[i];
if (v == null || b.timestamp == null) return v;
return [b.timestamp, v];
});
}
function renderChart(data, preserveZoom) {
if (!chart) return; if (!chart) return;
var range = chart.timeScale().getVisibleLogicalRange();
if (!range || !lastData || !lastData.preparedBars) return;
var total = lastData.preparedBars.length;
followingLatest = range.to >= total - 2;
});
resizeObs = new ResizeObserver(function () {
if (!chart || !chartEl) return;
chart.applyOptions({ width: chartEl.clientWidth, height: chartEl.clientHeight });
});
resizeObs.observe(chartEl);
currentChartMode = mode;
}
function applyPrevCloseLine(price) {
if (!candleSeries || currentChartMode !== 'candle') return;
if (prevCloseLine) {
candleSeries.removePriceLine(prevCloseLine);
prevCloseLine = null;
}
if (!chartOpts.prevClose || price == null || !isFinite(Number(price))) return;
var c = themeColors();
prevCloseLine = candleSeries.createPriceLine({
price: Number(price),
color: c.prevClose,
lineWidth: 1,
lineStyle: LightweightCharts.LineStyle.Dashed,
axisLabelVisible: true,
title: '昨收',
});
}
function setVisibleRange(prepared, preserve) {
if (!chart || !prepared.length) return;
var ts = chart.timeScale();
if (preserve && followingLatest) {
var span = DEFAULT_VISIBLE_BARS;
try {
var cur = ts.getVisibleLogicalRange();
if (cur) span = Math.max(20, cur.to - cur.from);
} catch (e) { /* ignore */ }
ts.setVisibleLogicalRange({
from: Math.max(0, prepared.length - span),
to: prepared.length + 4,
});
return;
}
if (preserve) return;
var show = Math.min(DEFAULT_VISIBLE_BARS, prepared.length);
ts.setVisibleLogicalRange({
from: Math.max(0, prepared.length - show),
to: prepared.length + 4,
});
}
function renderChart(data, preserveRange) {
if (!chartEl || !window.LightweightCharts) return;
lastData = data; lastData = data;
if (data.prev_close != null) lastPrevClose = data.prev_close; if (data.prev_close != null) lastPrevClose = data.prev_close;
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'; var isLine = data.chart_type === 'line' || data.period === 'timeshare';
var gapDay = chartOpts.gapDay; var mode = isLine ? 'line' : 'candle';
var followLatest = preserveZoom && isFollowingLatest(); if (!chart || currentChartMode !== mode) buildChart(mode);
var dataZoom = getDataZoom(c, preserveZoom); if (!chart) return;
var zoom = preserveZoom ? getZoomRange() : { start: dataZoom[0].start, end: dataZoom[0].end };
var vIdx = visibleIndices(bars, zoom);
var hl = computeVisibleHL(bars, vIdx.start, vIdx.end);
var closes = bars.map(function (b) { return b.close; });
var ma21 = chartOpts.ma ? calcMA(21, closes) : null;
var ma55 = chartOpts.ma ? calcMA(55, closes) : null;
var crosses = chartOpts.ma ? findMaCrosses(ma21, ma55) : [];
var prevCloseVal = lastPrevClose != null ? lastPrevClose : data.prev_close;
var showPrev = chartOpts.prevClose && prevCloseVal != null;
var grids = [ var prepared = prepareBars(data.bars || [], data.period || currentPeriod);
{ left: 56, right: 24, top: 44, height: '50%' }, data.preparedBars = prepared;
{ left: 56, right: 24, top: '66%', height: '14%' }, if (!prepared.length) return;
];
var xAxisType = gapDay ? 'time' : 'category'; if (mode === 'line') {
var xAxis0 = { areaSeries.setData(prepared.map(function (b) {
type: xAxisType, return { time: b.time, value: b.close };
gridIndex: 0, }));
boundaryGap: gapDay ? false : true,
axisLabel: { color: c.text, fontSize: 10 },
axisLine: { lineStyle: { color: c.grid } },
splitLine: { show: false },
};
var xAxis1 = {
type: xAxisType,
gridIndex: 1,
boundaryGap: gapDay ? false : true,
axisLabel: { show: false },
axisLine: { lineStyle: { color: c.grid } },
splitLine: { show: false },
};
if (!gapDay) {
xAxis0.data = times;
xAxis1.data = times;
}
var base = {
backgroundColor: c.bg,
animation: false,
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' } },
axisPointer: { link: [{ xAxisIndex: 'all' }] },
grid: grids,
xAxis: [xAxis0, xAxis1],
yAxis: [
{
scale: true,
gridIndex: 0,
splitLine: { show: false },
axisLabel: { color: c.text },
},
{
scale: true,
gridIndex: 1,
splitLine: { show: false },
axisLabel: { color: c.text, fontSize: 10 },
splitNumber: 2,
},
],
};
if (!preserveZoom) {
base.dataZoom = dataZoom;
}
var series = [];
var mainMark = {
markPoint: buildHLMarkPoint(hl, c),
};
if (showPrev) mainMark.markLine = buildPrevCloseMarkLine(prevCloseVal, c);
if (chartOpts.ma && crosses.length) {
var crossMp = buildMaCrossMarkPoint(crosses, bars, c);
mainMark.markPoint.data = mainMark.markPoint.data.concat(crossMp.data);
}
if (isLine) {
var lineData = mapSeriesData(bars, closes, gapDay);
series.push({
id: 'main',
name: '价格',
type: 'line',
data: lineData,
smooth: true,
showSymbol: false,
lineStyle: { width: 1.5, color: c.line },
areaStyle: { color: c.area },
xAxisIndex: 0,
yAxisIndex: 0,
markPoint: mainMark.markPoint,
markLine: mainMark.markLine,
});
} else { } else {
var candle = bars.map(function (b) { candleSeries.setData(prepared.map(function (b) {
if (gapDay && b.timestamp) { return { time: b.time, open: b.open, high: b.high, low: b.low, close: b.close };
return [b.timestamp, b.open, b.close, b.low, b.high]; }));
} volumeSeries.setData(prepared.map(function (b) {
return [b.open, b.close, b.low, b.high];
});
series.push({
id: 'main',
name: 'K线',
type: 'candlestick',
data: candle,
barMaxWidth: 14,
barMinWidth: 3,
itemStyle: {
color: c.up,
color0: c.down,
borderColor: c.up,
borderColor0: c.down,
},
xAxisIndex: 0,
yAxisIndex: 0,
markPoint: mainMark.markPoint,
markLine: mainMark.markLine,
});
}
if (chartOpts.ma && ma21 && ma55) {
series.push({
id: 'ma21',
name: 'MA21',
type: 'line',
data: mapSeriesData(bars, ma21, gapDay),
smooth: true,
showSymbol: false,
lineStyle: { width: 1, color: c.ma21 },
xAxisIndex: 0,
yAxisIndex: 0,
});
series.push({
id: 'ma55',
name: 'MA55',
type: 'line',
data: mapSeriesData(bars, ma55, gapDay),
smooth: true,
showSymbol: false,
lineStyle: { width: 1, color: c.ma55 },
xAxisIndex: 0,
yAxisIndex: 0,
});
}
var vols = bars.map(function (b) {
var up = b.close >= b.open; var up = b.close >= b.open;
var val = { var c = themeColors();
return {
time: b.time,
value: b.volume, value: b.volume,
itemStyle: { color: up ? c.up : c.down, opacity: 0.65 }, color: up ? c.up : c.down,
}; };
if (gapDay && b.timestamp) { }));
return { value: [b.timestamp, b.volume], itemStyle: val.itemStyle }; if (chartOpts.ma && ma21Series && ma55Series) {
ma21Series.setData(calcMA(21, prepared));
ma55Series.setData(calcMA(55, prepared));
} }
return val; applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close);
});
series.push({
id: 'volume',
name: '成交量',
type: 'bar',
data: vols,
xAxisIndex: 1,
yAxisIndex: 1,
});
if (!isLine) {
base.tooltip = {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter: function (params) {
var idx = params[0] && params[0].dataIndex;
if (idx == null || !bars[idx]) return '';
var b = bars[idx];
var lines = [b.time, '开 ' + b.open, '高 ' + b.high, '低 ' + b.low, '收 ' + b.close, '量 ' + b.volume];
if (ma21 && ma21[idx] != null) lines.push('MA21 ' + ma21[idx]);
if (ma55 && ma55[idx] != null) lines.push('MA55 ' + ma55[idx]);
return lines.join('<br/>');
},
};
} }
if (preserveZoom) { setVisibleRange(prepared, !!preserveRange);
chart.setOption(Object.assign(base, { series: series }), false);
} else {
chart.setOption(Object.assign(base, { series: series }), true);
dataZoomBound = false;
}
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 } },
legend: chartOpts.ma ? {
data: ['MA21', 'MA55'],
top: 8,
right: 12,
textStyle: { color: c.text, fontSize: 10 },
} : { show: false },
});
if (followLatest) {
var span = zoom.end - zoom.start;
chart.dispatchAction({
type: 'dataZoom',
dataZoomIndex: 0,
start: Math.max(0, 100 - span),
end: 100,
});
chart.dispatchAction({
type: 'dataZoom',
dataZoomIndex: 2,
start: Math.max(0, 100 - span),
end: 100,
});
}
bindDataZoomHL();
} }
function periodLabel(key) { function periodLabel(key) {
@@ -603,9 +410,9 @@
src = ' · ' + klineSourceLabel(lastData.source); src = ' · ' + klineSourceLabel(lastData.source);
} }
if (isTradingSession()) { if (isTradingSession()) {
el.textContent = '交易中 · 后台刷新 · SSE 推送(约1秒)' + src; el.textContent = 'TradingView 图表 · 交易中 SSE 推送' + src;
} else { } else {
el.textContent = 'SSE 推送 · 非交易时段低频刷新' + src; el.textContent = 'TradingView 图表 · 非交易时段低频刷新' + src;
} }
} }
@@ -632,7 +439,9 @@
if (data.prev_close != null) { if (data.prev_close != null) {
lastPrevClose = data.prev_close; lastPrevClose = data.prev_close;
updatePrevCloseDisplay(data.prev_close); updatePrevCloseDisplay(data.prev_close);
if (chartOpts.prevClose && lastData) renderChart(lastData, true); if (chartOpts.prevClose && lastData) {
applyPrevCloseLine(data.prev_close);
}
} }
} }
@@ -674,6 +483,7 @@
klineSource = new EventSource('/api/kline/stream?' + q); klineSource = new EventSource('/api/kline/stream?' + q);
streamActive = true; streamActive = true;
followingLatest = true;
updateRefreshHint(false); updateRefreshHint(false);
klineSource.addEventListener('kline', function (e) { klineSource.addEventListener('kline', function (e) {
@@ -725,28 +535,22 @@
function shiftDataZoom(delta) { function shiftDataZoom(delta) {
if (!chart) return; if (!chart) return;
var opt = chart.getOption(); var ts = chart.timeScale();
if (!opt || !opt.dataZoom || !opt.dataZoom.length) return; var range = ts.getVisibleLogicalRange();
var z = opt.dataZoom[0]; if (!range) return;
var span = (z.end - z.start) || 20; var span = range.to - range.from;
var newSpan = Math.max(5, Math.min(100, span + delta)); var newSpan = Math.max(15, span + delta);
var center = (z.start + z.end) / 2; var center = (range.from + range.to) / 2;
var start = Math.max(0, center - newSpan / 2); ts.setVisibleLogicalRange({
var end = Math.min(100, center + newSpan / 2); from: center - newSpan / 2,
if (end - start < newSpan) { to: center + newSpan / 2,
if (start === 0) end = newSpan; });
else start = end - newSpan;
}
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: end });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: end });
} }
function resetDataZoom() { function resetDataZoom() {
if (!chart) return; if (!chart || !lastData || !lastData.preparedBars) return;
var start = getDefaultZoomStart(); followingLatest = true;
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: 100 }); setVisibleRange(lastData.preparedBars, false);
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: 100 });
chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 0, end: 100 });
} }
function bindPeriodTabs() { function bindPeriodTabs() {
@@ -758,6 +562,7 @@
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';
followingLatest = true;
if (getSymbol()) loadKline(true); if (getSymbol()) loadKline(true);
}); });
} }
@@ -766,8 +571,8 @@
var zoomIn = document.getElementById('chart-zoom-in'); var zoomIn = document.getElementById('chart-zoom-in');
var zoomOut = document.getElementById('chart-zoom-out'); var zoomOut = document.getElementById('chart-zoom-out');
var zoomReset = document.getElementById('chart-zoom-reset'); var zoomReset = document.getElementById('chart-zoom-reset');
if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-12); }); if (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-20); });
if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(12); }); if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(20); });
if (zoomReset) zoomReset.addEventListener('click', resetDataZoom); if (zoomReset) zoomReset.addEventListener('click', resetDataZoom);
} }
@@ -778,29 +583,47 @@
if (prevCb) { if (prevCb) {
prevCb.addEventListener('change', function () { prevCb.addEventListener('change', function () {
chartOpts.prevClose = prevCb.checked; chartOpts.prevClose = prevCb.checked;
if (lastData) renderChart(lastData, true); if (lastData) {
applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : lastData.prev_close);
}
}); });
} }
if (maCb) { if (maCb) {
maCb.addEventListener('change', function () { maCb.addEventListener('change', function () {
chartOpts.ma = maCb.checked; chartOpts.ma = maCb.checked;
if (lastData) renderChart(lastData, true); if (lastData) {
destroyChart();
renderChart(lastData, false);
}
}); });
} }
if (gapCb) { if (gapCb) {
gapCb.addEventListener('change', function () { gapCb.addEventListener('change', function () {
chartOpts.gapDay = gapCb.checked; chartOpts.gapDay = gapCb.checked;
followingLatest = true;
if (lastData) renderChart(lastData, false); if (lastData) renderChart(lastData, false);
}); });
} }
} }
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
initChart(); if (!window.LightweightCharts) {
if (emptyEl) emptyEl.textContent = '图表库加载失败,请刷新页面';
return;
}
bindPeriodTabs(); bindPeriodTabs();
bindZoomButtons(); bindZoomButtons();
bindChartOptions(); bindChartOptions();
document.addEventListener('click', function (e) {
if (e.target.closest('[data-theme-pick]') && lastData) {
setTimeout(function () {
destroyChart();
renderChart(lastData, false);
}, 80);
}
});
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';
@@ -812,6 +635,8 @@
if (input) { if (input) {
input.addEventListener('symbol-selected', function () { input.addEventListener('symbol-selected', function () {
lastPrevClose = null; lastPrevClose = null;
lastData = null;
destroyChart();
updatePrevCloseDisplay(null); updatePrevCloseDisplay(null);
loadKline(true); loadKline(true);
}); });
@@ -823,6 +648,9 @@
updateRefreshHint(false); updateRefreshHint(false);
} }
window.addEventListener('beforeunload', stopKlineStream); window.addEventListener('beforeunload', function () {
stopKlineStream();
destroyChart();
});
}); });
})(); })();
+3 -3
View File
@@ -45,7 +45,7 @@
<div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div> <div class="market-chart-empty" id="market-chart-empty">请选择合约并点击「查看」</div>
<div class="market-chart-loading" id="market-chart-loading">连接中…</div> <div class="market-chart-loading" id="market-chart-loading">连接中…</div>
</div> </div>
<p class="hint">数据来源:{% if ctp_connected %}报价来自 CTPK 线历史新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时 K 线与报价回退新浪{% endif %}。拖拽左右平移、滚轮缩放;按住图表上下拖动可平移价格轴。可视区内自动标注最高/最低价</p> <p class="hint">图表引擎:TradingView Lightweight Charts(红跌绿涨)。数据来源:{% if ctp_connected %}报价 CTPK 线历史新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时回退新浪{% endif %}。滚轮缩放、拖拽平移;勾选「间隔日」可压缩夜盘空白</p>
</div> </div>
<style> <style>
@@ -104,7 +104,7 @@
.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); background:var(--card-inner);
height:min(62vh,520px);min-height:360px; height:min(68vh,560px);min-height:420px;
} }
.market-chart{width:100%;height:100%} .market-chart{width:100%;height:100%}
.market-chart-empty, .market-chart-empty,
@@ -134,6 +134,6 @@ html[data-theme="light"] .market-chart-wrap.loading .market-chart-loading{backgr
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.1/dist/echarts.min.js"></script> <script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="{{ url_for('static', filename='js/market.js') }}"></script> <script src="{{ url_for('static', filename='js/market.js') }}"></script>
{% endblock %} {% endblock %}