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
Capacitor 8.0.0 (原生功能桥接)
TailwindCSS 4.1.18 (实用优先的 CSS 框架)
TradingView Charting Library CL v29.4.0 (金融图表)
构建工具:
Vite 7.2.7 (开发服务器和构建工具)
@@ -138,6 +140,10 @@ API 配置集中在 src/api/index.ts
认证逻辑封装在 src/auth/index.ts
支持登录、注册组件复用
认证状态管理通过 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 混合模式
函数风格使用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 { ResolutionString } from "#/datafeed-api";
import { CustomDatafeed } from "./datafeed";
const { VITE_TRADINGVIEW_LIBRARY_URL, VITE_TRADINGVIEW_DATA_API_URL } = useEnv();
@@ -7,7 +8,7 @@ const defaultOptions = {
container: "tradingview_chart_container",
locale: "zh",
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",
interval: "1D" as ResolutionString,
debug: true,
@@ -34,6 +35,10 @@ const defaultOptions = {
export const TradingViewChart = defineComponent({
name: "TradingViewChart",
props: {
symbol: {
type: String,
default: "BTCUSDT",
},
options: {
type: Object as PropType<Partial<ChartingLibraryWidgetOptions>>,
required: false,
@@ -51,7 +56,7 @@ export const TradingViewChart = defineComponent({
});
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-header>
<ion-content :fullscreen="true">
<TradingViewChart class="mb-5" />
<TradingViewChart
class="mb-5" :options="{
symbol: 'BTC/USDT',
}"
/>
<div class="grid grid-cols-5">
<div class="col-span-3 space-y-2">
<TradeSwitch v-model:active="mode" />