feat: 修改tradingview的websocket请求方式

This commit is contained in:
2026-01-04 21:01:29 +07:00
parent 03c9ddac66
commit fd5f835e5c
9 changed files with 1531 additions and 254 deletions

View File

@@ -1,3 +1,3 @@
VITE_API_URL=http://192.168.1.3:9528 VITE_API_URL=http://192.168.1.8:9528
VITE_TRADINGVIEW_LIBRARY_URL=http://192.168.1.5:6173 VITE_TRADINGVIEW_LIBRARY_URL=http://192.168.1.6:6173
VITE_TRADINGVIEW_DATA_API_URL=https://demo-feed-data.tradingview.com # VITE_TRADINGVIEW_DATA_API_URL=https://demo-feed-data.tradingview.com

View File

@@ -1,3 +1,3 @@
VITE_API_URL=http://192.168.1.3:9527 VITE_API_URL=http://192.168.1.8:9527
VITE_TRADINGVIEW_LIBRARY_URL=http://192.168.1.5:6173 VITE_TRADINGVIEW_LIBRARY_URL=http://192.168.1.6:6173
VITE_TRADINGVIEW_DATA_API_URL=https://demo-feed-data.tradingview.com # VITE_TRADINGVIEW_DATA_API_URL=https://demo-feed-data.tradingview.com

View File

@@ -33,7 +33,7 @@
"@elysiajs/eden": "^1.4.5", "@elysiajs/eden": "^1.4.5",
"@ionic/vue": "^8.7.11", "@ionic/vue": "^8.7.11",
"@ionic/vue-router": "^8.7.11", "@ionic/vue-router": "^8.7.11",
"@riwa/api-types": "http://192.168.1.3:9527/api/riwa-api-types-0.0.67.tgz", "@riwa/api-types": "http://192.168.1.8:9527/api/riwa-api-types-0.0.75.tgz",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@vee-validate/yup": "^4.15.1", "@vee-validate/yup": "^4.15.1",
"@vueuse/core": "^14.1.0", "@vueuse/core": "^14.1.0",
@@ -91,6 +91,7 @@
"unplugin-icons": "^22.5.0", "unplugin-icons": "^22.5.0",
"unplugin-vue-components": "^30.0.0", "unplugin-vue-components": "^30.0.0",
"vite": "^7.2.7", "vite": "^7.2.7",
"vite-plugin-pwa": "^1.2.0",
"vitest": "^4.0.15", "vitest": "^4.0.15",
"vue-tsc": "^3.1.8" "vue-tsc": "^3.1.8"
}, },

