feat: 添加自定义 TradingView 数据源实现;更新 TradingViewChart 组件以支持新数据源;优化交易视图中的图表配置

This commit is contained in:
2025-12-30 01:26:59 +07:00
parent 2402337162
commit 4a313f2af0
4 changed files with 315 additions and 4 deletions

View File

@@ -15,6 +15,8 @@ Ionic Vue 8.7.11 (移动端 UI 框架)
TypeScript 5.9.3 TypeScript 5.9.3
Capacitor 8.0.0 (原生功能桥接) Capacitor 8.0.0 (原生功能桥接)
TailwindCSS 4.1.18 (实用优先的 CSS 框架) TailwindCSS 4.1.18 (实用优先的 CSS 框架)
TradingView Charting Library CL v29.4.0 (金融图表)
构建工具: 构建工具:
Vite 7.2.7 (开发服务器和构建工具) Vite 7.2.7 (开发服务器和构建工具)
@@ -138,6 +140,10 @@ API 配置集中在 src/api/index.ts
认证逻辑封装在 src/auth/index.ts 认证逻辑封装在 src/auth/index.ts
支持登录、注册组件复用 支持登录、注册组件复用
认证状态管理通过 useAuth composable 认证状态管理通过 useAuth composable
10.图表集成
使用 TradingView Charting Library 集成金融图表
图表组件封装在 src/tradingview/index.tsx
TradingView 官方文档https://www.tradingview.com/charting-library-docs/latest/getting_started/
开发任务指引 开发任务指引
当收到开发任务时,请: 当收到开发任务时,请:
@@ -162,4 +168,5 @@ Capacitor 配置本地开发服务器地址为 http://localhost:5173
样式使用 TailwindCSS 4.x + Ionic CSS Variables 混合模式 样式使用 TailwindCSS 4.x + Ionic CSS Variables 混合模式
函数风格使用function关键字定义一般不要使用箭头函数 函数风格使用function关键字定义一般不要使用箭头函数
如果有任何不明确的地方,随时提问以获取更多信息 如果有任何不明确的地方,随时提问以获取更多信息
如果代码有错误,请先执行 pnpm run lint:fix 来自动修复代码问题 如果代码有错误,请先执行 pnpm run lint:fix + 修改/新创建的文件路径来自动修复代码问题
如果有工具链文档地址,请参考相关文档进行开发

295
src/tradingview/datafeed.ts Normal file
View File

