fix: TradingView K线图表并修复品种推荐为空。
- 行情页改用 Lightweight Charts 标准蜡烛图(红跌绿涨) - 修复 fee_rates 缺 source 列导致推荐刷新失败 - 空缓存自动重试,持仓页实时兜底计算推荐列表 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,),
|
||||||
|
|||||||
+25
-5
@@ -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):
|
||||||
refresh_recommend_cache(
|
try:
|
||||||
conn, capital, _main_quote, trading_mode=mode, max_margin_pct=max_pct,
|
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)
|
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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+32
-13
@@ -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"
|
||||||
fee_info = calc_fee_breakdown(
|
try:
|
||||||
fee_ths, p, p, 1.0, open_time="", close_time="", trading_mode=trading_mode,
|
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_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,16 +116,28 @@ def list_product_recommendations(
|
|||||||
|
|
||||||
def _one(product: dict) -> dict:
|
def _one(product: dict) -> dict:
|
||||||
ths = product["ths"]
|
ths = product["ths"]
|
||||||
quote = quote_fn(ths) or {}
|
try:
|
||||||
price = quote.get("price")
|
quote = quote_fn(ths) or {}
|
||||||
row = assess_product_for_capital(
|
price = quote.get("price")
|
||||||
product, capital, price,
|
row = assess_product_for_capital(
|
||||||
max_margin_pct=max_margin_pct,
|
product, capital, price,
|
||||||
trading_mode=trading_mode,
|
max_margin_pct=max_margin_pct,
|
||||||
)
|
trading_mode=trading_mode,
|
||||||
main_code = (quote.get("ths_code") or "").strip()
|
)
|
||||||
row["main_code"] = main_code
|
main_code = (quote.get("ths_code") or "").strip()
|
||||||
return row
|
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:
|
with ThreadPoolExecutor(max_workers=10) as pool:
|
||||||
rows = list(pool.map(_one, PRODUCTS))
|
rows = list(pool.map(_one, PRODUCTS))
|
||||||
|
|||||||
+32
-5
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+315
-487
@@ -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;
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
return { start: getDefaultZoomStart(), end: 100 };
|
candleSeries = null;
|
||||||
|
volumeSeries = null;
|
||||||
|
areaSeries = null;
|
||||||
|
ma21Series = null;
|
||||||
|
ma55Series = null;
|
||||||
|
prevCloseLine = null;
|
||||||
|
currentChartMode = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function visibleIndices(bars, zoom) {
|
function buildChart(mode) {
|
||||||
var n = bars.length;
|
destroyChart();
|
||||||
if (!n) return { start: 0, end: 0 };
|
if (!chartEl || !window.LightweightCharts) return;
|
||||||
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;
|
|
||||||
|
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() {
|
function applyPrevCloseLine(price) {
|
||||||
var z = getZoomRange();
|
if (!candleSeries || currentChartMode !== 'candle') return;
|
||||||
return z.end >= 98;
|
if (prevCloseLine) {
|
||||||
}
|
candleSeries.removePriceLine(prevCloseLine);
|
||||||
|
prevCloseLine = null;
|
||||||
function mapSeriesData(bars, values, gapDay) {
|
}
|
||||||
if (!gapDay) return values;
|
if (!chartOpts.prevClose || price == null || !isFinite(Number(price))) return;
|
||||||
return bars.map(function (b, i) {
|
var c = themeColors();
|
||||||
var v = values[i];
|
prevCloseLine = candleSeries.createPriceLine({
|
||||||
if (v == null || b.timestamp == null) return v;
|
price: Number(price),
|
||||||
return [b.timestamp, v];
|
color: c.prevClose,
|
||||||
|
lineWidth: 1,
|
||||||
|
lineStyle: LightweightCharts.LineStyle.Dashed,
|
||||||
|
axisLabelVisible: true,
|
||||||
|
title: '昨收',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChart(data, preserveZoom) {
|
function setVisibleRange(prepared, preserve) {
|
||||||
if (!chart) return;
|
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];
|
var up = b.close >= b.open;
|
||||||
});
|
var c = themeColors();
|
||||||
series.push({
|
return {
|
||||||
id: 'main',
|
time: b.time,
|
||||||
name: 'K线',
|
value: b.volume,
|
||||||
type: 'candlestick',
|
color: up ? c.up : c.down,
|
||||||
data: candle,
|
};
|
||||||
barMaxWidth: 14,
|
}));
|
||||||
barMinWidth: 3,
|
if (chartOpts.ma && ma21Series && ma55Series) {
|
||||||
itemStyle: {
|
ma21Series.setData(calcMA(21, prepared));
|
||||||
color: c.up,
|
ma55Series.setData(calcMA(55, prepared));
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -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 %}报价来自 CTP;K 线历史由新浪补齐、最新 bar 由 CTP tick 更新{% else %}CTP 未连接时 K 线与报价回退新浪{% endif %}。拖拽左右平移、滚轮缩放;按住图表上下拖动可平移价格轴。可视区内自动标注最高/最低价。</p>
|
<p class="hint">图表引擎:TradingView Lightweight Charts(红跌绿涨)。数据来源:{% if ctp_connected %}报价 CTP;K 线历史新浪补齐、最新 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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user