diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 26ce51f..deff8e5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 + 修改/新创建的文件路径来自动修复代码问题 +如果有工具链文档地址,请参考相关文档进行开发 diff --git a/src/tradingview/datafeed.ts b/src/tradingview/datafeed.ts new file mode 100644 index 0000000..1fd026d --- /dev/null +++ b/src/tradingview/datafeed.ts @@ -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 = 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; + } + } +} diff --git a/src/tradingview/index.tsx b/src/tradingview/index.tsx index 237d5d0..04ebe16 100644 --- a/src/tradingview/index.tsx +++ b/src/tradingview/index.tsx @@ -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>, required: false, @@ -51,7 +56,7 @@ export const TradingViewChart = defineComponent({ }); widget.onChartReady(() => { - widget.setDebugMode(true); + console.log("[TradingView]: Chart is ready"); }); }); diff --git a/src/views/trade/index.vue b/src/views/trade/index.vue index 673873a..f9b645d 100644 --- a/src/views/trade/index.vue +++ b/src/views/trade/index.vue @@ -33,7 +33,11 @@ const mode = ref<"buy" | "sell">("buy"); - +