@@ -0,0 +1,295 @@
import type {
Bar,
DatafeedConfiguration,
HistoryCallback,
IDatafeedChartApi,
IExternalDatafeed,
LibrarySymbolInfo,
OnReadyCallback,
PeriodParams,
ResolutionString,
ResolveCallback,
SearchSymbolsCallback,
SubscribeBarsCallback,
} from "#/datafeed-api";
/**
* 自定义 TradingView Datafeed 实现
* 用于从后端 API 获取 K 线数据
*/
export class CustomDatafeed implements IDatafeedChartApi, IExternalDatafeed {
private apiUrl: string;
private updateInterval: number;
private subscribers: Map<string, { callback: SubscribeBarsCallback; resolution: ResolutionString }> = new Map();
constructor(apiUrl: string, updateInterval = 1000) {
this.apiUrl = apiUrl;
this.updateInterval = updateInterval;
}
/**
* 初始化配置
*/
onReady(callback: OnReadyCallback): void {
console.log("[CustomDatafeed]: onReady called");
const config: DatafeedConfiguration = {
// 支持的交易所
exchanges: [
{ value: "", name: "All Exchanges", desc: "" },
],
// 支持的品种类型
symbols_types: [
{ name: "All types", value: "" },
{ name: "Stock", value: "stock" },
{ name: "Crypto", value: "crypto" },
],
// 支持的时间周期
supported_resolutions: [
"1" as ResolutionString,
"5" as ResolutionString,
"15" as ResolutionString,
"30" as ResolutionString,
"60" as ResolutionString,
"240" as ResolutionString,
"1D" as ResolutionString,
"1W" as ResolutionString,
"1M" as ResolutionString,
],
supports_marks: false,
supports_timescale_marks: false,
supports_time: true,
};
setTimeout(() => callback(config), 0);
}
/**
* 搜索品种
*/
searchSymbols(
userInput: string,
exchange: string,
symbolType: string,
onResult: SearchSymbolsCallback,
): void {
console.log("[CustomDatafeed]: searchSymbols", userInput, exchange, symbolType);
// TODO: 调用后端 API 搜索品种
// const results = await client.api.market.search.get({ query: { keyword: userInput, exchange, type: symbolType } })
// 模拟搜索结果
const results = [
{
symbol: "AAPL",
full_name: "NASDAQ:AAPL",
description: "Apple Inc.",
exchange: "NASDAQ",
ticker: "AAPL",
type: "stock",
},
];
onResult(results);
}
/**
* 解析品种信息
*/
resolveSymbol(
symbolName: string,
onResolve: ResolveCallback,
onError: (reason: string) => void,
): void {
console.log("[CustomDatafeed]: resolveSymbol", symbolName);
// TODO: 调用后端 API 获取品种信息
// const symbolInfo = await client.api.market.symbol[symbolName].get()
// 模拟品种信息
const symbolInfo: LibrarySymbolInfo = {
name: symbolName,
ticker: symbolName,
description: symbolName,
type: "crypto",
session: "24x7",
exchange: "Binance",
listed_exchange: "Binance",
timezone: "Etc/UTC",
format: "price",
pricescale: 100,
minmov: 1,
has_intraday: true,
has_daily: true,
has_weekly_and_monthly: true,
supported_resolutions: [
"1" as ResolutionString,
"5" as ResolutionString,
"15" as ResolutionString,
"30" as ResolutionString,
"60" as ResolutionString,
"240" as ResolutionString,
"1D" as ResolutionString,
"1W" as ResolutionString,
"1M" as ResolutionString,
],
intraday_multipliers: ["1", "5", "15", "30", "60", "240"],
volume_precision: 2,
data_status: "streaming",
};
setTimeout(() => onResolve(symbolInfo), 0);
}
/**
* 获取历史 K 线数据
*/
getBars(
symbolInfo: LibrarySymbolInfo,
resolution: ResolutionString,
periodParams: PeriodParams,
onResult: HistoryCallback,
onError: (reason: string) => void,
): void {
const { from, to, firstDataRequest } = periodParams;
console.log("[CustomDatafeed]: getBars", {
symbol: symbolInfo.name,
resolution,
from: new Date(from * 1000).toISOString(),
to: new Date(to * 1000).toISOString(),
firstDataRequest,
});
// TODO: 调用后端 API 获取 K 线数据
// client.api.market.kline.get({
// query: {
// symbol: symbolInfo.name,
// resolution,
// from,
// to,
// }
// }).then(response => {
// const bars = response.data.map(item => ({
// time: item.time * 1000,
// open: item.open,
// high: item.high,
// low: item.low,
// close: item.close,
// volume: item.volume,
// }))
// onResult(bars, { noData: bars.length === 0 })
// }).catch(error => {
// onError(error.message)
// })
// 模拟 K 线数据
const bars: Bar[] = this.generateMockBars(from, to, resolution);
if (bars.length === 0) {
onResult([], { noData: true });
}
else {
onResult(bars, { noData: false });
}
}
/**
* 订阅实时数据
*/
subscribeBars(
symbolInfo: LibrarySymbolInfo,
resolution: ResolutionString,
onTick: SubscribeBarsCallback,
listenerGuid: string,
onResetCacheNeededCallback: () => void,
): void {
console.log("[CustomDatafeed]: subscribeBars", {
symbol: symbolInfo.name,
resolution,
listenerGuid,
});
this.subscribers.set(listenerGuid, { callback: onTick, resolution });
// TODO: 建立 WebSocket 连接获取实时数据
// const ws = new WebSocket(`${this.apiUrl}/ws/kline?symbol=${symbolInfo.name}&resolution=${resolution}`)
// ws.onmessage = (event) => {
// const data = JSON.parse(event.data)
// const bar: Bar = {
// time: data.time * 1000,
// open: data.open,
// high: data.high,
// low: data.low,
// close: data.close,
// volume: data.volume,
// }
// onTick(bar)
// }
}
/**
* 取消订阅实时数据
*/
unsubscribeBars(listenerGuid: string): void {
console.log("[CustomDatafeed]: unsubscribeBars", listenerGuid);
this.subscribers.delete(listenerGuid);
// TODO: 关闭 WebSocket 连接
// ws.close()
}
/**
* 生成模拟 K 线数据(用于测试)
*/
private generateMockBars(from: number, to: number, resolution: ResolutionString): Bar[] {
const bars: Bar[] = [];
const interval = this.getIntervalInSeconds(resolution);
let currentTime = from * 1000;
let lastClose = 100 + Math.random() * 10;
while (currentTime <= to * 1000) {
const open = lastClose;
const change = (Math.random() - 0.5) * 5;
const close = open + change;
const high = Math.max(open, close) + Math.random() * 2;
const low = Math.min(open, close) - Math.random() * 2;
bars.push({
time: currentTime,
open,
high,
low,
close,
volume: Math.random() * 1000000,
});
lastClose = close;
currentTime += interval * 1000;
}
return bars;
}
/**
* 获取时间周期对应的秒数
*/
private getIntervalInSeconds(resolution: ResolutionString): number {
const match = resolution.match(/^(\d+)([DWMH]?)$/);
if (!match)
return 60;
const value = Number.parseInt(match[1]);
const unit = match[2];
switch (unit) {
case "D":
return value * 86400;
case "W":
return value * 604800;
case "M":
return value * 2592000;
default:
return value * 60;
}
}
}