1294
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -66,6 +66,8 @@ export type UserWithdrawOrderData = Treaty.Data<typeof client.api.withdraw.get>[
export type UserWithdrawOrderBody = TreatyQuery<typeof client.api.withdraw.get>; export type UserWithdrawOrderBody = TreatyQuery<typeof client.api.withdraw.get>;
export type MarketDataStreaming = ReturnType<typeof client.api.market_data.streaming.subscribe>;
/** /**
* 应用版本信息 * 应用版本信息
*/ */

View File

@@ -12,189 +12,186 @@ import type {
SearchSymbolsCallback, SearchSymbolsCallback,
SubscribeBarsCallback, SubscribeBarsCallback,
} from "#/datafeed-api"; } from "#/datafeed-api";
import type { MarketDataStreaming } from "@/api/types";
const { VITE_TRADINGVIEW_DATA_API_URL } = useEnv(); import { client } from "@/api";
/** /**
* 自定义 TradingView Datafeed 实现 * 自定义 TradingView Datafeed 实现
* 用于从后端 API 获取 K 线数据 * 用于从后端 API 获取 K 线数据
*/ */
export class RWADatafeed extends Datafeeds.UDFCompatibleDatafeed { export class RWADatafeed extends Datafeeds.UDFCompatibleDatafeed {
private apiUrl: string = VITE_TRADINGVIEW_DATA_API_URL;
private updateInterval: number;
private subscribers: Map<string, { callback: SubscribeBarsCallback; resolution: ResolutionString }> = new Map(); private subscribers: Map<string, { callback: SubscribeBarsCallback; resolution: ResolutionString }> = new Map();
private wsConnections: Map<string, MarketDataStreaming> = new Map();
constructor(apiUrl: string, updateInterval = 1000) { constructor(apiUrl: string) {
super(apiUrl); super(apiUrl);
this.apiUrl = apiUrl;
this.updateInterval = updateInterval;
} }
/** // /**
* 初始化配置 // * 初始化配置
*/ // */
onReady(callback: OnReadyCallback): void { // onReady(callback: OnReadyCallback): void {
console.log("[RWADatafeed]: onReady called"); // console.log("[RWADatafeed]: onReady called");
const config: DatafeedConfiguration = { // const config: DatafeedConfiguration = {
// 支持的交易所 // // 支持的交易所
exchanges: [ // exchanges: [
{ value: "", name: "All Exchanges", desc: "" }, // { value: "", name: "All Exchanges", desc: "" },
], // ],
// 支持的品种类型 // // 支持的品种类型
symbols_types: [ // symbols_types: [
{ name: "All types", value: "" }, // { name: "All types", value: "" },
{ name: "Stock", value: "stock" }, // { name: "Stock", value: "stock" },
{ name: "Crypto", value: "crypto" }, // { name: "Crypto", value: "crypto" },
], // ],
// 支持的时间周期 // // 支持的时间周期
supported_resolutions: [ // supported_resolutions: [
"1" as ResolutionString, // "1" as ResolutionString,
"5" as ResolutionString, // "5" as ResolutionString,
"15" as ResolutionString, // "15" as ResolutionString,
"30" as ResolutionString, // "30" as ResolutionString,
"60" as ResolutionString, // "60" as ResolutionString,
"240" as ResolutionString, // "240" as ResolutionString,
"1D" as ResolutionString, // "1D" as ResolutionString,
"1W" as ResolutionString, // "1W" as ResolutionString,
"1M" as ResolutionString, // "1M" as ResolutionString,
], // ],
supports_marks: false, // supports_marks: false,
supports_timescale_marks: false, // supports_timescale_marks: false,
supports_time: true, // supports_time: true,
}; // };
setTimeout(() => callback(config), 0); // setTimeout(() => callback(config), 0);
} // }
/** // /**
* 搜索品种 // * 搜索品种
*/ // */
searchSymbols( // searchSymbols(
userInput: string, // userInput: string,
exchange: string, // exchange: string,
symbolType: string, // symbolType: string,
onResult: SearchSymbolsCallback, // onResult: SearchSymbolsCallback,
): void { // ): void {
console.log("[RWADatafeed]: searchSymbols", userInput, exchange, symbolType); // console.log("[RWADatafeed]: searchSymbols", userInput, exchange, symbolType);
// TODO: 调用后端 API 搜索品种 // // TODO: 调用后端 API 搜索品种
// const results = await client.api.market.search.get({ query: { keyword: userInput, exchange, type: symbolType } }) // // const results = await client.api.market.search.get({ query: { keyword: userInput, exchange, type: symbolType } })
// 模拟搜索结果 // // 模拟搜索结果
const results = [ // const results = [
{ // {
symbol: "AAPL", // symbol: "AAPL",
full_name: "NASDAQ:AAPL", // full_name: "NASDAQ:AAPL",
description: "Apple Inc.", // description: "Apple Inc.",
exchange: "NASDAQ", // exchange: "NASDAQ",
ticker: "AAPL", // ticker: "AAPL",
type: "stock", // type: "stock",
}, // },
]; // ];
onResult(results); // onResult(results);
} // }
/** // /**
* 解析品种信息 // * 解析品种信息
*/ // */
resolveSymbol( // resolveSymbol(
symbolName: string, // symbolName: string,
onResolve: ResolveCallback, // onResolve: ResolveCallback,
onError: (reason: string) => void, // onError: (reason: string) => void,
): void { // ): void {
console.log("[RWADatafeed]: resolveSymbol", symbolName); // console.log("[RWADatafeed]: resolveSymbol", symbolName);
// TODO: 调用后端 API 获取品种信息 // // TODO: 调用后端 API 获取品种信息
// const symbolInfo = await client.api.market.symbol[symbolName].get() // // const symbolInfo = await client.api.market.symbol[symbolName].get()
// 模拟品种信息 // // 模拟品种信息
const symbolInfo: LibrarySymbolInfo = { // const symbolInfo: LibrarySymbolInfo = {
name: symbolName, // name: symbolName,
ticker: symbolName, // ticker: symbolName,
description: symbolName, // description: symbolName,
type: "crypto", // type: "crypto",
session: "24x7", // session: "24x7",
exchange: "Binance", // exchange: "Binance",
listed_exchange: "Binance", // listed_exchange: "Binance",
timezone: "Etc/UTC", // timezone: "Etc/UTC",
format: "price", // format: "price",
pricescale: 100, // pricescale: 100,
minmov: 1, // minmov: 1,
has_intraday: true, // has_intraday: true,
has_daily: true, // has_daily: true,
has_weekly_and_monthly: true, // has_weekly_and_monthly: true,
supported_resolutions: [ // supported_resolutions: [
"1" as ResolutionString, // "1" as ResolutionString,
"5" as ResolutionString, // "5" as ResolutionString,
"15" as ResolutionString, // "15" as ResolutionString,
"30" as ResolutionString, // "30" as ResolutionString,
"60" as ResolutionString, // "60" as ResolutionString,
"240" as ResolutionString, // "240" as ResolutionString,
"1D" as ResolutionString, // "1D" as ResolutionString,
"1W" as ResolutionString, // "1W" as ResolutionString,
"1M" as ResolutionString, // "1M" as ResolutionString,
], // ],
intraday_multipliers: ["1", "5", "15", "30", "60", "240"], // intraday_multipliers: ["1", "5", "15", "30", "60", "240"],
volume_precision: 2, // volume_precision: 2,
data_status: "streaming", // data_status: "streaming",
}; // };
setTimeout(() => onResolve(symbolInfo), 0); // setTimeout(() => onResolve(symbolInfo), 0);
} // }
/** // /**
* 获取历史 K 线数据 // * 获取历史 K 线数据
*/ // */
getBars( // getBars(
symbolInfo: LibrarySymbolInfo, // symbolInfo: LibrarySymbolInfo,
resolution: ResolutionString, // resolution: ResolutionString,
periodParams: PeriodParams, // periodParams: PeriodParams,
onResult: HistoryCallback, // onResult: HistoryCallback,
onError: (reason: string) => void, // onError: (reason: string) => void,
): void { // ): void {
const { from, to, firstDataRequest } = periodParams; // const { from, to, firstDataRequest } = periodParams;
console.log("[RWADatafeed]: getBars", { // console.log("[RWADatafeed]: getBars", {
symbol: symbolInfo.name, // symbol: symbolInfo.name,
resolution, // resolution,
from: new Date(from * 1000).toISOString(), // from: new Date(from * 1000).toISOString(),
to: new Date(to * 1000).toISOString(), // to: new Date(to * 1000).toISOString(),
firstDataRequest, // firstDataRequest,
}); // });
// TODO: 调用后端 API 获取 K 线数据 // // TODO: 调用后端 API 获取 K 线数据
// client.api.market.kline.get({ // // client.api.market.kline.get({
// query: { // // query: {
// symbol: symbolInfo.name, // // symbol: symbolInfo.name,
// resolution, // // resolution,
// from, // // from,
// to, // // to,
// } // // }
// }).then(response => { // // }).then(response => {
// const bars = response.data.map(item => ({ // // const bars = response.data.map(item => ({
// time: item.time * 1000, // // time: item.time * 1000,
// open: item.open, // // open: item.open,
// high: item.high, // // high: item.high,
// low: item.low, // // low: item.low,
// close: item.close, // // close: item.close,
// volume: item.volume, // // volume: item.volume,
// })) // // }))
// onResult(bars, { noData: bars.length === 0 }) // // onResult(bars, { noData: bars.length === 0 })
// }).catch(error => { // // }).catch(error => {
// onError(error.message) // // onError(error.message)
// }) // // })
// 模拟 K 线数据 // // 模拟 K 线数据
const bars: Bar[] = this.generateMockBars(from, to, resolution); // const bars: Bar[] = this.generateMockBars(from, to, resolution);
if (bars.length === 0) { // if (bars.length === 0) {
onResult([], { noData: true }); // onResult([], { noData: true });
} // }
else { // else {
onResult(bars, { noData: false }); // onResult(bars, { noData: false });
} // }
} // }
/** /**
* 订阅实时数据 * 订阅实时数据
@@ -203,96 +200,45 @@ export class RWADatafeed extends Datafeeds.UDFCompatibleDatafeed {
symbolInfo: LibrarySymbolInfo, symbolInfo: LibrarySymbolInfo,
resolution: ResolutionString, resolution: ResolutionString,
onTick: SubscribeBarsCallback, onTick: SubscribeBarsCallback,
listenerGuid: string, subscriberUID: string,
onResetCacheNeededCallback: () => void, onResetCacheNeededCallback: () => void,
): void { ): void {
console.log("[RWADatafeed]: subscribeBars", { console.log("[RWADatafeed]: subscribeBars", {
symbol: symbolInfo.name, symbol: symbolInfo.name,
resolution, resolution,
listenerGuid, subscriberUID,
});
this.subscribers.set(subscriberUID, { callback: onTick, resolution });
const ws = client.api.market_data.streaming.subscribe();
this.wsConnections.set(subscriberUID, ws);
ws.on("open", () => {
ws.send({ type: "subscribe", subscriberUID, symbol: symbolInfo.name, resolution });
}); });
this.subscribers.set(listenerGuid, { callback: onTick, resolution }); ws.subscribe((message) => {
if (message.data.type !== "bar")
// TODO: 建立 WebSocket 连接获取实时数据 return;
// const ws = new WebSocket(`${this.apiUrl}/ws/kline?symbol=${symbolInfo.name}&resolution=${resolution}`) const bar: Bar = {
// ws.onmessage = (event) => { time: message.data.bar.time,
// const data = JSON.parse(event.data) open: message.data.bar.open,
// const bar: Bar = { high: message.data.bar.high,
// time: data.time * 1000, low: message.data.bar.low,
// open: data.open, close: message.data.bar.close,
// high: data.high, volume: message.data.bar.volume,
// low: data.low, };
// close: data.close, onTick(bar);
// volume: data.volume, });
// }
// onTick(bar)
// }
} }
/** /**
* 取消订阅实时数据 * 取消订阅实时数据
*/ */
unsubscribeBars(listenerGuid: string): void { unsubscribeBars(subscriberUID: string): void {
console.log("[RWADatafeed]: unsubscribeBars", listenerGuid); console.log("[RWADatafeed]: unsubscribeBars", subscriberUID);
this.subscribers.delete(listenerGuid); const ws = this.wsConnections.get(subscriberUID);
if (ws) {
// TODO: 关闭 WebSocket 连接 ws.close();
// ws.close() this.wsConnections.delete(subscriberUID);
}
/**
* 生成模拟 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

@@ -5,9 +5,9 @@ import type { CSSProperties } from "vue";
import { mergeWith } from "lodash-es"; import { mergeWith } from "lodash-es";
import { RWADatafeed } from "./datafeed"; import { RWADatafeed } from "./datafeed";
const { VITE_TRADINGVIEW_LIBRARY_URL, VITE_TRADINGVIEW_DATA_API_URL } = useEnv(); const { VITE_TRADINGVIEW_LIBRARY_URL, VITE_API_URL } = useEnv();
const datafeed = new RWADatafeed(VITE_TRADINGVIEW_DATA_API_URL, 60000); const datafeed = new RWADatafeed(`${VITE_API_URL}/api/udf`);
const defaultOptions = { const defaultOptions = {
container: "tradingview_chart_container", container: "tradingview_chart_container",
@@ -15,7 +15,7 @@ const defaultOptions = {
library_path: `${VITE_TRADINGVIEW_LIBRARY_URL}/charting_library/`, library_path: `${VITE_TRADINGVIEW_LIBRARY_URL}/charting_library/`,
datafeed, datafeed,
interval: "1D" as ResolutionString, interval: "1D" as ResolutionString,
symbol: "AAPL", symbol: "",
debug: true, debug: true,
autosize: true, autosize: true,
// 禁用移动端不友好的功能 // 禁用移动端不友好的功能
@@ -71,6 +71,10 @@ const defaultOptions = {
export const TradingViewChart = defineComponent({ export const TradingViewChart = defineComponent({
name: "TradingViewChart", name: "TradingViewChart",
props: { props: {
symbol: {
type: String,
required: true,
},
width: { width: {
type: [String, Number] as PropType<string | number>, type: [String, Number] as PropType<string | number>,
required: false, required: false,
@@ -102,6 +106,7 @@ export const TradingViewChart = defineComponent({
} }
const opts = mergeWith({}, defaultOptions, props.options); const opts = mergeWith({}, defaultOptions, props.options);
opts.symbol = props.symbol;
const styles = computed(() => { const styles = computed(() => {
const style: CSSProperties = {}; const style: CSSProperties = {};

View File

@@ -9,7 +9,6 @@ import TradeWay from "./components/trade-way.vue";
const mode = ref<"buy" | "sell">("buy"); const mode = ref<"buy" | "sell">("buy");
const tradingviewOptions: Partial<ChartingLibraryWidgetOptions> = { const tradingviewOptions: Partial<ChartingLibraryWidgetOptions> = {
symbol: "BTC/USDT",
disabled_features: [ disabled_features: [
"create_volume_indicator_by_default", "create_volume_indicator_by_default",
], ],
@@ -40,7 +39,7 @@ const tradingviewOptions: Partial<ChartingLibraryWidgetOptions> = {
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content :fullscreen="true"> <ion-content :fullscreen="true">
<TradingViewChart class="mb-5" height="300px" :options="tradingviewOptions" /> <TradingViewChart class="mb-5" height="300px" symbol="BTCUSD" :options="tradingviewOptions" />
<div class="grid grid-cols-5 px-4"> <div class="grid grid-cols-5 px-4">
<div class="col-span-3 space-y-2"> <div class="col-span-3 space-y-2">

View File

@@ -12,6 +12,7 @@ import icons from "unplugin-icons/vite";
import { IonicResolver } from "unplugin-vue-components/resolvers"; import { IonicResolver } from "unplugin-vue-components/resolvers";
import components from "unplugin-vue-components/vite"; import components from "unplugin-vue-components/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import { VitePWA } from "vite-plugin-pwa";
import { generateVersion } from "./scripts/build"; import { generateVersion } from "./scripts/build";
dotenv.config({ path: `.env.${process.env.NODE_ENV}` }); dotenv.config({ path: `.env.${process.env.NODE_ENV}` });
@@ -39,6 +40,46 @@ export default defineConfig({
directoryAsNamespace: true, directoryAsNamespace: true,
resolvers: [IonicResolver(), iconsResolver({ prefix: "i" })], resolvers: [IonicResolver(), iconsResolver({ prefix: "i" })],
}), }),
VitePWA({
registerType: "autoUpdate",
includeAssets: ["favicon.svg", "apple-touch-icon.png"],
manifest: {
name: "Riwa",
short_name: "Riwa",
description: "Riwa Ionic App",
theme_color: "#ffffff",
background_color: "#ffffff",
display: "standalone",
orientation: "portrait",
scope: "/",
start_url: "/",
id: "/",
prefer_related_applications: false,
icons: [
{
src: "/pwa-192x192.png",
sizes: "192x192",
type: "image/png",
},
{
src: "/pwa-512x512.png",
sizes: "512x512",
type: "image/png",
},
{
src: "/pwa-512x512.png",
sizes: "512x512",
type: "image/png",
purpose: "any maskable",
},
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api/],
},
}),
generateVersion({ generateVersion({
version: appVersion, version: appVersion,
}), }),
@@ -57,6 +98,7 @@ export default defineConfig({
"/api": { "/api": {
target: process.env.VITE_API_URL, target: process.env.VITE_API_URL,
changeOrigin: true, changeOrigin: true,
ws: true,
}, },
}, },
}, },