diff --git a/fee_specs.py b/fee_specs.py
index a67db0c..2853c34 100644
--- a/fee_specs.py
+++ b/fee_specs.py
@@ -37,6 +37,26 @@ def _get_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:
conn = _get_db()
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"]
conn = _get_db()
+ ensure_fee_rates_schema(conn)
row = conn.execute(
"SELECT * FROM fee_rates WHERE product=? AND source='ctp'",
(product,),
diff --git a/install_trading.py b/install_trading.py
index 44c7997..85394c4 100644
--- a/install_trading.py
+++ b/install_trading.py
@@ -22,9 +22,9 @@ from position_sizing import (
)
from recommend_store import (
load_recommend_cache,
+ recommend_cache_needs_refresh,
recommend_payload,
refresh_recommend_cache,
- rows_missing_max_lots,
)
from recommend_stream import recommend_hub, start_recommend_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)
max_pct = get_max_margin_pct(get_setting)
rec_loaded = load_recommend_cache(conn)
- if rec_loaded.get("stale") or rows_missing_max_lots(rec_loaded.get("rows") or []):
- refresh_recommend_cache(
- conn, capital, _main_quote, trading_mode=mode, max_margin_pct=max_pct,
- )
+ if recommend_cache_needs_refresh(rec_loaded, capital=capital):
+ try:
+ refresh_recommend_cache(
+ 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)
+ 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(
"trade.html",
trading_mode=mode,
diff --git a/kline_chart.py b/kline_chart.py
index 0a6952a..c5b7be8 100644
--- a/kline_chart.py
+++ b/kline_chart.py
@@ -220,20 +220,39 @@ def _timeshare_session(bars: list) -> list:
def bars_to_api(bars: list) -> list[dict]:
- """转为前端图表 JSON。"""
- result = []
+ """转为前端图表 JSON(去重、排序、数值规范化)。"""
+ result: list[dict] = []
+ seen: dict[int, dict] = {}
for bar in bars:
dt = _bar_datetime(bar)
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"],
"timestamp": ts,
- "open": bar["o"],
- "high": bar["h"],
- "low": bar["l"],
- "close": bar["c"],
- "volume": bar.get("v", 0),
- })
+ "open": o,
+ "high": h,
+ "low": l,
+ "close": c,
+ "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
diff --git a/product_recommend.py b/product_recommend.py
index c54f344..b6c6a6e 100644
--- a/product_recommend.py
+++ b/product_recommend.py
@@ -1,6 +1,7 @@
"""按账户资金推荐可交易品种(期货核心筛选)。"""
from __future__ import annotations
+import logging
import math
from concurrent.futures import ThreadPoolExecutor
from typing import Callable, Optional
@@ -9,6 +10,8 @@ from contract_specs import get_contract_spec
from fee_specs import calc_fee_breakdown
from symbols import PRODUCTS
+logger = logging.getLogger(__name__)
+
def _letters_from_ths(ths_code: str) -> str:
import re
@@ -61,9 +64,13 @@ def assess_product_for_capital(
ref_sl = round(p - stop_dist, 4)
ref_tp = round(p + stop_dist * reward_risk_ratio, 4)
fee_ths = ths + "8888"
- fee_info = calc_fee_breakdown(
- fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode,
- )
+ try:
+ fee_info = calc_fee_breakdown(
+ 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_risk = cap > 0 and risk_one_lot <= cap * 0.01
@@ -109,16 +116,28 @@ def list_product_recommendations(
def _one(product: dict) -> dict:
ths = product["ths"]
- quote = quote_fn(ths) or {}
- price = quote.get("price")
- row = assess_product_for_capital(
- product, capital, price,
- max_margin_pct=max_margin_pct,
- trading_mode=trading_mode,
- )
- main_code = (quote.get("ths_code") or "").strip()
- row["main_code"] = main_code
- return row
+ try:
+ quote = quote_fn(ths) or {}
+ price = quote.get("price")
+ row = assess_product_for_capital(
+ product, capital, price,
+ max_margin_pct=max_margin_pct,
+ trading_mode=trading_mode,
+ )
+ main_code = (quote.get("ths_code") or "").strip()
+ row["main_code"] = main_code
+ 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:
rows = list(pool.map(_one, PRODUCTS))
diff --git a/recommend_store.py b/recommend_store.py
index e8a5bb5..0455cae 100644
--- a/recommend_store.py
+++ b/recommend_store.py
@@ -2,12 +2,16 @@
from __future__ import annotations
import json
+import logging
import math
from datetime import datetime
from typing import Callable, Optional
+from fee_specs import ensure_fee_rates_schema
from product_recommend import list_product_recommendations
+logger = logging.getLogger(__name__)
+
RECOMMEND_CACHE_SQL = """
CREATE TABLE IF NOT EXISTS product_recommend_cache (
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)
+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(
rows: list[dict],
capital: float,
@@ -81,10 +101,19 @@ def refresh_recommend_cache(
) -> list[dict]:
"""后台拉行情、筛选并写入数据库。"""
ensure_recommend_tables(conn)
+ ensure_fee_rates_schema(conn)
all_rows = list_product_recommendations(
capital, quote_fn, max_margin_pct=max_margin_pct, trading_mode=trading_mode,
)
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")
conn.execute(
"""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)))
payload["capital"] = cap
payload["max_margin_pct"] = pct
- payload["rows"] = enrich_recommend_rows(
- payload.get("rows") or [],
- cap,
- max_margin_pct=pct,
- )
+ rows = payload.get("rows") or []
+ payload["rows"] = enrich_recommend_rows(rows, cap, max_margin_pct=pct)
+ payload["needs_refresh"] = recommend_cache_needs_refresh(payload, capital=cap)
return payload
diff --git a/recommend_stream.py b/recommend_stream.py
index db6bd62..bb4c56f 100644
--- a/recommend_stream.py
+++ b/recommend_stream.py
@@ -12,10 +12,9 @@ from db_conn import connect_db
from kline_stream import sse_format
from recommend_store import (
load_recommend_cache,
- recommend_cache_stale,
+ recommend_cache_needs_refresh,
recommend_payload,
refresh_recommend_cache,
- rows_missing_max_lots,
)
logger = logging.getLogger(__name__)
@@ -78,9 +77,7 @@ def start_recommend_worker(
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
cached = load_recommend_cache(conn)
- if recommend_cache_stale(cached.get("updated_at")) or rows_missing_max_lots(
- cached.get("rows") or [],
- ):
+ if recommend_cache_needs_refresh(cached, capital=capital):
refresh_recommend_cache(
conn, capital, quote_fn, trading_mode=mode, max_margin_pct=max_pct,
)
diff --git a/static/js/market.js b/static/js/market.js
index 6635280..58d2fa8 100644
--- a/static/js/market.js
+++ b/static/js/market.js
@@ -3,14 +3,36 @@
var emptyEl = document.getElementById('market-chart-empty');
var wrapEl = document.getElementById('market-chart-wrap');
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 currentChartMode = '';
var klineSource = null;
var streamActive = false;
var reconnectTimer = null;
var lastData = null;
var lastPrevClose = null;
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() {
var hidden = document.getElementById('market-symbol-hidden');
@@ -31,17 +53,14 @@
function themeColors() {
var dark = document.documentElement.getAttribute('data-theme') !== 'light';
return {
- bg: dark ? '#0a0c14' : '#f4f7fc',
+ bg: dark ? '#0a0c14' : '#ffffff',
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)',
- slider: dark ? '#1e2640' : '#cbd5e1',
- sliderFill: dark ? '#4cc2ff' : '#2563eb',
- ma21: dark ? '#ffb347' : '#d97706',
+ grid: dark ? '#1e2640' : '#e8edf5',
+ up: dark ? '#26a69a' : '#089981',
+ down: dark ? '#ef5350' : '#f23645',
+ line: dark ? '#4cc2ff' : '#2962ff',
+ areaTop: dark ? 'rgba(76,194,255,0.28)' : 'rgba(41,98,255,0.22)',
+ ma21: dark ? '#ffb347' : '#f7931a',
ma55: dark ? '#c084fc' : '#7c3aed',
prevClose: dark ? '#fbbf24' : '#b45309',
};
@@ -63,484 +82,272 @@
return false;
}
- function calcMA(period, closes) {
- var result = [];
- for (var i = 0; i < closes.length; i++) {
- if (i < period - 1) {
- result.push(null);
- continue;
+ function barUnixTime(bar) {
+ if (bar.timestamp) return Math.floor(bar.timestamp / 1000);
+ if (bar.time) {
+ var d = new Date(String(bar.time).replace(' ', 'T'));
+ if (!isNaN(d.getTime())) return Math.floor(d.getTime() / 1000);
+ }
+ 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;
- for (var j = 0; j < period; j++) sum += closes[i - j];
- result.push(+(sum / period).toFixed(4));
+ for (var j = 0; j < period; j++) sum += bars[i - j].close;
+ result.push({ time: bars[i].time, value: +(sum / period).toFixed(4) });
}
return result;
}
- function findMaCrosses(ma21, ma55) {
- var points = [];
- for (var i = 1; i < ma21.length; i++) {
- if (ma21[i] == null || ma55[i] == null || ma21[i - 1] == null || ma55[i - 1] == null) continue;
- 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' });
+ function destroyChart() {
+ if (resizeObs) {
+ resizeObs.disconnect();
+ resizeObs = null;
}
- return points;
- }
-
- function getDefaultZoomStart() {
- return currentPeriod === 'timeshare' ? 60 : 75;
- }
-
- 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 };
+ if (chart) {
+ chart.remove();
+ chart = null;
}
- return { start: getDefaultZoomStart(), end: 100 };
+ candleSeries = null;
+ volumeSeries = null;
+ areaSeries = null;
+ ma21Series = null;
+ ma55Series = null;
+ prevCloseLine = null;
+ currentChartMode = '';
}
- 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);
+ function buildChart(mode) {
+ destroyChart();
+ if (!chartEl || !window.LightweightCharts) return;
var c = themeColors();
- chart.setOption({
- series: [{ id: 'main', markPoint: buildHLMarkPoint(hl, c) }],
- });
- }
-
- function getDataZoom(c, preserve) {
- var defStart = getDefaultZoomStart();
- var xZoom = {
- type: 'inside',
- 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 },
+ var w = chartEl.clientWidth || 600;
+ var h = chartEl.clientHeight || 400;
+ chart = LightweightCharts.createChart(chartEl, {
+ width: w,
+ height: h,
+ layout: {
+ background: { type: 'solid', color: c.bg },
+ textColor: c.text,
+ fontSize: 11,
},
- textStyle: { color: c.text, fontSize: 10 },
- filterMode: 'none',
- brushSelect: false,
- };
- var zoom = [xZoom, yZoom, slider];
- if (preserve && chart) {
- var opt = chart.getOption();
- if (opt && opt.dataZoom) {
- opt.dataZoom.forEach(function (z) {
- if (!z.id) return;
- var target = zoom.find(function (t) { return t.id === z.id; });
- if (target && z.start != null && z.end != null) {
- target.start = z.start;
- target.end = z.end;
- }
+ grid: {
+ vertLines: { color: c.grid, style: 1 },
+ horzLines: { color: c.grid, style: 1 },
+ },
+ crosshair: {
+ mode: LightweightCharts.CrosshairMode.Normal,
+ vertLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid },
+ horzLine: { width: 1, color: c.text, style: 2, labelBackgroundColor: c.grid },
+ },
+ rightPriceScale: {
+ borderColor: c.grid,
+ scaleMargins: mode === 'line' ? { top: 0.08, bottom: 0.08 } : { top: 0.05, bottom: 0.22 },
+ },
+ 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;
+
+ chart.timeScale().subscribeVisibleLogicalRangeChange(function () {
+ 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 isFollowingLatest() {
- 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 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 renderChart(data, preserveZoom) {
- if (!chart) return;
+ 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;
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 gapDay = chartOpts.gapDay;
- var followLatest = preserveZoom && isFollowingLatest();
- var dataZoom = getDataZoom(c, preserveZoom);
- 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 mode = isLine ? 'line' : 'candle';
+ if (!chart || currentChartMode !== mode) buildChart(mode);
+ if (!chart) return;
- var grids = [
- { left: 56, right: 24, top: 44, height: '50%' },
- { left: 56, right: 24, top: '66%', height: '14%' },
- ];
+ var prepared = prepareBars(data.bars || [], data.period || currentPeriod);
+ data.preparedBars = prepared;
+ if (!prepared.length) return;
- var xAxisType = gapDay ? 'time' : 'category';
- var xAxis0 = {
- type: xAxisType,
- 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,
- });
+ if (mode === 'line') {
+ areaSeries.setData(prepared.map(function (b) {
+ return { time: b.time, value: b.close };
+ }));
} else {
- var candle = bars.map(function (b) {
- if (gapDay && b.timestamp) {
- return [b.timestamp, b.open, b.close, b.low, b.high];
- }
- 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 val = {
- value: b.volume,
- itemStyle: { color: up ? c.up : c.down, opacity: 0.65 },
- };
- if (gapDay && b.timestamp) {
- return { value: [b.timestamp, b.volume], itemStyle: val.itemStyle };
+ candleSeries.setData(prepared.map(function (b) {
+ return { time: b.time, open: b.open, high: b.high, low: b.low, close: b.close };
+ }));
+ volumeSeries.setData(prepared.map(function (b) {
+ var up = b.close >= b.open;
+ var c = themeColors();
+ return {
+ time: b.time,
+ value: b.volume,
+ color: up ? c.up : c.down,
+ };
+ }));
+ if (chartOpts.ma && ma21Series && ma55Series) {
+ ma21Series.setData(calcMA(21, prepared));
+ ma55Series.setData(calcMA(55, prepared));
}
- return val;
- });
- 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('
');
- },
- };
+ applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : data.prev_close);
}
- if (preserveZoom) {
- 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();
+ setVisibleRange(prepared, !!preserveRange);
}
function periodLabel(key) {
@@ -603,9 +410,9 @@
src = ' · ' + klineSourceLabel(lastData.source);
}
if (isTradingSession()) {
- el.textContent = '交易中 · 后台刷新 · SSE 推送(约1秒)' + src;
+ el.textContent = 'TradingView 图表 · 交易中 SSE 推送' + src;
} else {
- el.textContent = 'SSE 推送 · 非交易时段低频刷新' + src;
+ el.textContent = 'TradingView 图表 · 非交易时段低频刷新' + src;
}
}
@@ -632,7 +439,9 @@
if (data.prev_close != null) {
lastPrevClose = 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);
streamActive = true;
+ followingLatest = true;
updateRefreshHint(false);
klineSource.addEventListener('kline', function (e) {
@@ -725,28 +535,22 @@
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', dataZoomIndex: 0, start: start, end: end });
- chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: end });
+ var ts = chart.timeScale();
+ var range = ts.getVisibleLogicalRange();
+ if (!range) return;
+ var span = range.to - range.from;
+ var newSpan = Math.max(15, span + delta);
+ var center = (range.from + range.to) / 2;
+ ts.setVisibleLogicalRange({
+ from: center - newSpan / 2,
+ to: center + newSpan / 2,
+ });
}
function resetDataZoom() {
- if (!chart) return;
- var start = getDefaultZoomStart();
- chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 0, start: start, end: 100 });
- chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 2, start: start, end: 100 });
- chart.dispatchAction({ type: 'dataZoom', dataZoomIndex: 1, start: 0, end: 100 });
+ if (!chart || !lastData || !lastData.preparedBars) return;
+ followingLatest = true;
+ setVisibleRange(lastData.preparedBars, false);
}
function bindPeriodTabs() {
@@ -758,6 +562,7 @@
tabs.querySelectorAll('.period-tab').forEach(function (el) { el.classList.remove('active'); });
btn.classList.add('active');
currentPeriod = btn.getAttribute('data-period') || '15m';
+ followingLatest = true;
if (getSymbol()) loadKline(true);
});
}
@@ -766,8 +571,8 @@
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 (zoomIn) zoomIn.addEventListener('click', function () { shiftDataZoom(-20); });
+ if (zoomOut) zoomOut.addEventListener('click', function () { shiftDataZoom(20); });
if (zoomReset) zoomReset.addEventListener('click', resetDataZoom);
}
@@ -778,29 +583,47 @@
if (prevCb) {
prevCb.addEventListener('change', function () {
chartOpts.prevClose = prevCb.checked;
- if (lastData) renderChart(lastData, true);
+ if (lastData) {
+ applyPrevCloseLine(lastPrevClose != null ? lastPrevClose : lastData.prev_close);
+ }
});
}
if (maCb) {
maCb.addEventListener('change', function () {
chartOpts.ma = maCb.checked;
- if (lastData) renderChart(lastData, true);
+ if (lastData) {
+ destroyChart();
+ renderChart(lastData, false);
+ }
});
}
if (gapCb) {
gapCb.addEventListener('change', function () {
chartOpts.gapDay = gapCb.checked;
+ followingLatest = true;
if (lastData) renderChart(lastData, false);
});
}
}
document.addEventListener('DOMContentLoaded', function () {
- initChart();
+ if (!window.LightweightCharts) {
+ if (emptyEl) emptyEl.textContent = '图表库加载失败,请刷新页面';
+ return;
+ }
bindPeriodTabs();
bindZoomButtons();
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');
if (active) currentPeriod = active.getAttribute('data-period') || '15m';
@@ -812,6 +635,8 @@
if (input) {
input.addEventListener('symbol-selected', function () {
lastPrevClose = null;
+ lastData = null;
+ destroyChart();
updatePrevCloseDisplay(null);
loadKline(true);
});
@@ -823,6 +648,9 @@
updateRefreshHint(false);
}
- window.addEventListener('beforeunload', stopKlineStream);
+ window.addEventListener('beforeunload', function () {
+ stopKlineStream();
+ destroyChart();
+ });
});
})();
diff --git a/templates/market.html b/templates/market.html
index a9cd180..dfc43ee 100644
--- a/templates/market.html
+++ b/templates/market.html
@@ -45,7 +45,7 @@
数据来源:{% if ctp_connected %}报价来自 CTP;K 线历史由新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时 K 线与报价回退新浪{% endif %}。拖拽左右平移、滚轮缩放;按住图表上下拖动可平移价格轴。可视区内自动标注最高/最低价。
+图表引擎:TradingView Lightweight Charts(红跌绿涨)。数据来源:{% if ctp_connected %}报价 CTP;K 线历史新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时回退新浪{% endif %}。滚轮缩放、拖拽平移;勾选「间隔日」可压缩夜盘空白。