View File

@@ -1,5 +1,6 @@
import type { ChartingLibraryWidgetOptions } from "#/charting_library"; import type { ChartingLibraryWidgetOptions } from "#/charting_library";
import type { ResolutionString } from "#/datafeed-api"; import type { ResolutionString } from "#/datafeed-api";
import { CustomDatafeed } from "./datafeed";
const { VITE_TRADINGVIEW_LIBRARY_URL, VITE_TRADINGVIEW_DATA_API_URL } = useEnv(); const { VITE_TRADINGVIEW_LIBRARY_URL, VITE_TRADINGVIEW_DATA_API_URL } = useEnv();
@@ -7,7 +8,7 @@ const defaultOptions = {
container: "tradingview_chart_container", container: "tradingview_chart_container",
locale: "zh", locale: "zh",
library_path: `${VITE_TRADINGVIEW_LIBRARY_URL}/charting_library/`, library_path: `${VITE_TRADINGVIEW_LIBRARY_URL}/charting_library/`,
datafeed: new Datafeeds.UDFCompatibleDatafeed(VITE_TRADINGVIEW_DATA_API_URL, 60000), datafeed: new CustomDatafeed(VITE_TRADINGVIEW_DATA_API_URL, 60000),
symbol: "AAPL", symbol: "AAPL",
interval: "1D" as ResolutionString, interval: "1D" as ResolutionString,
debug: true, debug: true,
@@ -34,6 +35,10 @@ const defaultOptions = {
export const TradingViewChart = defineComponent({ export const TradingViewChart = defineComponent({
name: "TradingViewChart", name: "TradingViewChart",
props: { props: {
symbol: {
type: String,
default: "BTCUSDT",
},
options: { options: {
type: Object as PropType<Partial<ChartingLibraryWidgetOptions>>, type: Object as PropType<Partial<ChartingLibraryWidgetOptions>>,
required: false, required: false,
@@ -51,7 +56,7 @@ export const TradingViewChart = defineComponent({
}); });
widget.onChartReady(() => { widget.onChartReady(() => {
widget.setDebugMode(true); console.log("[TradingView]: Chart is ready");
}); });
}); });

View File

@@ -33,7 +33,11 @@ const mode = ref<"buy" | "sell">("buy");
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content :fullscreen="true"> <ion-content :fullscreen="true">
<TradingViewChart class="mb-5" /> <TradingViewChart
class="mb-5" :options="{
symbol: 'BTC/USDT',
}"
/>
<div class="grid grid-cols-5"> <div class="grid grid-cols-5">
<div class="col-span-3 space-y-2"> <div class="col-span-3 space-y-2">
<TradeSwitch v-model:active="mode" /> <TradeSwitch v-model:active="mode" />