Files
DinQuant/quantdinger_vue/src/views/indicator-analysis/components/KlineChart.vue
T

3883 lines
136 KiB
Vue
Raw Normal View History

2025-12-29 03:06:49 +08:00
<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
2025-12-31 02:22:10 +08:00
// 动态加载 Pyodide(生产环境默认 CDN 优先,避免本地静态资源缺失导致 404 卡住/报错)
// 可通过环境变量自定义:
// - VUE_APP_PYODIDE_CDN_BASE: 覆盖 CDN 基础路径(需以 / 结尾或会自动补齐)
// - VUE_APP_PYODIDE_LOCAL_BASE: 覆盖本地基础路径(需以 / 结尾或会自动补齐)
// - VUE_APP_PYODIDE_PREFER_CDN: 'true'/'false' 强制优先级
2025-12-29 03:06:49 +08:00
const PYODIDE_VERSION = '0.25.0'
2025-12-31 02:22:10 +08:00
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')
2025-12-29 03:06:49 +08:00
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 () => {
2025-12-31 02:22:10 +08:00
const tryLoad = async (base) => {
await loadScript(base + 'pyodide.js')
await initFromBase(base)
}
2025-12-29 03:06:49 +08:00
try {
2025-12-31 02:22:10 +08:00
if (preferCdn) {
// 1) CDN-first (production default)
await tryLoad(cdnBase)
} else {
// 1) Local-first (dev convenience)
await tryLoad(localBase)
}
} catch (firstErr) {
2025-12-29 03:06:49 +08:00
try {
2025-12-31 02:22:10 +08:00
// 2) Fallback
await tryLoad(preferCdn ? localBase : cdnBase)
} catch (secondErr) {
throw secondErr || firstErr
2025-12-29 03:06:49 +08:00
}
}
})().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>