Files
DinQuant/quantdinger_vue/src/views/indicator-analysis/components/KlineChart.vue
T
TIANHE 76d24aa203 new
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2025-12-31 02:22:10 +08:00

3883 lines
136 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="chart-left" :class="{ 'theme-dark': chartTheme === 'dark' }">
<div class="chart-wrapper">
<!-- 画线工具工具栏 -->
<div class="drawing-toolbar">
<a-tooltip
v-for="tool in drawingTools"
:key="tool.name"
:title="tool.title"
placement="right"
>
<div
class="drawing-tool-btn"
:class="{ active: activeDrawingTool === tool.name }"
@click="selectDrawingTool(tool.name)"
>
<a-icon :type="tool.icon" />
</div>
</a-tooltip>
<a-divider type="vertical" />
<a-tooltip :title="$t('dashboard.indicator.drawing.clearAll')" placement="right">
<div class="drawing-tool-btn" @click="clearAllDrawings">
<a-icon type="delete" />
</div>
</a-tooltip>
</div>
<!-- 图表内容区域 -->
<div class="chart-content-area">
<!-- 指标工具栏 -->
<div class="indicator-toolbar">
<div
v-for="indicator in indicatorButtons"
:key="indicator.id"
class="indicator-btn"
:class="{ active: isIndicatorActive(indicator.id) }"
@click="toggleIndicator(indicator)"
:title="indicator.name"
>
{{ indicator.shortName }}
</div>
</div>
<div
id="kline-chart-container"
class="kline-chart-container"
></div>
</div>
<div v-if="loading" class="chart-overlay">
<a-spin size="large">
<a-icon slot="indicator" type="loading" style="font-size: 24px; color: #13c2c2" spin />
</a-spin>
</div>
<div v-if="error" class="chart-overlay">
<div class="error-box">
<a-icon type="warning" style="font-size: 24px; color: #ef5350; margin-bottom: 10px" />
<span>{{ error }}</span>
<a-button type="primary" size="small" ghost @click="handleRetry" style="margin-top: 12px">
{{ $t('dashboard.indicator.retry') }}
</a-button>
</div>
</div>
<!-- Pyodide 加载失败提示 -->
<div v-if="pyodideLoadFailed" class="chart-overlay pyodide-warning">
<div class="warning-box">
<a-icon type="warning" style="font-size: 32px; color: #faad14; margin-bottom: 12px" />
<div class="warning-title">{{ $t('dashboard.indicator.warning.pyodideLoadFailed') }}</div>
<div class="warning-desc">{{ $t('dashboard.indicator.warning.pyodideLoadFailedDesc') }}</div>
</div>
</div>
<!-- 初始提示蒙版 -->
<div v-if="!symbol && !loading && !error && !pyodideLoadFailed" class="chart-overlay initial-hint">
<div class="hint-box">
<a-icon type="line-chart" style="font-size: 48px; color: #1890ff; margin-bottom: 16px" />
<div class="hint-title">{{ $t('dashboard.indicator.hint.selectSymbol') }}</div>
<div class="hint-desc">{{ $t('dashboard.indicator.hint.selectSymbolDesc') }}</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch, shallowRef, getCurrentInstance } from 'vue'
import { init, registerIndicator, registerOverlay } from 'klinecharts'
import request from '@/utils/request'
import { decryptCodeAuto, needsDecrypt } from '@/utils/codeDecrypt'
export default {
name: 'KlineChart',
props: {
symbol: {
type: String,
default: ''
},
market: {
type: String,
default: ''
},
timeframe: {
type: String,
default: '1H'
},
theme: {
type: String,
default: 'light'
},
activeIndicators: {
type: Array,
default: () => []
},
realtimeEnabled: {
type: Boolean,
default: false
},
userId: {
type: Number,
default: null
}
},
emits: ['retry', 'price-change', 'load', 'indicator-toggle'],
setup (props, { emit }) {
// K线数据
const klineData = shallowRef([])
const loading = ref(false)
const error = ref(null)
const loadingHistory = ref(false)
const hasMoreHistory = ref(true)
// 用于追踪正在进行的加载请求,防止重复请求
let loadingHistoryPromise = null
// 标记图表是否已初始化完成,避免初始化时触发加载
const chartInitialized = ref(false)
// 图表实例
const chartRef = shallowRef(null)
const chartTheme = ref(props.theme || 'light')
// 实时更新设置
const realtimeTimer = ref(null)
const realtimeInterval = ref(5000)
// 指标刷新锁:避免实时定时器触发时 updateIndicators 重入(Python 指标可能较慢)
const indicatorsUpdating = ref(false)
// 指标刷新节流:K线/价格可高频刷新,但指标计算可以低频刷新(默认 10s)
const indicatorRefreshInterval = ref(10000)
const lastIndicatorRefreshTs = ref(0)
// K线刷新很频繁时,指标计算不必同步频率;这里做节流(并且有重入锁)。
const maybeUpdateIndicators = (force = false) => {
if (!chartRef.value) return
const now = Date.now()
const iv = Number(indicatorRefreshInterval.value || 10000)
if (force || !lastIndicatorRefreshTs.value || (now - lastIndicatorRefreshTs.value) >= iv) {
lastIndicatorRefreshTs.value = now
updateIndicators()
}
}
// 已添加的指标 ID 列表(用于清理)
const addedIndicatorIds = ref([])
// 已添加的信号 overlay ID 列表(用于清理)
const addedSignalOverlayIds = ref([])
// 已添加的画线 overlay ID 列表(用于清理和管理)
const addedDrawingOverlayIds = ref([])
// 当前激活的画线工具
const activeDrawingTool = ref(null)
// 画线工具定义(使用 computed 实现多语言支持)
const { proxy } = getCurrentInstance()
const drawingTools = computed(() => [
{ name: 'line', title: proxy.$t('dashboard.indicator.drawing.line'), icon: 'line' },
{ name: 'horizontalLine', title: proxy.$t('dashboard.indicator.drawing.horizontalLine'), icon: 'minus' },
{ name: 'verticalLine', title: proxy.$t('dashboard.indicator.drawing.verticalLine'), icon: 'column-width' },
{ name: 'ray', title: proxy.$t('dashboard.indicator.drawing.ray'), icon: 'arrow-right' },
{ name: 'straightLine', title: proxy.$t('dashboard.indicator.drawing.straightLine'), icon: 'menu' },
{ name: 'parallelStraightLine', title: proxy.$t('dashboard.indicator.drawing.parallelLine'), icon: 'menu' },
{ name: 'priceLine', title: proxy.$t('dashboard.indicator.drawing.priceLine'), icon: 'dollar' },
{ name: 'priceChannelLine', title: proxy.$t('dashboard.indicator.drawing.priceChannel'), icon: 'border' },
{ name: 'fibonacciLine', title: proxy.$t('dashboard.indicator.drawing.fibonacciLine'), icon: 'rise' }
])
// 指标按钮定义
const indicatorButtons = ref([
{ id: 'sma', name: 'SMA (简单移动平均)', shortName: 'SMA', type: 'line', defaultParams: { length: 20 } },
{ id: 'ema', name: 'EMA (指数移动平均)', shortName: 'EMA', type: 'line', defaultParams: { length: 20 } },
{ id: 'rsi', name: 'RSI (相对强弱)', shortName: 'RSI', type: 'line', defaultParams: { length: 14 } },
{ id: 'macd', name: 'MACD', shortName: 'MACD', type: 'macd', defaultParams: { fast: 12, slow: 26, signal: 9 } },
{ id: 'bb', name: '布林带 (Bollinger Bands)', shortName: 'BB', type: 'band', defaultParams: { length: 20, mult: 2 } },
{ id: 'atr', name: 'ATR (平均真实波幅)', shortName: 'ATR', type: 'line', defaultParams: { period: 14 } },
{ id: 'cci', name: 'CCI (商品通道指数)', shortName: 'CCI', type: 'line', defaultParams: { length: 20 } },
{ id: 'williams', name: 'Williams %R (威廉指标)', shortName: 'W%R', type: 'line', defaultParams: { length: 14 } },
{ id: 'mfi', name: 'MFI (资金流量指标)', shortName: 'MFI', type: 'line', defaultParams: { length: 14 } },
{ id: 'adx', name: 'ADX (平均趋向指数)', shortName: 'ADX', type: 'adx', defaultParams: { length: 14 } },
{ id: 'obv', name: 'OBV (能量潮)', shortName: 'OBV', type: 'line', defaultParams: {} },
{ id: 'adosc', name: 'ADOSC (积累/派发振荡器)', shortName: 'ADOSC', type: 'line', defaultParams: { fast: 3, slow: 10 } },
{ id: 'ad', name: 'AD (积累/派发线)', shortName: 'AD', type: 'line', defaultParams: {} },
{ id: 'kdj', name: 'KDJ (随机指标)', shortName: 'KDJ', type: 'line', defaultParams: { period: 9, k: 3, d: 3 } }
])
// 检查指标是否激活
const isIndicatorActive = (indicatorId) => {
return props.activeIndicators.some(ind => ind.id === indicatorId)
}
// 选择画线工具
const selectDrawingTool = (toolName) => {
if (!chartRef.value) {
return
}
// 工具名称映射(UI 工具名 -> klinecharts 内部覆盖物名称)
const toolMap = {
line: 'segment',
horizontalLine: 'horizontalStraightLine',
verticalLine: 'verticalStraightLine',
ray: 'rayLine',
straightLine: 'straightLine',
parallelStraightLine: 'parallelStraightLine',
priceLine: 'priceLine',
priceChannelLine: 'priceChannelLine',
fibonacciLine: 'fibonacciLine'
}
const overlayName = toolMap[toolName] || toolName
// 如果点击的是当前激活的工具,则取消激活
if (activeDrawingTool.value === toolName) {
activeDrawingTool.value = null
// 取消当前的绘制模式
// KLineChart 没有直接的 "cancelDrawing" API,通常移除最后一个未完成的覆盖物
// 或者通过 overrideOverlay(null) 来取消正在进行的动作(如果支持)
try {
if (typeof chartRef.value.overrideOverlay === 'function') {
chartRef.value.overrideOverlay(null)
}
} catch (e) {
}
return
}
// 激活新的画线工具
activeDrawingTool.value = toolName
try {
// 准备覆盖物配置
const overlayConfig = {
name: overlayName,
lock: false, // 允许编辑
extendData: {
isDrawing: true // 标记为正在绘制
}
}
// 调用 createOverlay 且不传 points,库会自动进入绘制模式
const overlayId = chartRef.value.createOverlay(overlayConfig)
if (overlayId) {
addedDrawingOverlayIds.value.push(overlayId)
} else {
activeDrawingTool.value = null
}
} catch (err) {
activeDrawingTool.value = null
}
}
// 清除所有画线
const clearAllDrawings = () => {
if (!chartRef.value) return
try {
// 移除所有已添加的画线覆盖物
addedDrawingOverlayIds.value.forEach(overlayId => {
try {
if (typeof chartRef.value.removeOverlay === 'function') {
chartRef.value.removeOverlay(overlayId)
} else if (typeof chartRef.value.removeOverlayById === 'function') {
chartRef.value.removeOverlayById(overlayId)
}
} catch (err) {
}
})
addedDrawingOverlayIds.value = []
activeDrawingTool.value = null
// 取消当前的绘制模式
if (typeof chartRef.value.overrideOverlay === 'function') {
chartRef.value.overrideOverlay(null)
}
} catch (err) {
}
}
// 切换指标显示/隐藏
const toggleIndicator = (indicator) => {
const isActive = isIndicatorActive(indicator.id)
if (isActive) {
// 移除指标
emit('indicator-toggle', {
action: 'remove',
indicator: { id: indicator.id }
})
} else {
// 添加指标
const indicatorToAdd = {
...indicator,
params: { ...indicator.defaultParams },
calculate: null // calculate 函数在 updateIndicators 中通过 id 判断
}
emit('indicator-toggle', {
action: 'add',
indicator: indicatorToAdd
})
}
}
// Pyodide 相关
const pyodide = ref(null)
const loadingPython = ref(false)
const pythonReady = ref(false)
const pyodideLoadFailed = ref(false)
// 主题配置
const themeConfig = computed(() => {
if (chartTheme.value === 'dark') {
return {
backgroundColor: '#131722',
textColor: '#d1d4dc',
textColorSecondary: '#787b86',
borderColor: '#2a2e39',
gridLineColor: '#1f2943',
gridLineColorDashed: '#363c4e',
tooltipBg: 'rgba(25, 27, 32, 0.95)',
tooltipBorder: '#333',
tooltipText: '#ccc',
tooltipTextSecondary: '#888',
axisLabelColor: '#787b86',
splitAreaColor: ['rgba(250,250,250,0.05)', 'rgba(200,200,200,0.02)'],
dataZoomBorder: '#2a2e39',
dataZoomFiller: 'rgba(41, 98, 255, 0.15)',
dataZoomHandle: '#13c2c2',
dataZoomText: 'transparent',
dataZoomBg: '#1f2943'
}
} else {
return {
backgroundColor: '#fff',
textColor: '#333',
textColorSecondary: '#666',
borderColor: '#e8e8e8',
gridLineColor: '#e8e8e8',
gridLineColorDashed: '#e8e8e8',
tooltipBg: 'rgba(255, 255, 255, 0.95)',
tooltipBorder: '#e8e8e8',
tooltipText: '#333',
tooltipTextSecondary: '#666',
axisLabelColor: '#666',
splitAreaColor: ['rgba(250,250,250,0.05)', 'rgba(200,200,200,0.02)'],
dataZoomBorder: '#e8e8e8',
dataZoomFiller: 'rgba(24, 144, 255, 0.15)',
dataZoomHandle: '#1890ff',
dataZoomText: '#999',
dataZoomBg: '#f0f2f5'
}
}
})
// 根据主题获取指标颜色
const getIndicatorColor = (idx) => {
if (chartTheme.value === 'dark') {
return ['#13c2c2', '#e040fb', '#ffeb3b', '#00e676', '#ff6d00', '#9c27b0'][idx % 6]
} else {
return ['#13c2c2', '#9c27b0', '#f57c00', '#1976d2', '#c2185b', '#7b1fa2'][idx % 6]
}
}
// ========== Pyodide 初始化 ==========
const loadPyodide = () => {
return new Promise((resolve, reject) => {
// 检查是否已经加载
if (window.pyodide) {
pyodide.value = window.pyodide
pythonReady.value = true
resolve(window.pyodide)
return
}
loadingPython.value = true
// 动态加载 Pyodide(生产环境默认 CDN 优先,避免本地静态资源缺失导致 404 卡住/报错)
// 可通过环境变量自定义:
// - VUE_APP_PYODIDE_CDN_BASE: 覆盖 CDN 基础路径(需以 / 结尾或会自动补齐)
// - VUE_APP_PYODIDE_LOCAL_BASE: 覆盖本地基础路径(需以 / 结尾或会自动补齐)
// - VUE_APP_PYODIDE_PREFER_CDN: 'true'/'false' 强制优先级
const PYODIDE_VERSION = '0.25.0'
const _ensureTrailingSlash = (s) => (s && s.endsWith('/')) ? s : (s ? (s + '/') : s)
const defaultLocalBase = `/assets/pyodide/v${PYODIDE_VERSION}/full/`
const defaultCdnBase = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`
const localBase = _ensureTrailingSlash(process.env.VUE_APP_PYODIDE_LOCAL_BASE || defaultLocalBase)
const cdnBase = _ensureTrailingSlash(process.env.VUE_APP_PYODIDE_CDN_BASE || defaultCdnBase)
const preferCdnEnv = (process.env.VUE_APP_PYODIDE_PREFER_CDN || '').toString().toLowerCase()
const preferCdn = preferCdnEnv
? (preferCdnEnv === 'true' || preferCdnEnv === '1' || preferCdnEnv === 'yes')
: (process.env.NODE_ENV === 'production')
const loadScript = (src) => new Promise((resolve, reject) => {
// If script already inserted, reuse it
const existing = document.querySelector(`script[data-pyodide-src="${src}"]`)
if (existing) {
// If already loaded, resolve immediately.
if (typeof window.loadPyodide === 'function') return resolve()
// Otherwise wait for load/error.
existing.addEventListener('load', () => resolve(), { once: true })
existing.addEventListener('error', () => reject(new Error('Pyodide 脚本加载失败')), { once: true })
return
}
const s = document.createElement('script')
s.dataset.pyodideSrc = src
s.src = src
s.onload = () => resolve()
s.onerror = () => reject(new Error('Pyodide 脚本加载失败'))
document.head.appendChild(s)
})
const initFromBase = async (baseUrl) => {
if (typeof window.loadPyodide !== 'function') {
throw new Error('loadPyodide 函数未找到')
}
window.pyodide = await window.loadPyodide({ indexURL: baseUrl })
// 预加载 pandas 和 numpy
await window.pyodide.loadPackage(['pandas', 'numpy'])
pyodide.value = window.pyodide
pythonReady.value = true
loadingPython.value = false
resolve(window.pyodide)
}
(async () => {
const tryLoad = async (base) => {
await loadScript(base + 'pyodide.js')
await initFromBase(base)
}
try {
if (preferCdn) {
// 1) CDN-first (production default)
await tryLoad(cdnBase)
} else {
// 1) Local-first (dev convenience)
await tryLoad(localBase)
}
} catch (firstErr) {
try {
// 2) Fallback
await tryLoad(preferCdn ? localBase : cdnBase)
} catch (secondErr) {
throw secondErr || firstErr
}
}
})().catch((err) => {
loadingPython.value = false
pyodideLoadFailed.value = true
reject(err)
})
})
}
// ========== Python 代码解析 ==========
// 解析 Python 代码,提取参数信息
const parsePythonStrategy = (code) => {
if (!code || typeof code !== 'string') {
return null
}
try {
// 简单的参数提取:查找类似 @param 或 #param 的注释,或者函数参数
// 提取可能的参数
const params = {}
// 尝试从代码中提取参数(如果有的话)
// 例如:查找类似 span=144 这样的参数
const paramMatches = code.match(/(\w+)\s*=\s*(\d+\.?\d*)/g)
if (paramMatches) {
paramMatches.forEach(match => {
const parts = match.split('=')
if (parts.length === 2) {
const key = parts[0].trim()
const value = parseFloat(parts[1].trim())
if (!isNaN(value)) {
params[key] = value
}
}
})
}
// 返回解析结果
return {
params: params,
plots: [], // 从代码中无法直接提取 plots,需要在执行时确定
success: true
}
} catch (err) {
// 即使解析失败,也返回一个基本对象,允许执行
return {
params: {},
plots: [],
success: false
}
}
}
// ========== Python 执行引擎 ==========
const executePythonStrategy = async (userCode, klineData, params = {}, indicatorInfo = {}) => {
if (!pythonReady.value || !pyodide.value) {
// 如果正在加载,等待一段时间后重试
if (loadingPython.value) {
// 等待最多 15 秒(30次 * 500ms
let waitCount = 0
while (loadingPython.value && waitCount < 30) {
await new Promise(resolve => setTimeout(resolve, 500))
waitCount++
// 如果加载完成,退出循环
if (pythonReady.value && pyodide.value) {
break
}
}
}
// 如果仍然未就绪,检查是否加载失败
if (!pythonReady.value || !pyodide.value) {
// 如果不在加载中,说明加载失败或超时
if (!loadingPython.value) {
pyodideLoadFailed.value = true
} else {
// 如果还在加载中但超时了,也标记为失败
loadingPython.value = false
pyodideLoadFailed.value = true
}
throw new Error('Python 引擎未就绪,请等待加载完成')
}
}
try {
// 检查代码是否需要解密(购买的指标)
let finalCode = userCode
const isEncrypted = indicatorInfo.is_encrypted || indicatorInfo.isEncrypted || 0
if (isEncrypted || needsDecrypt(userCode, isEncrypted)) {
// 获取用户ID(优先级:indicatorInfo > props > params
const userId = indicatorInfo.user_id || indicatorInfo.userId || props.userId || params.userId
// 使用原始数据库ID(originalId),如果没有则使用id
const indicatorId = indicatorInfo.originalId || indicatorInfo.id || params.indicatorId
if (userId && indicatorId) {
try {
finalCode = await decryptCodeAuto(finalCode, userId, indicatorId)
} catch (decryptError) {
throw new Error('代码解密失败,无法执行指标: ' + (decryptError.message || '未知错误'))
}
} else {
throw new Error('缺少必要的解密参数(用户ID或指标ID),无法执行加密指标')
}
}
// 1. 数据转换:将 JS 的 klineData / params 转换为 JSON 字符串
// klineData 可能是内部格式(time)或 KLineChart 格式(timestamp
const rawData = klineData.map(item => {
// 兼容两种格式
let timeValue = item.timestamp || item.time
// 如果是秒级时间戳,转换为毫秒
if (timeValue < 1e10) {
timeValue = timeValue * 1000
}
return {
time: Math.floor(timeValue / 1000), // Python 端使用秒级时间戳
open: parseFloat(item.open) || 0,
high: parseFloat(item.high) || 0,
low: parseFloat(item.low) || 0,
close: parseFloat(item.close) || 0,
volume: parseFloat(item.volume) || 0
}
})
const rawDataJson = JSON.stringify(rawData)
const paramsJson = JSON.stringify(params || {})
// 2. 构建 Python 执行代码
// 转义 JSON 字符串中的特殊字符
const escapedJson = rawDataJson.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r')
const escapedParams = paramsJson.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r')
const pythonCode = `
import json
import pandas as pd
import numpy as np
# 递归清理 NaN 值的函数
def clean_nan(obj):
if isinstance(obj, dict):
return {k: clean_nan(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [clean_nan(item) for item in obj]
elif isinstance(obj, (pd.Series, np.ndarray)):
return [None if (isinstance(x, float) and (np.isnan(x) or np.isinf(x))) else x for x in obj]
elif isinstance(obj, (float, np.floating)):
if np.isnan(obj) or np.isinf(obj):
return None
return float(obj)
elif pd.isna(obj):
return None
else:
return obj
# 接收 JSON 数据
raw_data = json.loads('${escapedJson}')
params = json.loads('${escapedParams}')
# 将前端参数注入为指标代码可直接使用的变量(对齐回测/实盘执行环境)
# 兼容多种命名(snake_case / camelCase
def _get_param(key, default=None):
if key in params:
return params.get(key, default)
# camelCase fallback
camel = ''.join([key.split('_')[0]] + [p.capitalize() for p in key.split('_')[1:]])
return params.get(camel, default)
try:
leverage = float(_get_param('leverage', 1) or 1)
except Exception:
leverage = 1
trade_direction = _get_param('trade_direction', _get_param('tradeDirection', 'both')) or 'both'
try:
initial_position = int(_get_param('initial_position', 0) or 0)
except Exception:
initial_position = 0
try:
initial_avg_entry_price = float(_get_param('initial_avg_entry_price', 0.0) or 0.0)
except Exception:
initial_avg_entry_price = 0.0
try:
initial_position_count = int(_get_param('initial_position_count', 0) or 0)
except Exception:
initial_position_count = 0
try:
initial_last_add_price = float(_get_param('initial_last_add_price', 0.0) or 0.0)
except Exception:
initial_last_add_price = 0.0
try:
initial_highest_price = float(_get_param('initial_highest_price', 0.0) or 0.0)
except Exception:
initial_highest_price = 0.0
# 转换为 DataFrame
df = pd.DataFrame(raw_data)
# 转换数据类型
df['open'] = df['open'].astype(float)
df['high'] = df['high'].astype(float)
df['low'] = df['low'].astype(float)
df['close'] = df['close'].astype(float)
df['volume'] = df['volume'].astype(float)
# 用户代码(已解密)
${finalCode}
# 构造输出(如果用户没有定义 output,则尝试从 result_json 获取)
if 'output' not in locals():
if 'result_json' in locals():
output = json.loads(result_json)
else:
output = {"plots": []}
else:
# 确保 output 是字典格式
if isinstance(output, str):
output = json.loads(output)
# 清理 output 中的所有 NaN 值
output = clean_nan(output)
# 返回 JSON 字符串
json.dumps(output)
`
// 3. 执行 Python 代码
const resultJson = await pyodide.value.runPythonAsync(pythonCode)
// 检查返回结果
if (!resultJson || typeof resultJson !== 'string') {
throw new Error(`Python 代码执行后未返回有效的 JSON 字符串,返回类型: ${typeof resultJson}`)
}
let result
try {
result = JSON.parse(resultJson)
} catch (parseError) {
throw new Error(`JSON 解析失败: ${parseError.message}。可能是数据中包含 NaN 或其他无效值。`)
}
// 4. 验证和格式化输出
if (!result) {
return { plots: [], signals: [], calculatedVars: {} }
}
// 确保 plots 存在且为数组
if (!result.plots || !Array.isArray(result.plots)) {
result.plots = []
}
// 5. 处理每个 plot 的数据,将 NaN 转换为 null
result.plots = result.plots.map(plot => {
if (plot.data && Array.isArray(plot.data)) {
plot.data = plot.data.map(val => {
if (val === null || val === undefined || (typeof val === 'number' && isNaN(val))) {
return null
}
return val
})
}
return plot
})
// 6. 处理 signals(如果有)
if (result.signals && Array.isArray(result.signals)) {
result.signals = result.signals.map(signal => {
if (signal.data && Array.isArray(signal.data)) {
signal.data = signal.data.map(val => {
if (val === null || val === undefined || (typeof val === 'number' && isNaN(val))) {
return null
}
return val
})
}
return signal
})
}
// 7. 确保 calculatedVars 存在
if (!result.calculatedVars) {
result.calculatedVars = {}
}
return result
} catch (err) {
throw new Error(`Python 执行失败: ${err.message}`)
}
}
// --- 指标计算函数 ---
// 这些函数可能通过 indicator.calculate 间接调用,所以 ESLint 可能无法识别
// eslint-disable-next-line no-unused-vars
function calculateSMA (data, length) {
const result = []
for (let i = 0; i < data.length; i++) {
if (i < length - 1) {
result.push(null)
} else {
let sum = 0
for (let j = i - length + 1; j <= i; j++) {
sum += data[j].close
}
result.push(sum / length)
}
}
return result
}
function calculateEMA (data, length) {
const result = []
const multiplier = 2 / (length + 1)
let ema = null
for (let i = 0; i < data.length; i++) {
if (i === 0) {
ema = data[i].close
} else {
ema = (data[i].close - ema) * multiplier + ema
}
result.push(ema)
}
return result
}
// eslint-disable-next-line no-unused-vars
function calculateBollingerBands (data, length, mult) {
// 内部计算SMA
const sma = []
for (let i = 0; i < data.length; i++) {
if (i < length - 1) {
sma.push(null)
} else {
let sum = 0
for (let j = i - length + 1; j <= i; j++) {
sum += data[j].close
}
sma.push(sum / length)
}
}
const result = []
for (let i = 0; i < data.length; i++) {
if (i < length - 1) {
result.push({ upper: null, middle: null, lower: null })
continue
}
let sum = 0
for (let j = i - length + 1; j <= i; j++) {
sum += Math.pow(data[j].close - sma[i], 2)
}
const std = Math.sqrt(sum / length)
result.push({
upper: sma[i] + mult * std,
middle: sma[i],
lower: sma[i] - mult * std
})
}
return result
}
// eslint-disable-next-line no-unused-vars
function calculateRSI (data, length) {
const result = []
let avgGain = 0
let avgLoss = 0
for (let i = 0; i < data.length; i++) {
if (i === 0) {
result.push(null)
continue
}
const change = data[i].close - data[i - 1].close
const gain = change > 0 ? change : 0
const loss = change < 0 ? Math.abs(change) : 0
if (i < length) {
// 前length-1个值,累积但不计算RSI
result.push(null)
} else if (i === length) {
// 第length个值,计算初始平均值
let sumGain = 0
let sumLoss = 0
for (let j = 1; j <= length; j++) {
const chg = data[j].close - data[j - 1].close
if (chg > 0) sumGain += chg
else sumLoss += Math.abs(chg)
}
avgGain = sumGain / length
avgLoss = sumLoss / length
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss
result.push(100 - (100 / (1 + rs)))
} else {
// 后续值,使用平滑移动平均
avgGain = (avgGain * (length - 1) + gain) / length
avgLoss = (avgLoss * (length - 1) + loss) / length
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss
result.push(100 - (100 / (1 + rs)))
}
}
return result
}
// eslint-disable-next-line no-unused-vars
function calculateMACD (data, fast, slow, signal) {
const fastEMA = calculateEMA(data, fast)
const slowEMA = calculateEMA(data, slow)
const macdLine = []
// 计算MACD线
for (let i = 0; i < data.length; i++) {
if (fastEMA[i] == null || slowEMA[i] == null) {
macdLine.push(null)
} else {
macdLine.push(fastEMA[i] - slowEMA[i])
}
}
// 计算Signal线 (MACD的EMA)
// 需要保持原始数组长度,对null值进行特殊处理
const signalLine = []
const histogram = []
let signalEMA = null
let signalStartIdx = -1
// 找到第一个非null的MACD值作为signal计算的起点
for (let i = 0; i < macdLine.length; i++) {
if (macdLine[i] !== null && signalStartIdx === -1) {
signalStartIdx = i
signalEMA = macdLine[i]
break
}
}
// 如果找到了起点,继续计算signal
if (signalStartIdx >= 0) {
const multiplier = 2 / (signal + 1)
for (let i = 0; i < macdLine.length; i++) {
if (i < signalStartIdx + signal - 1) {
// signal需要等待足够的MACD值
signalLine.push(null)
histogram.push(null)
} else if (macdLine[i] === null) {
signalLine.push(null)
histogram.push(null)
} else {
if (i === signalStartIdx + signal - 1) {
// 第一个signal值:计算前signal个MACD值的平均值
let sum = 0
let count = 0
for (let j = signalStartIdx; j <= i; j++) {
if (macdLine[j] !== null) {
sum += macdLine[j]
count++
}
}
signalEMA = sum / count
} else {
// 后续值:使用EMA公式
signalEMA = (macdLine[i] - signalEMA) * multiplier + signalEMA
}
signalLine.push(signalEMA)
histogram.push(macdLine[i] - signalEMA)
}
}
} else {
// 如果没有有效的MACD值,全部设为null
for (let i = 0; i < macdLine.length; i++) {
signalLine.push(null)
histogram.push(null)
}
}
return { macd: macdLine, signal: signalLine, histogram }
}
// 计算ATR(平均真实波幅)
function calculateATR (data, period) {
const tr = [] // 真实波幅
for (let i = 0; i < data.length; i++) {
if (i === 0) {
tr.push(data[i].high - data[i].low)
} else {
const hl = data[i].high - data[i].low
const hc = Math.abs(data[i].high - data[i - 1].close)
const lc = Math.abs(data[i].low - data[i - 1].close)
tr.push(Math.max(hl, hc, lc))
}
}
// 计算ATRTR的SMA
const atr = []
for (let i = 0; i < data.length; i++) {
if (i < period - 1) {
atr.push(null)
} else {
let sum = 0
for (let j = i - period + 1; j <= i; j++) {
sum += tr[j]
}
atr.push(sum / period)
}
}
return atr
}
// 计算CCI (商品通道指数)
function calculateCCI (data, length) {
const cci = []
for (let i = 0; i < data.length; i++) {
if (i < length - 1) {
cci.push(null)
} else {
// 计算典型价格 (TP)
const tp = []
for (let j = i - length + 1; j <= i; j++) {
tp.push((data[j].high + data[j].low + data[j].close) / 3)
}
// 计算TP的SMA
const sma = tp.reduce((sum, val) => sum + val, 0) / length
// 计算平均偏差
const meanDev = tp.reduce((sum, val) => sum + Math.abs(val - sma), 0) / length
// 计算CCI
const currentTP = (data[i].high + data[i].low + data[i].close) / 3
const cciValue = meanDev === 0 ? 0 : (currentTP - sma) / (0.015 * meanDev)
cci.push(cciValue)
}
}
return cci
}
// 计算Williams %R (威廉指标)
function calculateWilliamsR (data, length) {
const williamsR = []
for (let i = 0; i < data.length; i++) {
if (i < length - 1) {
williamsR.push(null)
} else {
let highest = -Infinity
let lowest = Infinity
for (let j = i - length + 1; j <= i; j++) {
highest = Math.max(highest, data[j].high)
lowest = Math.min(lowest, data[j].low)
}
const wr = (highest - lowest) === 0 ? -50 : ((highest - data[i].close) / (highest - lowest)) * -100
williamsR.push(wr)
}
}
return williamsR
}
// 计算MFI (资金流量指标)
function calculateMFI (data, length) {
const mfi = []
for (let i = 0; i < data.length; i++) {
if (i < length) {
mfi.push(null)
} else {
let positiveFlow = 0
let negativeFlow = 0
for (let j = i - length + 1; j <= i; j++) {
const typicalPrice = (data[j].high + data[j].low + data[j].close) / 3
const rawMoneyFlow = typicalPrice * data[j].volume
if (j > i - length + 1) {
const prevTypicalPrice = (data[j - 1].high + data[j - 1].low + data[j - 1].close) / 3
if (typicalPrice > prevTypicalPrice) {
positiveFlow += rawMoneyFlow
} else if (typicalPrice < prevTypicalPrice) {
negativeFlow += rawMoneyFlow
}
}
}
const moneyFlowRatio = negativeFlow === 0 ? 100 : positiveFlow / negativeFlow
const mfiValue = 100 - (100 / (1 + moneyFlowRatio))
mfi.push(mfiValue)
}
}
return mfi
}
// 计算ADX (平均趋向指数) 和 DMI (+DI, -DI)
function calculateADX (data, length) {
const plusDI = []
const minusDI = []
const adx = []
// 计算真实波幅(TR)和方向移动(+DM, -DM)
const tr = []
const plusDM = []
const minusDM = []
for (let i = 0; i < data.length; i++) {
if (i === 0) {
tr.push(data[i].high - data[i].low)
plusDM.push(0)
minusDM.push(0)
} else {
const hl = data[i].high - data[i].low
const hc = Math.abs(data[i].high - data[i - 1].close)
const lc = Math.abs(data[i].low - data[i - 1].close)
tr.push(Math.max(hl, hc, lc))
const upMove = data[i].high - data[i - 1].high
const downMove = data[i - 1].low - data[i].low
if (upMove > downMove && upMove > 0) {
plusDM.push(upMove)
} else {
plusDM.push(0)
}
if (downMove > upMove && downMove > 0) {
minusDM.push(downMove)
} else {
minusDM.push(0)
}
}
}
// 计算平滑的TR, +DM, -DM
const smoothTR = []
const smoothPlusDM = []
const smoothMinusDM = []
for (let i = 0; i < data.length; i++) {
if (i < length - 1) {
smoothTR.push(null)
smoothPlusDM.push(null)
smoothMinusDM.push(null)
plusDI.push(null)
minusDI.push(null)
adx.push(null)
} else if (i === length - 1) {
// 初始值:简单求和
let sumTR = 0
let sumPlusDM = 0
let sumMinusDM = 0
for (let j = 0; j <= i; j++) {
sumTR += tr[j]
sumPlusDM += plusDM[j]
sumMinusDM += minusDM[j]
}
smoothTR.push(sumTR)
smoothPlusDM.push(sumPlusDM)
smoothMinusDM.push(sumMinusDM)
} else {
// 平滑计算:Wilder's smoothing
smoothTR.push(smoothTR[i - 1] - (smoothTR[i - 1] / length) + tr[i])
smoothPlusDM.push(smoothPlusDM[i - 1] - (smoothPlusDM[i - 1] / length) + plusDM[i])
smoothMinusDM.push(smoothMinusDM[i - 1] - (smoothMinusDM[i - 1] / length) + minusDM[i])
}
if (i >= length - 1) {
const trVal = smoothTR[i]
const plusDMVal = smoothPlusDM[i]
const minusDMVal = smoothMinusDM[i]
if (trVal === 0) {
plusDI.push(0)
minusDI.push(0)
} else {
plusDI.push((plusDMVal / trVal) * 100)
minusDI.push((minusDMVal / trVal) * 100)
}
// 计算DX
if (i >= length - 1) {
const diSum = plusDI[i] + minusDI[i]
const dx = diSum === 0 ? 0 : Math.abs(plusDI[i] - minusDI[i]) / diSum * 100
// 计算ADX (DX的平滑)
if (i === length - 1) {
adx.push(dx)
} else if (i === length) {
// 第二个ADX值:前两个DX的平均值
const prevDX = Math.abs(plusDI[i - 1] - minusDI[i - 1]) / (plusDI[i - 1] + minusDI[i - 1]) * 100
adx.push((prevDX + dx) / 2)
} else {
// ADX平滑:Wilder's smoothing
adx.push((adx[i - 1] * (length - 1) + dx) / length)
}
}
}
}
return { adx, plusDI, minusDI }
}
// 计算OBV (能量潮指标)
function calculateOBV (data) {
const obv = []
let obvValue = 0
for (let i = 0; i < data.length; i++) {
if (i === 0) {
obvValue = data[i].volume
} else {
if (data[i].close > data[i - 1].close) {
obvValue += data[i].volume
} else if (data[i].close < data[i - 1].close) {
obvValue -= data[i].volume
}
// 如果收盘价相同,OBV不变
}
obv.push(obvValue)
}
return obv
}
// 计算AD (积累/派发线)
function calculateAD (data) {
const ad = []
let adValue = 0
for (let i = 0; i < data.length; i++) {
const high = data[i].high
const low = data[i].low
const close = data[i].close
const volume = data[i].volume
if (high !== low) {
const clv = ((close - low) - (high - close)) / (high - low)
adValue += clv * volume
}
ad.push(adValue)
}
return ad
}
// 计算ADOSC (积累/派发振荡器) = AD的快速EMA - AD的慢速EMA
function calculateADOSC (data, fast, slow) {
const ad = calculateAD(data)
const fastEMA = []
const slowEMA = []
const adosc = []
const fastMultiplier = 2 / (fast + 1)
const slowMultiplier = 2 / (slow + 1)
let fastEMAValue = ad[0]
let slowEMAValue = ad[0]
for (let i = 0; i < ad.length; i++) {
if (i === 0) {
fastEMA.push(ad[0])
slowEMA.push(ad[0])
adosc.push(0)
} else {
fastEMAValue = (ad[i] - fastEMAValue) * fastMultiplier + fastEMAValue
slowEMAValue = (ad[i] - slowEMAValue) * slowMultiplier + slowEMAValue
fastEMA.push(fastEMAValue)
slowEMA.push(slowEMAValue)
adosc.push(fastEMAValue - slowEMAValue)
}
}
return adosc
}
// 计算KDJ (随机指标)
function calculateKDJ (data, period, kPeriod, dPeriod) {
const kValues = []
const dValues = []
const jValues = []
for (let i = 0; i < data.length; i++) {
if (i < period - 1) {
kValues.push(null)
dValues.push(null)
jValues.push(null)
} else {
// 找到period内的最高价和最低价
let highest = -Infinity
let lowest = Infinity
for (let j = i - period + 1; j <= i; j++) {
highest = Math.max(highest, data[j].high)
lowest = Math.min(lowest, data[j].low)
}
// 计算RSV
const rsv = (highest - lowest) === 0 ? 50 : ((data[i].close - lowest) / (highest - lowest)) * 100
// 计算K值 (RSV的移动平均)
if (kValues[i - 1] === null) {
kValues.push(rsv)
} else {
kValues.push((rsv * 2 + kValues[i - 1] * (kPeriod - 2)) / kPeriod)
}
// 计算D值 (K值的移动平均)
if (dValues[i - 1] === null) {
dValues.push(kValues[i])
} else {
dValues.push((kValues[i] * 2 + dValues[i - 1] * (dPeriod - 2)) / dPeriod)
}
// 计算J值
jValues.push(3 * kValues[i] - 2 * dValues[i])
}
}
return { k: kValues, d: dValues, j: jValues }
}
// ========== 注册自定义信号 Overlay (Signal Tag) ==========
// 这是一个能够绘制 "圆点 + 带背景色文字框" 的自定义覆盖物
// ========== 注册自定义信号 Overlay (Signal Tag) ==========
registerOverlay({
name: 'signalTag',
// 【关键修改1】必须改为 1。告诉图表这个图形只需要一个点就画完了。
// 只要这里是 1,图表就不会画那个蓝色的"编辑中"手柄。
totalStep: 1,
// 【关键修改2】彻底禁止该 Overlay 响应任何鼠标事件
// 这样鼠标放上去也不会有蓝色的选中框
lock: true,
needDefaultPointFigure: false,
needDefaultXAxisFigure: false,
needDefaultYAxisFigure: false,
// 【建议保留】进一步确保不拦截事件
checkEventOn: () => false,
createPointFigures: ({ coordinates, overlay }) => {
const { text } = overlay.extendData || {}
const color = overlay.extendData?.color || '#555555'
// 1. 获取信号点坐标
if (!coordinates[0]) return []
const x = coordinates[0].x
const signalY = coordinates[0].y // Point 0: Python中计算的标签位置(已包含垂直间距)
// 2. 获取K线极值坐标(用于画圆点)
const anchorY = coordinates[1] ? coordinates[1].y : signalY // Point 1: K线的high/low
const boxPaddingX = 8
const boxPaddingY = 4
const fontSize = 12
const textStr = String(text || '')
// 简单的字符宽度估算
const textWidth = textStr.split('').reduce((acc, char) => acc + (char.charCodeAt(0) > 255 ? 12 : 7), 0)
const boxWidth = textWidth + boxPaddingX * 2
const boxHeight = fontSize + boxPaddingY * 2
// Compatibility: old overlays used extendData.type='buy'/'sell', new overlays use extendData.side='buy'/'sell'
const side = overlay.extendData?.side || overlay.extendData?.type || 'buy'
const isBuy = side === 'buy'
// 3. 计算 Box 的 Y 轴位置
// 【关键修改】直接使用 signalY(Python中已经调整好的位置),不再使用固定margin
// signalY 已经包含了反转信号的垂直间距调整
const boxY = isBuy ? signalY : (signalY - boxHeight)
// 计算线段连接点
// 圆点画在K线极值位置(anchorY),紧挨着K线
// 连线从圆点连到标签框
const circleY = anchorY // 圆点位置:K线的High或Low
const lineStartY = circleY // 连线起点:圆点位置
const lineEndY = isBuy ? boxY : (boxY + boxHeight) // 连线终点:标签框边缘
return [
// 1. 虚线 (从圆点连到标签框)
{
type: 'line',
attrs: {
coordinates: [
{ x, y: lineStartY }, // 从圆点(K线极值位置)
{ x, y: lineEndY } // 连到标签框边缘
]
},
styles: { style: 'stroke', color: color, dashedValue: [2, 2] },
ignoreEvent: true
},
// 2. 圆点 (画在K线极值位置,紧挨着K线)
{
type: 'circle',
attrs: { x, y: circleY, r: 4 },
styles: { style: 'fill', color: color },
ignoreEvent: true
},
// 3. 背景框 (基于 boxY)
{
type: 'rect',
attrs: {
x: x - boxWidth / 2,
y: boxY,
width: boxWidth,
height: boxHeight,
r: 4
},
styles: { style: 'fill', color: color, borderSize: 0 },
ignoreEvent: true
},
// 4. 文字
{
type: 'text',
attrs: {
x: x,
y: boxY + boxHeight / 2,
text: textStr,
align: 'center',
baseline: 'middle'
},
styles: { color: '#ffffff', size: fontSize, weight: 'bold', backgroundColor: color, borderRadius: 5 },
ignoreEvent: true
}
]
}
})
// --- 数据加载相关函数 ---
// 格式化数据为 KLineChart 格式(timestamp 需要是毫秒)
const formatKlineData = (data) => {
return data.map(item => {
let timeValue = item.time || item.timestamp
if (typeof timeValue === 'string') {
timeValue = parseInt(timeValue)
}
// KLineChart 需要毫秒时间戳,如果当前是秒级,转换为毫秒
if (timeValue < 1e10) {
timeValue = timeValue * 1000
}
return {
timestamp: timeValue,
open: parseFloat(item.open),
high: parseFloat(item.high),
low: parseFloat(item.low),
close: parseFloat(item.close),
volume: parseFloat(item.volume || 0)
}
}).filter(item => item.timestamp && !isNaN(item.open) && !isNaN(item.high) && !isNaN(item.low) && !isNaN(item.close))
.sort((a, b) => a.timestamp - b.timestamp)
}
const updatePricePanel = (data) => {
if (data.length > 0) {
const last = data[data.length - 1]
if (data.length > 1) {
const prev = data[data.length - 2]
const price = last.close.toFixed(2)
const change = ((last.close - prev.close) / prev.close) * 100
emit('price-change', { price, change })
} else {
const price = last.close.toFixed(2)
emit('price-change', { price, change: 0 })
}
}
}
// 将 KLineChart 格式的数据转换为内部格式(用于 isSameTimeframe 等函数)
const convertToInternalFormat = (data) => {
return data.map(item => ({
time: Math.floor(item.timestamp / 1000), // 转回秒级时间戳用于比较
open: item.open,
high: item.high,
low: item.low,
close: item.close,
volume: item.volume
}))
}
const isSameTimeframe = (time1, time2, tf) => {
const date1 = new Date(time1 * 1000)
const date2 = new Date(time2 * 1000)
switch (tf) {
case '1m':
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate() &&
date1.getHours() === date2.getHours() &&
date1.getMinutes() === date2.getMinutes()
case '5m':
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate() &&
date1.getHours() === date2.getHours() &&
Math.floor(date1.getMinutes() / 5) === Math.floor(date2.getMinutes() / 5)
case '15m':
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate() &&
date1.getHours() === date2.getHours() &&
Math.floor(date1.getMinutes() / 15) === Math.floor(date2.getMinutes() / 15)
case '30m':
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate() &&
date1.getHours() === date2.getHours() &&
Math.floor(date1.getMinutes() / 30) === Math.floor(date2.getMinutes() / 30)
case '1H':
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate() &&
date1.getHours() === date2.getHours()
case '4H':
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate() &&
Math.floor(date1.getHours() / 4) === Math.floor(date2.getHours() / 4)
case '1D':
return date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
case '1W':
const week1 = Math.floor((date1.getTime() - new Date(date1.getFullYear(), 0, 1).getTime()) / (7 * 24 * 60 * 60 * 1000))
const week2 = Math.floor((date2.getTime() - new Date(date2.getFullYear(), 0, 1).getTime()) / (7 * 24 * 60 * 60 * 1000))
return date1.getFullYear() === date2.getFullYear() && week1 === week2
default:
return time1 === time2
}
}
const loadKlineData = async (silent = false) => {
if (!props.symbol) return
if (loading.value && !silent) return
loading.value = true
error.value = null
try {
let formattedData = []
try {
const response = await request({
url: '/api/indicator/kline',
method: 'post',
data: {
market: props.market,
symbol: props.symbol,
timeframe: props.timeframe,
limit: 500
}
})
if (response.code === 1 && response.data && Array.isArray(response.data)) {
formattedData = formatKlineData(response.data)
} else {
throw new Error(response.msg || '获取K线数据失败')
}
} catch (apiErr) {
throw apiErr
}
// 检查数据是否为空
if (!formattedData || formattedData.length === 0) {
throw new Error('未获取到K线数据')
}
klineData.value = formattedData
hasMoreHistory.value = true
const internalData = convertToInternalFormat(formattedData)
updatePricePanel(internalData)
nextTick(() => {
if (!chartRef.value) {
initChart()
} else {
// 确保数据格式正确
const validData = klineData.value.filter(item =>
item.timestamp &&
!isNaN(item.open) &&
!isNaN(item.high) &&
!isNaN(item.low) &&
!isNaN(item.close)
)
if (validData.length > 0 && chartRef.value) {
// 使用 applyNewData 初始化
try {
chartRef.value.applyNewData(validData)
} catch (e) {
chartRef.value.applyNewData(validData)
}
// 延迟更新指标
setTimeout(() => {
if (chartRef.value) {
updateIndicators()
}
}, 100)
}
}
if (props.realtimeEnabled) {
stopRealtime()
startRealtime()
}
})
} catch (err) {
error.value = proxy.$t('dashboard.indicator.error.loadDataFailed') + ': ' + (err.message || proxy.$t('dashboard.indicator.error.loadDataFailedDesc'))
// 清空K线数据,不显示图表
klineData.value = []
// 如果有图表实例,清空数据
if (chartRef.value) {
try {
chartRef.value.applyNewData([])
} catch (e) {
}
}
} finally {
loading.value = false
}
}
// 加载更多历史数据(用于滚动加载,保持滚动位置)
const loadMoreHistoryDataForScroll = async (timestamp) => {
if (!props.symbol || !klineData.value || klineData.value.length === 0) {
return
}
// 【核心修复】防止重复请求:如果已经有正在进行的请求,直接返回
if (loadingHistory.value || loadingHistoryPromise) {
// 如果有正在进行的请求,等待它完成
if (loadingHistoryPromise) {
try {
await loadingHistoryPromise
} catch (e) {
}
}
return
}
if (!hasMoreHistory.value) {
// 如果没有更多数据,通知图表
if (chartRef.value && typeof chartRef.value.noMoreData === 'function') {
chartRef.value.noMoreData()
}
return
}
// 立即设置加载状态和创建 Promise,防止并发请求
loadingHistory.value = true
loadingHistoryPromise = (async () => {
// 强制触发更新
await nextTick()
try {
// timestamp 是毫秒时间戳,转换为秒级用于 API
const beforeTime = Math.floor(timestamp / 1000)
const response = await request({
url: '/api/indicator/kline',
method: 'post',
data: {
market: props.market,
symbol: props.symbol,
timeframe: props.timeframe,
limit: 500,
beforeTime: beforeTime // 获取此时间之前的数据
}
})
if (response.code === 1 && response.data && Array.isArray(response.data)) {
const newData = formatKlineData(response.data)
if (newData.length === 0) {
// 没有更多数据了
hasMoreHistory.value = false
if (chartRef.value && typeof chartRef.value.noMoreData === 'function') {
chartRef.value.noMoreData()
}
return
}
// 确保新数据的时间早于传入的时间戳
const filteredNewData = newData.filter(item => item.timestamp < timestamp)
if (filteredNewData.length === 0) {
// 没有更早的数据了
hasMoreHistory.value = false
if (chartRef.value && typeof chartRef.value.noMoreData === 'function') {
chartRef.value.noMoreData()
}
return
}
// 保存当前可见范围,用于恢复滚动位置
// klinecharts 9.x 的 getVisibleRange() 返回的 from/to 是数据索引(整数),不是百分比
let savedVisibleRange = null
try {
if (chartRef.value && typeof chartRef.value.getVisibleRange === 'function') {
savedVisibleRange = chartRef.value.getVisibleRange()
}
} catch (e) {
}
// 记录新数据的数量,用于后续计算偏移
const newDataCount = filteredNewData.length
// 将新数据插入到现有数据的前面
klineData.value = [...filteredNewData, ...klineData.value]
// 使用 applyNewData 添加历史数据(applyMoreData 在 v9.8.0 已废弃)
nextTick(() => {
if (chartRef.value) {
// 应用新数据
chartRef.value.applyNewData(klineData.value)
// 恢复滚动位置
// 由于新数据插入到了前面,原来的索引需要偏移 newDataCount
if (savedVisibleRange && typeof savedVisibleRange.from === 'number') {
// 计算新的可见范围索引
// 原来看的是索引 from 到 to 的数据,现在这些数据的索引变成了 from + newDataCount 到 to + newDataCount
const newFrom = savedVisibleRange.from + newDataCount
const newTo = savedVisibleRange.to + newDataCount
// 使用 setTimeout 确保数据已经渲染完成
setTimeout(() => {
try {
if (chartRef.value) {
// 尝试使用 scrollToDataIndex 方法(如果存在)
if (typeof chartRef.value.scrollToDataIndex === 'function') {
chartRef.value.scrollToDataIndex(newFrom)
} else if (typeof chartRef.value.setVisibleRange === 'function') {
// 使用 setVisibleRange 设置可见范围(参数是数据索引)
chartRef.value.setVisibleRange(newFrom, newTo)
}
}
} catch (e) {
}
}, 50)
}
// 更新指标
updateIndicators()
}
})
} else {
// API返回错误,通知图表加载失败
if (chartRef.value && typeof chartRef.value.noMoreData === 'function') {
chartRef.value.noMoreData()
}
}
} catch (err) {
// 加载失败,通知图表
if (chartRef.value && typeof chartRef.value.noMoreData === 'function') {
chartRef.value.noMoreData()
}
} finally {
loadingHistory.value = false
loadingHistoryPromise = null // 清除请求追踪
}
})() // 立即执行 Promise
// 等待请求完成
try {
await loadingHistoryPromise
} catch (err) {
// 错误已经在内部的 catch 中处理,这里只是确保 Promise 完成
}
}
// 加载更多历史数据(保留原有函数,用于其他场景)
const loadMoreHistoryData = async () => {
if (!props.symbol || !klineData.value || klineData.value.length === 0) {
return
}
if (loadingHistory.value || !hasMoreHistory.value) {
return
}
loadingHistory.value = true
try {
// 获取当前最早的数据时间(转换为秒级用于 API)
const earliestTimestamp = klineData.value[0].timestamp
const earliestTime = Math.floor(earliestTimestamp / 1000) // 转换为秒级
const response = await request({
url: '/api/indicator/kline',
method: 'post',
data: {
market: props.market,
symbol: props.symbol,
timeframe: props.timeframe,
limit: 500,
beforeTime: earliestTime // 获取此时间之前的数据
}
})
if (response.code === 1 && response.data && Array.isArray(response.data)) {
const newData = formatKlineData(response.data)
if (newData.length === 0) {
// 没有更多数据了
hasMoreHistory.value = false
loadingHistory.value = false
return
}
// 确保新数据的时间早于现有最早数据
const filteredNewData = newData.filter(item => item.timestamp < earliestTimestamp)
if (filteredNewData.length === 0) {
// 没有更早的数据了
hasMoreHistory.value = false
loadingHistory.value = false
return
}
// 将新数据插入到现有数据的前面
klineData.value = [...filteredNewData, ...klineData.value]
// 更新图表
nextTick(() => {
if (chartRef.value) {
chartRef.value.applyNewData(klineData.value)
updateIndicators()
}
})
} else {
// API返回错误,但不一定表示没有更多数据,可能是网络问题
// 不设置 hasMoreHistory = false,允许用户重试
}
} catch (err) {
// 加载失败可能是网络问题,不应该立即认为没有更多数据
// 只有在明确知道没有更早数据时才设置 hasMoreHistory = false
// 这里不设置,允许用户重试
} finally {
loadingHistory.value = false
}
}
// 增量更新K线数据(实时更新)
const updateKlineRealtime = async () => {
if (!props.symbol || !klineData.value || klineData.value.length === 0) {
return // 如果没有现有数据,不进行增量更新
}
try {
// 只获取最新的5根K线用于更新
const response = await request({
url: '/api/indicator/kline',
method: 'post',
data: {
market: props.market,
symbol: props.symbol,
timeframe: props.timeframe,
limit: 5 // 只获取最新5根
}
})
if (response.code === 1 && response.data && Array.isArray(response.data) && response.data.length > 0) {
const newData = formatKlineData(response.data)
const existingData = [...klineData.value]
if (newData.length > 0) {
const lastNewTime = Math.floor(newData[newData.length - 1].timestamp / 1000) // 转回秒级用于比较
const lastExistingTime = Math.floor(existingData[existingData.length - 1].timestamp / 1000)
// 判断是否属于同一时间段
if (isSameTimeframe(lastNewTime, lastExistingTime, props.timeframe)) {
// 同一时间段,合并更新最后一根K线的数据
// K线合并规则:
// - open: 保持不变(时间段开始时的价格)
// - high: 取最大值(时间段内的最高价)
// - low: 取最小值(时间段内的最低价)
// - close: 更新为最新价格(当前价格)
// - volume: 累加(时间段内的总成交量)
const existingLast = existingData[existingData.length - 1]
const newLast = newData[newData.length - 1]
existingData[existingData.length - 1] = {
timestamp: existingLast.timestamp, // 保持原有时间戳(毫秒)
open: existingLast.open, // 开盘价保持不变
high: Math.max(existingLast.high, newLast.high), // 最高价取最大值
low: Math.min(existingLast.low, newLast.low), // 最低价取最小值
close: newLast.close, // 收盘价更新为最新价格
volume: existingLast.volume + newLast.volume // 成交量累加
}
klineData.value = existingData
// 更新价格面板(使用内部格式)
const internalData = convertToInternalFormat(klineData.value)
updatePricePanel(internalData)
// 更新 KLineChart - 使用 updateData 方法保持滚动位置
if (chartRef.value && typeof chartRef.value.updateData === 'function') {
// 更新最后一根K线数据,保持滚动位置
// updateData 只需要一个参数:要更新的数据对象(v9.8.0+ 不再接受 callback 参数)
const lastIndex = klineData.value.length - 1
chartRef.value.updateData(existingData[lastIndex])
// 指标计算节流:默认 10 秒刷新一次(避免 1 秒频率下 CPU 拉满)
maybeUpdateIndicators(false)
} else if (chartRef.value) {
// 降级方案:使用 applyNewData(会重置滚动位置)
chartRef.value.applyNewData(klineData.value)
maybeUpdateIndicators(false)
}
} else if (lastNewTime > lastExistingTime) {
// 新的时间段,追加新数据
// 先移除可能重复的K线(基于时间段,而不是精确时间戳)
const uniqueNewData = newData.filter(newItem => {
const newItemTime = Math.floor(newItem.timestamp / 1000)
// 检查是否与现有数据中的任何一条属于同一时间段
return !existingData.some(existingItem => {
const existingItemTime = Math.floor(existingItem.timestamp / 1000)
return isSameTimeframe(newItemTime, existingItemTime, props.timeframe)
})
})
if (uniqueNewData.length > 0) {
klineData.value = [...existingData, ...uniqueNewData]
// 如果数据超过限制,保留最新的数据
if (klineData.value.length > 500) {
klineData.value = klineData.value.slice(-500)
}
// 更新价格面板(使用内部格式)
const internalData = convertToInternalFormat(klineData.value)
updatePricePanel(internalData)
// 更新 KLineChart - 使用 applyMoreData 保持滚动位置
if (chartRef.value && typeof chartRef.value.applyMoreData === 'function') {
// 追加新K线,使用 applyMoreData 保持滚动位置
chartRef.value.applyMoreData(uniqueNewData)
// 新K线出现时强制刷新一次指标
maybeUpdateIndicators(true)
} else if (chartRef.value) {
// 降级方案:使用 applyNewData(会重置滚动位置)
chartRef.value.applyNewData(klineData.value)
maybeUpdateIndicators(true)
}
}
}
// 如果新数据的时间更早,说明没有更新,保持原数据不变
}
}
} catch (err) {
// 增量更新失败时静默处理,不影响现有数据
}
}
// 启动实时更新
const startRealtime = () => {
// 先清除现有定时器
if (realtimeTimer.value) {
clearInterval(realtimeTimer.value)
}
// 根据时间周期智能调整更新频率
const intervalMap = {
'1m': 5000, // 1分钟K线,每5秒更新
'5m': 10000, // 5分钟K线,每10秒更新
'15m': 15000, // 15分钟K线,每15秒更新
'30m': 30000, // 30分钟K线,每30秒更新
'1H': 60000, // 1小时K线,每60秒更新
'4H': 300000, // 4小时K线,每5分钟更新
'1D': 600000, // 日线,每10分钟更新
'1W': 1800000 // 周线,每30分钟更新
}
// UI体验优先:允许高频刷新以便观察“价格/K线/指标”同步效果。
// 这里将刷新间隔上限限制为 1 秒;若指标计算较慢,updateIndicators 内部会用锁避免重入。
const base = intervalMap[props.timeframe] || 10000
realtimeInterval.value = Math.min(base, 1000)
// 如果启用了实时更新且有选中的标的
if (props.realtimeEnabled && props.symbol && klineData.value.length > 0) {
realtimeTimer.value = setInterval(() => {
// 只在不在加载中且有现有数据时进行增量更新
if (!loading.value && props.symbol && klineData.value && klineData.value.length > 0) {
updateKlineRealtime() // 增量更新K线
}
}, realtimeInterval.value)
}
}
// 停止实时更新
const stopRealtime = () => {
if (realtimeTimer.value) {
clearInterval(realtimeTimer.value)
realtimeTimer.value = null
}
}
// --- 图表初始化函数 ---
const initChart = () => {
const container = document.getElementById('kline-chart-container')
if (!container) return
if (container.clientWidth === 0 || container.clientHeight === 0) {
let retryCount = 0
const maxRetries = 10
const checkAndInit = () => {
const checkContainer = document.getElementById('kline-chart-container')
if (checkContainer && checkContainer.clientWidth > 0 && checkContainer.clientHeight > 0) {
initChart()
} else if (retryCount < maxRetries) {
retryCount++
setTimeout(checkAndInit, 200)
} else {
initChart()
}
}
setTimeout(checkAndInit, 200)
return
}
// 如果图表已存在,先销毁
if (chartRef.value) {
try {
chartRef.value.destroy()
} catch (e) {}
chartRef.value = null
}
try {
// 初始化 KLineChart
const container = document.getElementById('kline-chart-container')
if (!container) {
throw new Error('容器元素不存在')
}
// 尝试使用配置选项初始化,看是否支持内置画线工具栏
try {
// 尝试使用第二个参数传入配置选项
chartRef.value = init(container, {
drawingBarVisible: true, // 尝试启用内置画线工具栏
overlay: {
visible: true
}
})
} catch (e) {
// 如果不支持配置选项,使用默认初始化
chartRef.value = init(container)
}
// 如果配置选项方式不支持,尝试调用方法启用画线工具栏
if (chartRef.value && typeof chartRef.value.setDrawingBarVisible === 'function') {
chartRef.value.setDrawingBarVisible(true)
} else if (chartRef.value && typeof chartRef.value.setDrawingBar === 'function') {
chartRef.value.setDrawingBar(true)
} else if (chartRef.value && typeof chartRef.value.enableDrawing === 'function') {
chartRef.value.enableDrawing(true)
}
if (!chartRef.value) {
throw new Error('图表初始化失败:无法创建图表实例')
}
// 调试:输出图表实例的所有方法,检查是否有画线工具栏相关的方法
if (chartRef.value) {
// 检查是否有内置画线工具栏的方法
if (typeof chartRef.value.setDrawingBarVisible === 'function') {
chartRef.value.setDrawingBarVisible(true)
}
if (typeof chartRef.value.setDrawingBar === 'function') {
chartRef.value.setDrawingBar(true)
}
if (typeof chartRef.value.enableDrawing === 'function') {
chartRef.value.enableDrawing(true)
}
}
// 设置主题样式
updateChartTheme()
// 监听覆盖物创建完成事件,自动退出绘制模式
if (chartRef.value && typeof chartRef.value.subscribeAction === 'function') {
// 监听覆盖物创建完成事件
chartRef.value.subscribeAction('onOverlayCreated', (overlay) => {
// 如果是通过画线工具创建的覆盖物,记录ID并退出绘制模式
if (activeDrawingTool.value && overlay && overlay.id) {
addedDrawingOverlayIds.value.push(overlay.id)
// 重置激活状态
activeDrawingTool.value = null
// 退出绘制模式
try {
if (typeof chartRef.value.overrideOverlay === 'function') {
chartRef.value.overrideOverlay(null)
}
} catch (e) {
}
}
})
// 监听覆盖物绘制完成事件(某些版本可能使用此事件)
if (typeof chartRef.value.subscribeAction === 'function') {
try {
chartRef.value.subscribeAction('onOverlayComplete', (overlay) => {
if (activeDrawingTool.value && overlay && overlay.id) {
addedDrawingOverlayIds.value.push(overlay.id)
activeDrawingTool.value = null
// 退出绘制模式 - 不调用 overrideOverlay(null),因为会导致错误
}
})
} catch (e) {
// 如果 onOverlayComplete 不存在,忽略错误
}
}
// 监听覆盖物移除事件
chartRef.value.subscribeAction('onOverlayRemoved', (overlayId) => {
// 从列表中移除
const index = addedDrawingOverlayIds.value.indexOf(overlayId)
if (index > -1) {
addedDrawingOverlayIds.value.splice(index, 1)
}
})
}
// 使用 subscribeAction 监听可见范围变化,手动触发加载更多
// 替代 setLoadMoreDataCallback,因为它在某些版本可能不触发
if (chartRef.value && typeof chartRef.value.subscribeAction === 'function') {
// 保存上一次的可见范围,用于检测是否滚动到最左侧
let lastVisibleFrom = null
// 标记是否已经处理过初始化时的可见范围变化
let initialRangeProcessed = false
chartRef.value.subscribeAction('onVisibleRangeChange', async (data) => {
if (data && typeof data.from === 'number') {
// 如果是初始化时的第一次可见范围变化,只记录,不触发加载
if (!initialRangeProcessed) {
lastVisibleFrom = data.from
initialRangeProcessed = true
// 延迟标记图表初始化完成,确保初始化完成后再允许触发加载
setTimeout(() => {
chartInitialized.value = true
}, 1000)
return
}
// 如果图表还未初始化完成,不触发加载
if (!chartInitialized.value) {
lastVisibleFrom = data.from
return
}
// 如果正在加载历史数据,且用户尝试继续向左滚动,阻止滚动
if (loadingHistory.value && data.from <= 0) {
// 尝试将可见范围保持在第一个数据点之后,防止继续向左
try {
if (chartRef.value && typeof chartRef.value.setVisibleRange === 'function') {
const dataLength = klineData.value.length
if (dataLength > 0) {
// 获取当前可见范围
const currentRange = chartRef.value.getVisibleRange()
if (currentRange) {
// 计算可见的数据条数
const visibleCount = Math.ceil((currentRange.to - currentRange.from) * dataLength / 100)
// 设置新的可见范围,从第一个数据点开始(索引0对应0%,但我们要稍微向右一点)
// 使用百分比:第一个数据点大约是 0%,我们设置为 0.1% 来防止继续向左
const minFrom = 0.1
const newTo = Math.min(100, minFrom + (visibleCount / dataLength * 100))
chartRef.value.setVisibleRange(minFrom, newTo)
}
}
}
} catch (e) {
}
return
}
// 当滚动到最左侧(索引接近0或小于等于5)时触发加载
// 只有在用户主动向左滚动时才触发(lastVisibleFrom > data.from 表示向左滚动)
// 【关键】同时检查 loadingHistory.value 和 loadingHistoryPromise,确保没有正在进行的请求
if (data.from <= 5 && !loadingHistory.value && !loadingHistoryPromise && hasMoreHistory.value && chartInitialized.value) {
// 检查是否是用户主动向左滚动(避免初始化时触发)
if (lastVisibleFrom !== null && lastVisibleFrom > data.from) {
if (klineData.value.length > 0) {
const earliestTimestamp = klineData.value[0].timestamp
await loadMoreHistoryDataForScroll(earliestTimestamp)
}
}
}
// 更新上一次的可见范围
lastVisibleFrom = data.from
}
})
}
// 如果有数据,应用数据
if (klineData.value && klineData.value.length > 0) {
// 确保数据格式正确
const validData = klineData.value.filter(item =>
item.timestamp &&
!isNaN(item.open) &&
!isNaN(item.high) &&
!isNaN(item.low) &&
!isNaN(item.close)
)
if (validData.length > 0) {
// 使用 applyNewData 初始化
try {
chartRef.value.applyNewData(validData)
} catch (e) {
// 尝试降级处理
try {
chartRef.value.applyNewData(validData)
} catch (e2) {
}
}
// 创建成交量指标(默认显示)
try {
chartRef.value.createIndicator('VOL', false, { height: 100, dragEnabled: true })
} catch (e) {
}
// 延迟更新指标,确保K线先渲染
nextTick(() => {
updateIndicators()
})
}
}
window.addEventListener('resize', handleResize)
} catch (error) {
error.value = proxy.$t('dashboard.indicator.error.chartInitFailed') + ': ' + (error.message || '未知错误')
}
}
const handleResize = () => {
if (chartRef.value) {
setTimeout(() => {
if (chartRef.value) {
chartRef.value.resize()
}
}, 100)
} else {
const container = document.getElementById('kline-chart-container')
if (container && container.clientWidth > 0 && container.clientHeight > 0) {
initChart()
}
}
}
// 更新图表主题
const updateChartTheme = () => {
if (!chartRef.value) return
const theme = themeConfig.value
const isDark = chartTheme.value === 'dark'
chartRef.value.setStyles({
grid: {
show: true,
horizontal: {
show: true,
color: theme.gridLineColor,
style: 'dashed',
size: 1
},
vertical: {
show: false
}
},
candle: {
priceMark: {
show: true,
high: {
show: true,
color: theme.axisLabelColor
},
low: {
show: true,
color: theme.axisLabelColor
}
},
tooltip: {
showRule: 'always',
showType: 'standard',
labels: [
proxy.$t('dashboard.indicator.tooltip.time'),
proxy.$t('dashboard.indicator.tooltip.open'),
proxy.$t('dashboard.indicator.tooltip.high'),
proxy.$t('dashboard.indicator.tooltip.low'),
proxy.$t('dashboard.indicator.tooltip.close'),
proxy.$t('dashboard.indicator.tooltip.volume')
],
values: (kLineData) => {
const d = new Date(kLineData.timestamp)
return [
`${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()} ${d.getHours()}:${d.getMinutes()}`,
kLineData.open.toFixed(2),
kLineData.high.toFixed(2),
kLineData.low.toFixed(2),
kLineData.close.toFixed(2),
kLineData.volume.toFixed(0)
]
}
},
bar: {
upColor: isDark ? '#0ecb81' : '#13c2c2',
downColor: isDark ? '#f6465d' : '#fa541c',
noChangeColor: theme.borderColor
}
},
indicator: {
tooltip: {
showRule: 'always',
showType: 'standard'
}
},
xAxis: {
show: true,
axisLine: {
show: true,
color: theme.borderColor
}
},
yAxis: {
show: true,
axisLine: {
show: false
}
},
crosshair: {
show: true,
horizontal: {
show: true,
line: {
show: true,
style: 'dashed',
color: theme.gridLineColor,
size: 1
}
},
vertical: {
show: true,
line: {
show: true,
style: 'dashed',
color: theme.gridLineColor,
size: 1
}
}
},
watermark: {
show: false
}
})
}
// --- 注册自定义指标辅助函数 ---
const registerCustomIndicator = (name, calcFunc, figures, calcParams = [], precision = 2, shouldOverlay = false) => {
try {
// KLineChart v9 使用 series: 'price' 来标识主图指标
const indicatorConfig = {
name,
shortName: name, // 添加 shortName
calc: calcFunc,
figures,
calcParams,
precision,
series: shouldOverlay ? 'price' : 'normal'
}
registerIndicator(indicatorConfig)
// console.log(`成功注册指标: ${name}, series: ${indicatorConfig.series}`)
return true
} catch (err) {
// 如果已注册,忽略错误
if (err.message && err.message.includes('already registered')) {
return true
}
return false
}
}
// --- 更新指标(KLineChart 版本) ---
const updateIndicators = async () => {
if (indicatorsUpdating.value) {
return
}
// 使用 JSON 序列化/反序列化去除 Vue 2 Observer 的干扰
if (!chartRef.value || klineData.value.length === 0) {
return
}
indicatorsUpdating.value = true
try {
// 1. 移除所有已添加的信号 overlays
try {
if (addedSignalOverlayIds.value.length > 0 && chartRef.value) {
addedSignalOverlayIds.value.forEach(overlayId => {
try {
if (typeof chartRef.value.removeOverlay === 'function') {
chartRef.value.removeOverlay(overlayId)
} else if (typeof chartRef.value.removeOverlayById === 'function') {
chartRef.value.removeOverlayById(overlayId)
}
} catch (err) {
}
})
// 清空列表
addedSignalOverlayIds.value = []
}
} catch (e) {
}
// 2. 移除所有已添加的指标
try {
if (addedIndicatorIds.value.length > 0) {
addedIndicatorIds.value.forEach(info => {
// info 可以是 { paneId, name } 对象或仅 name 字符串
const name = typeof info === 'string' ? info : info.name
const paneId = typeof info === 'string' ? undefined : info.paneId
// 尝试移除指标
// KLineChart v9: removeIndicator(paneId, name)
if (paneId) {
chartRef.value.removeIndicator(paneId, name)
} else {
// 如果没有 paneId,尝试从主图移除
chartRef.value.removeIndicator('candle_pane', name)
// 也可以尝试不传 paneId
chartRef.value.removeIndicator(name)
}
})
// 清空列表
addedIndicatorIds.value = []
}
} catch (e) {
}
// 转换数据格式(KLineChart 需要内部格式用于计算)
const internalData = convertToInternalFormat(klineData.value)
// 遍历所有激活的指标
for (let idx = 0; idx < props.activeIndicators.length; idx++) {
const indicator = props.activeIndicators[idx]
try {
// 处理 Python 指标
if (indicator.type === 'python') {
if (!indicator.code) continue
try {
// 如果有 calculate 函数,使用它(用于 Python 指标)
if (indicator.calculate && typeof indicator.calculate === 'function') {
const result = await indicator.calculate(internalData, indicator.params || {})
// 处理结果中的 plots - 将所有 plots 合并到一个指标中
// 注意:signals 不添加到指标中,而是单独处理,避免显示 "n/a"
let allPlots = []
if (result && result.plots && Array.isArray(result.plots)) {
allPlots = [...result.plots]
}
// 处理 signals - 使用 KLineChart 的 createOverlay 显示(不添加到指标中)
if (result && result.signals && Array.isArray(result.signals)) {
for (const signal of result.signals) {
if (signal.data && Array.isArray(signal.data) && signal.data.length > 0) {
// 统计非空值的数量
const sampleValues = []
for (let i = 0; i < Math.min(signal.data.length, 20); i++) {
const val = signal.data[i]
if (val !== null && val !== undefined && !isNaN(val)) {
if (sampleValues.length < 5) {
sampleValues.push({ index: i, value: val })
}
}
}
// 找到所有非空的信号点
const signalPoints = []
for (let i = 0; i < signal.data.length && i < internalData.length; i++) {
const signalValue = signal.data[i]
if (signalValue !== null && signalValue !== undefined && !isNaN(signalValue)) {
const klineItem = internalData[i]
const timestamp = klineItem.timestamp || klineItem.time
// 【核心修改】获取当前 K 线的 High 和 Low
// 注意:internalData 已经是你转换过的格式,直接取即可
const highPrice = klineItem.high
const lowPrice = klineItem.low
// Signal type: chart only displays indicator signals (buy/sell).
const signalTypeRaw = (signal.type || 'buy')
const signalType = String(signalTypeRaw).toLowerCase()
// Chart only displays indicator signals (no position mgmt / TP/SL / trailing etc).
const allowedSignalTypes = ['buy', 'sell']
if (!allowedSignalTypes.includes(signalType)) {
continue
}
// Buy-side labels are shown below candles; sell-side labels above candles.
const isBuySignal = signalType === 'buy'
// Text: prefer per-point textData, otherwise use signal.text, otherwise fallback to B/S.
let pointText = signal.text || (isBuySignal ? 'B' : 'S')
if (signal.textData && signal.textData[i] != null) {
pointText = signal.textData[i]
}
signalPoints.push({
timestamp,
price: signalValue,
// 确定锚点价格:买入看 Low,卖出看 High
anchorPrice: isBuySignal ? lowPrice : highPrice,
// side is used for layout/styling; action preserves the original type (buy/sell).
side: isBuySignal ? 'buy' : 'sell',
action: signalType,
color: signal.color || (isBuySignal ? '#00E676' : '#FF5252'),
text: pointText
})
}
}
// 使用 KLineChart 的 createOverlay 添加标记
if (signalPoints.length > 0 && chartRef.value) {
for (const point of signalPoints) {
try {
// 确保时间戳是毫秒级
let timestamp = point.timestamp
if (timestamp < 1e10) {
timestamp = timestamp * 1000
}
// 只显示 buy 或 sell,不显示金额
const displaySimpleText = point.text
// === 使用自定义 signalTag ===
if (typeof chartRef.value.createOverlay === 'function') {
const overlayId = chartRef.value.createOverlay({
name: 'signalTag',
// 【核心修改】传入两个点:
// Point 0: 信号触发价格 (用于画圆点)
// Point 1: K线极值价格 (用于定位标签)
points: [
{ timestamp: timestamp, value: point.price },
{ timestamp: timestamp, value: point.anchorPrice }
],
extendData: {
text: displaySimpleText,
color: point.color,
side: point.side,
action: point.action,
price: point.price
},
lock: true // 锁定防止拖动
}, 'candle_pane') // 绘制在主图
if (overlayId) {
addedSignalOverlayIds.value.push(overlayId)
}
}
// === 修改结束 ===
} catch (overlayErr) {
}
}
} else {
}
}
}
}
// 只处理 plots(不包括 signals
if (allPlots.length > 0) {
// 过滤出有效的 plots
const validPlots = allPlots.filter(plot => plot.data && Array.isArray(plot.data) && plot.data.length > 0)
if (validPlots.length > 0) {
// 构建 figures 数组,包含所有 plots
const figures = []
const plotDataMap = {}
for (let plotIdx = 0; plotIdx < validPlots.length; plotIdx++) {
const plot = validPlots[plotIdx]
const plotName = plot.name || `PLOT_${plotIdx}_${idx}`
const figureKey = plotName.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '_')
const plotColor = plot.color || getIndicatorColor(plotIdx)
// 对于普通 plot,使用原类型或 'line'
const figureType = plot.type || 'line'
figures.push({
key: figureKey,
title: plot.name || plotName,
type: figureType,
color: plotColor
})
plotDataMap[figureKey] = plot.data
}
// 确定是否叠加在主图上(如果所有 plots 都是 overlay,则叠加)
const allOverlay = validPlots.every(plot => plot.overlay !== false)
// const customIndicatorName = `${indicator.id}_combined`
let customIndicatorName = `${indicator.id}_combined`
if (result && result.name) {
customIndicatorName = result.name
}
try {
// 注册合并的自定义指标
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList) => {
const result = []
for (let i = 0; i < kLineDataList.length; i++) {
const dataPoint = {}
for (const figureKey in plotDataMap) {
const plotData = plotDataMap[figureKey]
dataPoint[figureKey] = i < plotData.length ? plotData[i] : null
}
result.push(dataPoint)
}
return result
},
figures,
[],
2,
allOverlay
)
if (registered) {
if (allOverlay) {
// 主图指标
const paneId = chartRef.value.createIndicator(
customIndicatorName,
false,
{ id: 'candle_pane' }
)
if (paneId) {
addedIndicatorIds.value.push({ paneId, name: customIndicatorName })
} else {
addedIndicatorIds.value.push({ paneId: 'candle_pane', name: customIndicatorName })
}
} else {
// 副图指标
const indicatorId = chartRef.value.createIndicator(
customIndicatorName,
false,
{ height: 100, dragEnabled: true }
)
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: customIndicatorName })
}
}
}
} catch (plotErr) {
}
}
}
} else {
// 如果没有 calculate 函数,直接使用 executePythonStrategy
// 构建解密所需的信息
const decryptInfo = {
id: indicator.originalId || indicator.id, // 优先使用原始数据库ID
user_id: indicator.user_id || indicator.userId,
is_encrypted: indicator.is_encrypted || indicator.isEncrypted || 0
}
const pythonResult = await executePythonStrategy(
indicator.code,
internalData,
indicator.params || {},
decryptInfo // 传递解密信息
)
// 处理 plots - 将所有 plots 合并到一个指标中
// 注意:signals 不添加到指标中,而是单独处理,避免显示 "n/a"
let allPlots = []
if (pythonResult && pythonResult.plots && Array.isArray(pythonResult.plots)) {
allPlots = [...pythonResult.plots]
}
// 处理 signals - 使用 KLineChart 的 createOverlay 显示(不添加到指标中)
if (pythonResult && pythonResult.signals && Array.isArray(pythonResult.signals)) {
for (const signal of pythonResult.signals) {
if (signal.data && Array.isArray(signal.data) && signal.data.length > 0) {
// 统计非空值的数量
const sampleValues = []
for (let i = 0; i < Math.min(signal.data.length, 20); i++) {
const val = signal.data[i]
if (val !== null && val !== undefined && !isNaN(val)) {
if (sampleValues.length < 5) {
sampleValues.push({ index: i, value: val })
}
}
}
// 找到所有非空的信号点
const signalPoints = []
for (let i = 0; i < signal.data.length && i < internalData.length; i++) {
const signalValue = signal.data[i]
if (signalValue !== null && signalValue !== undefined && !isNaN(signalValue)) {
const klineItem = internalData[i]
const timestamp = klineItem.timestamp || klineItem.time
// 【核心修改】获取当前 K 线的 High 和 Low
// 注意:internalData 已经是你转换过的格式,直接取即可
const highPrice = klineItem.high
const lowPrice = klineItem.low
// Signal type: chart only displays indicator signals (buy/sell).
const signalTypeRaw = (signal.type || 'buy')
const signalType = String(signalTypeRaw).toLowerCase()
// Chart only displays indicator signals (no position mgmt / TP/SL / trailing etc).
const allowedSignalTypes = ['buy', 'sell']
if (!allowedSignalTypes.includes(signalType)) {
continue
}
const isBuySignal = signalType === 'buy'
// Text: prefer per-point textData, otherwise use signal.text, otherwise fallback to B/S.
let pointText = signal.text || (isBuySignal ? 'B' : 'S')
if (signal.textData && signal.textData[i] != null) {
pointText = signal.textData[i]
}
signalPoints.push({
timestamp,
price: signalValue,
// 确定锚点价格:买入看 Low,卖出看 High
anchorPrice: isBuySignal ? lowPrice : highPrice,
side: isBuySignal ? 'buy' : 'sell',
action: signalType,
color: signal.color || (isBuySignal ? '#00E676' : '#FF5252'),
text: pointText
})
}
}
// 使用 KLineChart 的 createOverlay 添加标记
if (signalPoints.length > 0 && chartRef.value) {
for (const point of signalPoints) {
try {
// 确保时间戳是毫秒级
let timestamp = point.timestamp
if (timestamp < 1e10) {
timestamp = timestamp * 1000
}
// 只显示 buy 或 sell,不显示金额
const displaySimpleText = point.text
// === 使用自定义 signalTag ===
if (typeof chartRef.value.createOverlay === 'function') {
const overlayId = chartRef.value.createOverlay({
name: 'signalTag',
// 【核心修改】传入两个点:
// Point 0: 信号触发价格 (用于画圆点)
// Point 1: K线极值价格 (用于定位标签)
points: [
{ timestamp: timestamp, value: point.price },
{ timestamp: timestamp, value: point.anchorPrice }
],
extendData: {
text: displaySimpleText,
color: point.color,
side: point.side,
action: point.action,
price: point.price
},
lock: true // 锁定防止拖动
}, 'candle_pane') // 绘制在主图
if (overlayId) {
addedSignalOverlayIds.value.push(overlayId)
}
}
// === 修改结束 ===
} catch (overlayErr) {
}
}
} else {
}
}
}
}
// 只处理 plots(不包括 signals
if (allPlots.length > 0) {
// 过滤出有效的 plots
const validPlots = allPlots.filter(plot => plot.data && Array.isArray(plot.data) && plot.data.length > 0)
if (validPlots.length > 0) {
// 构建 figures 数组,包含所有 plots
const figures = []
const plotDataMap = {}
for (let plotIdx = 0; plotIdx < validPlots.length; plotIdx++) {
const plot = validPlots[plotIdx]
const plotName = plot.name || `PLOT_${plotIdx}`
const figureKey = plotName.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '_')
const plotColor = plot.color || getIndicatorColor(plotIdx)
// 对于普通 plot,使用原类型或 'line'
const figureType = plot.type || 'line'
figures.push({
key: figureKey,
title: plot.name || plotName,
type: figureType,
color: plotColor
})
plotDataMap[figureKey] = plot.data
}
// 确定是否叠加在主图上(如果所有 plots 都是 overlay,则叠加)
const allOverlay = validPlots.every(plot => plot.overlay !== false)
// const customIndicatorName = `${indicator.id}_combined`
let customIndicatorName = `${indicator.id}_combined`
if (pythonResult && pythonResult.name) {
customIndicatorName = pythonResult.name
}
try {
// 注册合并的自定义指标
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList) => {
const result = []
for (let i = 0; i < kLineDataList.length; i++) {
const dataPoint = {}
for (const figureKey in plotDataMap) {
const plotData = plotDataMap[figureKey]
dataPoint[figureKey] = i < plotData.length ? plotData[i] : null
}
result.push(dataPoint)
}
return result
},
figures,
[],
2,
allOverlay
)
if (registered) {
if (allOverlay) {
// 主图指标
const paneId = chartRef.value.createIndicator(
customIndicatorName,
false,
{ id: 'candle_pane' }
)
if (paneId) {
addedIndicatorIds.value.push({ paneId, name: customIndicatorName })
} else {
addedIndicatorIds.value.push({ paneId: 'candle_pane', name: customIndicatorName })
}
} else {
// 副图指标
const indicatorId = chartRef.value.createIndicator(
customIndicatorName,
false,
{ height: 100, dragEnabled: true }
)
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: customIndicatorName })
}
}
}
} catch (plotErr) {
}
}
}
}
} catch (err) {
// 如果是 Python 引擎未就绪的错误,设置加载失败状态
if (err.message && err.message.includes('Python 引擎未就绪')) {
if (!loadingPython.value) {
pyodideLoadFailed.value = true
}
}
}
continue
}
// 注意:calculate 函数可能为 null,因为指标的计算逻辑在 updateIndicators 中通过 id 判断
// 所以这里不检查 calculate,而是直接根据 indicator.id 处理
const color = getIndicatorColor(idx)
// 根据指标类型创建 KLineChart 指标
if (indicator.id === 'sma' || indicator.id === 'ema') {
const maType = indicator.id === 'sma' ? 'SMA' : 'EMA'
const period = indicator.params?.length || indicator.params?.period || 20
const customIndicatorName = `${maType}_${period}`
const figureKey = maType.toLowerCase()
const calcPeriod = period
try {
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList, indicator) => {
const p = indicator.calcParams[0] || calcPeriod
// calculateSMA/EMA 需要传入包含 close 属性的对象数组,而不是数字数组
const values = maType === 'SMA'
? calculateSMA(kLineDataList, p)
: calculateEMA(kLineDataList, p)
return values.map(v => ({ [figureKey]: v }))
},
[{ key: figureKey, title: `${maType}(${period})`, type: 'line' }],
[period],
2,
true // shouldOverlay: true 表示主图指标
)
if (registered) {
// 创建指标
const paneId = chartRef.value.createIndicator(
customIndicatorName,
false, // isStack
{ id: 'candle_pane' }
)
if (paneId) {
addedIndicatorIds.value.push({ paneId, name: customIndicatorName })
} else {
// 如果返回 null (主图指标可能返回 null),也记录下来
addedIndicatorIds.value.push({ paneId: 'candle_pane', name: customIndicatorName })
}
}
} catch (err) {
}
} else if (indicator.id === 'macd') {
// MACD 默认画在副图
const indicatorId = chartRef.value.createIndicator('MACD', false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: 'MACD' })
}
} else if (indicator.id === 'rsi') {
const indicatorId = chartRef.value.createIndicator('RSI', false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: 'RSI' })
}
} else if (indicator.id === 'bollinger_bands' || indicator.id === 'bb') {
// 布林带需要注册自定义指标
const length = indicator.params?.length || 20
const mult = indicator.params?.mult || 2
const customIndicatorName = `BOLL_${length}_${mult}`
try {
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList, indicator) => {
const length = indicator.calcParams[0] || 20
const mult = indicator.calcParams[1] || 2
// calculateBollingerBands 需要传入包含 close 属性的对象数组
const bbResult = calculateBollingerBands(kLineDataList, length, mult)
// KLineChart 需要返回对象数组,每个对象的键对应 figures 的 key
const result = []
for (let i = 0; i < bbResult.length; i++) {
result.push({
upper: bbResult[i]?.upper ?? null,
middle: bbResult[i]?.middle ?? null,
lower: bbResult[i]?.lower ?? null
})
}
return result
},
[
{ key: 'upper', title: `上轨(${length},${mult})`, type: 'line' },
{ key: 'middle', title: `中轨(${length})`, type: 'line' },
{ key: 'lower', title: `下轨(${length},${mult})`, type: 'line' }
],
[length, mult], // calcParams
2, // precision
true // shouldOverlay: true 表示主图指标
)
if (registered) {
// 创建指标(主图指标)
const paneId = chartRef.value.createIndicator(
customIndicatorName,
false, // isStack: false 表示不堆叠
{ id: 'candle_pane' }
)
if (paneId) {
addedIndicatorIds.value.push({ paneId, name: customIndicatorName })
} else {
addedIndicatorIds.value.push({ paneId: 'candle_pane', name: customIndicatorName })
}
}
} catch (err) {
}
} else if (indicator.id === 'atr') {
// ATR 需要注册自定义指标
const period = indicator.params?.period || indicator.params?.length || 14
const customIndicatorName = `ATR_${period}`
try {
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList, indicator) => {
const period = indicator.calcParams[0] || 14
const data = kLineDataList.map(d => ({
high: d.high,
low: d.low,
close: d.close
}))
const atrValues = calculateATR(data, period)
// 转换为 KLineChart 需要的格式:返回对象数组
return atrValues.map(value => ({ atr: value }))
},
[{
key: 'atr',
title: `ATR(${period})`,
type: 'line',
color: color
}],
[period]
)
if (registered) {
const indicatorId = chartRef.value.createIndicator(customIndicatorName, false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: customIndicatorName })
}
}
} catch (err) {
}
} else if (indicator.id === 'williams' || indicator.id === 'williams_r') {
// Williams %R 需要注册自定义指标
const length = indicator.params?.length || 14
const customIndicatorName = `WPR_${length}`
try {
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList, indicator) => {
const length = indicator.calcParams[0] || 14
const data = kLineDataList.map(d => ({
high: d.high,
low: d.low,
close: d.close
}))
const wrValues = calculateWilliamsR(data, length)
// 转换为 KLineChart 需要的格式:返回对象数组
return wrValues.map(value => ({ wr: value }))
},
[{
key: 'wr',
title: `W%R(${length})`,
type: 'line',
color: color
}],
[length]
)
if (registered) {
const indicatorId = chartRef.value.createIndicator(customIndicatorName, false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: customIndicatorName })
}
}
} catch (err) {
}
} else if (indicator.id === 'mfi') {
// MFI 需要注册自定义指标
const length = indicator.params?.length || 14
const customIndicatorName = `MFI_${length}`
try {
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList, indicator) => {
const length = indicator.calcParams[0] || 14
const data = kLineDataList.map(d => ({
high: d.high,
low: d.low,
close: d.close,
volume: d.volume
}))
const mfiValues = calculateMFI(data, length)
// 转换为 KLineChart 需要的格式:返回对象数组
return mfiValues.map(value => ({ mfi: value }))
},
[{
key: 'mfi',
title: `MFI(${length})`,
type: 'line',
color: color
}],
[length]
)
if (registered) {
const indicatorId = chartRef.value.createIndicator(customIndicatorName, false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: customIndicatorName })
}
}
} catch (err) {
}
} else if (indicator.id === 'cci') {
// CCI 需要注册自定义指标
const length = indicator.params?.length || 20
const customIndicatorName = `CCI_${length}`
try {
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList, indicator) => {
const length = indicator.calcParams[0] || 20
const data = kLineDataList.map(d => ({
high: d.high,
low: d.low,
close: d.close
}))
const cciValues = calculateCCI(data, length)
// 转换为 KLineChart 需要的格式:返回对象数组
return cciValues.map(value => ({ cci: value }))
},
[{
key: 'cci',
title: `CCI(${length})`,
type: 'line',
color: color
}],
[length]
)
if (registered) {
const indicatorId = chartRef.value.createIndicator(customIndicatorName, false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: customIndicatorName })
}
}
} catch (err) {
}
} else if (indicator.id === 'adx') {
// ADX 需要注册自定义指标
const length = indicator.params?.length || 14
const customIndicatorName = `ADX_${length}`
try {
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList, indicator) => {
const length = indicator.calcParams[0] || 14
const data = kLineDataList.map(d => ({
high: d.high,
low: d.low,
close: d.close
}))
const result = calculateADX(data, length)
// 转换为 KLineChart 需要的格式:返回对象数组
return result.adx.map(value => ({ adx: value }))
},
[{
key: 'adx',
title: `ADX(${length})`,
type: 'line',
color: color
}],
[length]
)
if (registered) {
const indicatorId = chartRef.value.createIndicator(customIndicatorName, false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: customIndicatorName })
}
}
} catch (err) {
}
} else if (indicator.id === 'obv') {
// OBV 需要注册自定义指标
const customIndicatorName = 'OBV'
try {
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList, indicator) => {
const data = kLineDataList.map(d => ({
close: d.close,
volume: d.volume || 0
}))
const obvValues = calculateOBV(data)
return obvValues.map(value => ({ obv: value }))
},
[{
key: 'obv',
title: 'OBV',
type: 'line',
color: color
}],
[]
)
if (registered) {
const indicatorId = chartRef.value.createIndicator(customIndicatorName, false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: customIndicatorName })
}
}
} catch (err) {
}
} else if (indicator.id === 'adosc') {
// ADOSC 需要注册自定义指标
const fast = indicator.params?.fast || 3
const slow = indicator.params?.slow || 10
const customIndicatorName = `ADOSC_${fast}_${slow}`
try {
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList, indicator) => {
const fast = indicator.calcParams[0] || 3
const slow = indicator.calcParams[1] || 10
const data = kLineDataList.map(d => ({
high: d.high,
low: d.low,
close: d.close,
volume: d.volume || 0
}))
const adoscValues = calculateADOSC(data, fast, slow)
return adoscValues.map(value => ({ adosc: value }))
},
[{
key: 'adosc',
title: `ADOSC(${fast},${slow})`,
type: 'line',
color: color
}],
[fast, slow]
)
if (registered) {
const indicatorId = chartRef.value.createIndicator(customIndicatorName, false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: customIndicatorName })
}
}
} catch (err) {
}
} else if (indicator.id === 'ad') {
// AD 需要注册自定义指标
const customIndicatorName = 'AD'
try {
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList, indicator) => {
const data = kLineDataList.map(d => ({
high: d.high,
low: d.low,
close: d.close,
volume: d.volume || 0
}))
const adValues = calculateAD(data)
return adValues.map(value => ({ ad: value }))
},
[{
key: 'ad',
title: 'AD',
type: 'line',
color: color
}],
[]
)
if (registered) {
const indicatorId = chartRef.value.createIndicator(customIndicatorName, false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: customIndicatorName })
}
}
} catch (err) {
}
} else if (indicator.id === 'kdj') {
// KDJ 需要注册自定义指标
const period = indicator.params?.period || 9
const kPeriod = indicator.params?.k || 3
const dPeriod = indicator.params?.d || 3
const customIndicatorName = `KDJ_${period}_${kPeriod}_${dPeriod}`
try {
const registered = registerCustomIndicator(
customIndicatorName,
(kLineDataList, indicator) => {
const period = indicator.calcParams[0] || 9
const kPeriod = indicator.calcParams[1] || 3
const dPeriod = indicator.calcParams[2] || 3
const data = kLineDataList.map(d => ({
high: d.high,
low: d.low,
close: d.close
}))
const result = calculateKDJ(data, period, kPeriod, dPeriod)
return result.k.map((k, i) => ({
k: k,
d: result.d[i],
j: result.j[i]
}))
},
[
{ key: 'k', title: `K(${period},${kPeriod})`, type: 'line', color: '#FF6B6B' },
{ key: 'd', title: `D(${dPeriod})`, type: 'line', color: '#4ECDC4' },
{ key: 'j', title: `J`, type: 'line', color: '#95E1D3' }
],
[period, kPeriod, dPeriod]
)
if (registered) {
const indicatorId = chartRef.value.createIndicator(customIndicatorName, false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: customIndicatorName })
}
}
} catch (err) {
}
} else {
// 尝试直接用 indicator.id 创建(假设是内置指标名)
try {
const indicatorName = indicator.id.toUpperCase()
const indicatorId = chartRef.value.createIndicator(indicatorName, false, { height: 100, dragEnabled: true })
if (indicatorId) {
addedIndicatorIds.value.push({ paneId: indicatorId, name: indicatorName })
}
} catch (err) {
}
}
// ... 其他指标 ...
} catch (e) {
}
}
} finally {
indicatorsUpdating.value = false
}
}
const handleRetry = () => {
loadKlineData()
}
// 生命周期
watch(() => props.symbol, () => {
if (props.symbol) {
loadKlineData()
}
})
watch(() => props.theme, (newTheme) => {
chartTheme.value = newTheme
if (chartRef.value) {
updateChartTheme()
updateIndicators()
}
})
watch(() => props.symbol, () => {
if (props.symbol) {
loadKlineData()
}
})
watch(() => props.market, () => {
if (props.symbol) {
loadKlineData()
}
})
watch(() => props.timeframe, () => {
if (props.symbol) {
loadKlineData()
}
// 时间周期变化时,重新启动实时更新(如果已启用)
if (props.realtimeEnabled) {
stopRealtime()
startRealtime()
}
})
watch(() => props.activeIndicators, (newVal, oldVal) => {
// 当指标列表变化时,重新渲染图表
if (chartRef.value && klineData.value.length > 0) {
// 使用 nextTick 确保 DOM 更新完成后再更新图表
nextTick(() => {
if (chartRef.value) {
updateIndicators()
}
})
}
}, { deep: true })
watch(() => props.realtimeEnabled, (newVal) => {
if (newVal) {
startRealtime()
} else {
stopRealtime()
}
})
onMounted(async () => {
// 优先使用 props.theme(从 Vuex store 获取),确保与系统主题同步
// 使用 nextTick 确保 props 已经正确传递
await nextTick()
if (props.theme && (props.theme === 'dark' || props.theme === 'light')) {
chartTheme.value = props.theme
}
// 加载 Pyodide
try {
await loadPyodide()
} catch (err) {
pyodideLoadFailed.value = true
}
nextTick(() => {
setTimeout(() => {
if (!chartRef.value && props.symbol) {
initChart()
}
}, 300)
})
})
onBeforeUnmount(() => {
stopRealtime()
if (chartRef.value) {
chartRef.value.destroy()
chartRef.value = null
}
window.removeEventListener('resize', handleResize)
})
return {
klineData,
loading,
error,
loadingHistory,
chartRef,
chartTheme,
themeConfig,
getIndicatorColor,
handleRetry,
loadingPython,
pythonReady,
pyodideLoadFailed,
formatKlineData,
updatePricePanel,
isSameTimeframe,
loadKlineData,
loadMoreHistoryData,
updateKlineRealtime,
startRealtime,
stopRealtime,
initChart,
handleResize,
updateChartTheme,
updateIndicators,
executePythonStrategy,
parsePythonStrategy,
indicatorButtons,
isIndicatorActive,
toggleIndicator,
drawingTools,
activeDrawingTool,
selectDrawingTool,
clearAllDrawings
}
}
}
</script>
<style lang="less" scoped>
/* 左侧图表容器 */
.chart-left {
width: 70% !important;
flex: 0 0 70% !important;
position: relative;
border-right: 1px solid #e8e8e8;
background: #fff;
transition: background-color 0.3s;
touch-action: pan-x pan-y;
-webkit-overflow-scrolling: touch;
&.theme-dark {
background: #131722;
border-right-color: #2a2e39;
}
}
.chart-wrapper {
width: 100%;
height: 100%;
position: relative;
background: #fff;
transition: background-color 0.3s;
touch-action: pan-x pan-y;
-webkit-overflow-scrolling: touch;
display: flex;
.theme-dark & {
background: #131722;
}
}
/* 画线工具工具栏 */
.drawing-toolbar {
flex-shrink: 0;
width: 40px;
background: #fff;
border-right: 1px solid #e8e8e8;
display: flex;
flex-direction: column;
align-items: center;
padding: 8px 4px;
gap: 4px;
z-index: 10;
overflow-y: auto;
overflow-x: hidden;
}
.chart-left.theme-dark .drawing-toolbar {
background: #131722;
border-right-color: #2a2e39;
}
.drawing-tool-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 4px;
transition: all 0.2s;
color: #666;
font-size: 16px;
user-select: none;
}
.chart-left.theme-dark .drawing-tool-btn {
color: #d1d4dc;
}
.drawing-tool-btn:hover {
background: #f0f2f5;
color: #1890ff;
}
.chart-left.theme-dark .drawing-tool-btn:hover {
background: #1f2943;
color: #13c2c2;
}
.drawing-tool-btn.active {
background: #e6f7ff;
color: #1890ff;
border: 1px solid #1890ff;
}
.chart-left.theme-dark .drawing-tool-btn.active {
background: #1f2943;
color: #13c2c2;
border-color: #13c2c2;
}
.drawing-toolbar .ant-divider-vertical {
margin: 8px 0;
height: 20px;
}
/* 指标工具栏 */
.indicator-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #fff;
border-bottom: 1px solid #e8e8e8;
flex-wrap: wrap;
z-index: 1;
position: relative;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE 10+ */
}
.indicator-toolbar::-webkit-scrollbar {
display: none; /* Chrome Safari */
width: 0;
height: 0;
}
/* 图表内容区域 */
.chart-content-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0; /* 防止 flex 子元素溢出 */
overflow: hidden;
}
.chart-left.theme-dark .indicator-toolbar {
background: #131722;
border-bottom-color: #2a2e39;
}
.indicator-btn {
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
color: #666;
background: #f0f2f5;
border: 1px solid #e8e8e8;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
min-width: 40px;
text-align: center;
user-select: none;
}
.chart-left.theme-dark .indicator-btn {
color: #d1d4dc;
background: #1f2943;
border-color: #2a2e39;
}
.indicator-btn:hover {
color: #1890ff;
border-color: #1890ff;
background: #f0f8ff;
}
.chart-left.theme-dark .indicator-btn:hover {
color: #13c2c2;
border-color: #13c2c2;
background: #1f2943;
}
.indicator-btn.active {
color: #1890ff;
background: #fff;
border-color: #1890ff;
border-width: 2px;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
.chart-left.theme-dark .indicator-btn.active {
color: #13c2c2;
background: #1f2943;
border-color: #13c2c2;
box-shadow: 0 0 0 2px rgba(19, 194, 194, 0.2);
}
.kline-chart-container {
flex: 1;
width: 100%;
min-width: 0; /* 防止 flex 子元素溢出 */
background: #fff;
transition: background-color 0.3s;
touch-action: pan-x pan-y;
-webkit-overflow-scrolling: touch;
overflow: hidden;
.theme-dark & {
background: #131722;
}
}
.chart-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.95);
display: flex;
justify-content: center;
align-items: center;
z-index: 1;
backdrop-filter: blur(2px);
}
.chart-left.theme-dark .chart-overlay {
background: rgba(19, 23, 34, 0.95);
}
.error-box {
display: flex;
flex-direction: column;
align-items: center;
color: #333;
}
.initial-hint {
background: rgba(255, 255, 255, 0.98);
}
.chart-left.theme-dark .initial-hint {
background: rgba(19, 23, 34, 0.98);
}
.hint-box {
text-align: center;
color: #666;
display: flex;
flex-direction: column;
align-items: center;
max-width: 400px;
padding: 20px;
}
.pyodide-warning {
background: rgba(255, 255, 255, 0.98);
}
.chart-left.theme-dark .pyodide-warning {
background: rgba(19, 23, 34, 0.98);
}
.warning-box {
text-align: center;
color: #666;
display: flex;
flex-direction: column;
align-items: center;
max-width: 500px;
padding: 20px;
}
.warning-title {
font-size: 16px;
font-weight: 600;
color: #faad14;
margin-bottom: 8px;
}
.warning-desc {
font-size: 14px;
color: #666;
line-height: 1.6;
}
.chart-left.theme-dark .warning-box {
color: #d1d4dc;
}
.chart-left.theme-dark .warning-title {
color: #faad14;
}
.chart-left.theme-dark .warning-desc {
color: #868993;
}
.chart-left.theme-dark .hint-box {
color: #d1d4dc;
}
.hint-title {
font-size: 18px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
}
.chart-left.theme-dark .hint-title {
color: #d1d4dc;
}
.hint-desc {
font-size: 14px;
color: #999;
line-height: 1.6;
}
.chart-left.theme-dark .hint-desc {
color: #787b86;
}
/* 历史数据加载提示 */
.history-loading-hint {
position: absolute;
left: 20px;
top: 60px;
z-index: 1000 !important;
display: flex !important;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: rgba(255, 255, 255, 0.98) !important;
border: 1px solid #e8e8e8;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
font-size: 14px;
color: #666 !important;
backdrop-filter: blur(4px);
pointer-events: none;
visibility: visible !important;
opacity: 1 !important;
}
.chart-left.theme-dark .history-loading-hint {
background: rgba(19, 23, 34, 0.98) !important;
border-color: #2a2e39;
color: #d1d4dc !important;
}
.loading-text {
white-space: nowrap;
margin-left: 4px;
}
/* 移动端适配 */
@media (max-width: 768px) {
.drawing-toolbar {
display: none; /* 移动端隐藏画线工具栏 */
}
.indicator-toolbar {
padding-left: 12px; /* 移动端恢复原始padding */
flex-wrap: nowrap; /* 手机端不换行,只显示一行 */
overflow-x: auto; /* 允许横向滚动 */
overflow-y: hidden; /* 禁止纵向滚动 */
scrollbar-width: none; /* Firefox 隐藏滚动条 */
-ms-overflow-style: none; /* IE 10+ 隐藏滚动条 */
-webkit-overflow-scrolling: touch; /* iOS 平滑滚动 */
}
.indicator-toolbar::-webkit-scrollbar {
display: none; /* Chrome Safari 隐藏滚动条 */
width: 0;
height: 0;
}
.indicator-btn {
flex-shrink: 0; /* 按钮不收缩,保持原始大小 */
}
}
@media (max-width: 1200px) {
.drawing-toolbar {
display: none; /* 移动端隐藏画线工具栏 */
}
.indicator-toolbar {
padding-left: 12px; /* 移动端恢复原始padding */
}
.kline-chart-container {
margin-left: 0; /* 移动端恢复原始margin */
}
.chart-left {
width: 100% !important;
min-width: 100% !important;
border-right: none;
border-bottom: 1px solid #e8e8e8;
height: 600px !important;
min-height: 600px !important;
}
.chart-wrapper {
height: 100% !important;
min-height: 600px !important;
}
.kline-chart-container {
height: 100% !important;
min-height: 600px !important;
}
}
@media (max-width: 992px) {
.chart-left {
height: 650px !important;
min-height: 650px !important;
}
.chart-wrapper {
height: 100% !important;
min-height: 650px !important;
}
.kline-chart-container {
height: 100% !important;
min-height: 650px !important;
}
}
@media (max-width: 768px) {
.chart-left {
height: 60vh !important;
min-height: 400px !important;
max-height: 80vh !important;
}
.chart-wrapper {
height: 100% !important;
min-height: 400px !important;
max-height: 100% !important;
}
.kline-chart-container {
height: calc(100% - 45px) !important; /* 减去工具栏高度 */
min-height: 350px !important;
max-height: calc(100% - 45px) !important;
}
}
@media (max-width: 576px) {
.chart-left {
height: 55vh !important;
min-height: 350px !important;
max-height: 75vh !important;
}
.chart-wrapper {
height: 100% !important;
min-height: 350px !important;
max-height: 100% !important;
}
.kline-chart-container {
height: calc(100% - 45px) !important; /* 减去工具栏高度 */
min-height: 300px !important;
max-height: calc(100% - 45px) !important;
}
}
</style>