diff --git a/INTEGRATION_DETAILS.md b/INTEGRATION_DETAILS.md new file mode 100644 index 0000000..021061a --- /dev/null +++ b/INTEGRATION_DETAILS.md @@ -0,0 +1,196 @@ +# Integration Details + +This file explains the project internals: which APIs are used, how data moves +through the app, and where the TradingView-specific pieces live. + +## Main Files + +- `index.html`: loads Advanced Charts assets and `src/main.js`. +- `trading.html`: loads Trading Platform assets and `src/trading.js`. +- `src/main.js`: minimal Advanced Charts bootstrap. +- `src/trading.js`: Trading Platform bootstrap, broker sample setup, alerts, + toolbar, save/load, and chart-ready subscription area. +- `src/widget-options.js`: shared widget options plus route-specific AC/TP + option builders. The AC builder removes quote and DOM methods from the exposed + datafeed. +- `src/datafeed.js`: TradingView datafeed implementation. +- `src/streaming.js`: realtime candle streams. +- `src/quotes.js`: realtime quote streams used by Trading Platform + widgetbar/details/watchlist UI. +- `src/helpers.js`: Binance request helpers, symbol parsing, and resolution + mapping. +- `server.mjs`: static server and same-origin CoinDesk RSS proxy. + +## TradingView Runtime Assets + +`npm run tv:install:ac` and `npm run tv:install:tp` use npm to install +TradingView GitHub repositories into a temporary folder and copy the runtimes +directly into `vendor/tradingview/`. + +- `tv:install:ac` installs only Advanced Charts. +- `tv:install:tp` installs Advanced Charts and Trading Platform, so both `/` and + `/trading` have runtime assets. + +`npm run tv:sync` copies whichever local TradingView package folders exist: + +- `charting_library-master/charting_library` to + `vendor/tradingview/advanced_charts`. +- `trading_platform-master/charting_library` to + `vendor/tradingview/trading_platform`. + +The generated `vendor/tradingview/` folder is ignored by git. + +The Trading Platform install helper also copies `broker-sample/dist/bundle.js` +into `third_party/tradingview/broker-sample/dist/bundle.js` when the installed +package contains it. + +## Binance REST APIs + +Base URL: + +```text +https://api.binance.com/ +``` + +Used endpoints: + +- `api/v3/exchangeInfo`: loads Binance spot symbols for `searchSymbols` and + `resolveSymbol`. +- `api/v3/klines`: loads historical OHLCV bars for `getBars`. +- `api/v3/ticker/24hr`: seeds Trading Platform quote fields such as last price, + bid/ask, daily high/low, volume, and daily change. +- `api/v3/ticker?windowSize=1h`: seeds Trading Platform rolling 1-hour quote + change fields. + +No Binance API key is required. + +## Binance WebSocket Streams + +Base URL: + +```text +wss://stream.binance.com:9443/ws +``` + +Used streams: + +- `@kline_`: native Binance intervals such as `1m`, `5m`, + `1h`, `1d`. +- `@trade`: tick stream used to rebuild custom intervals such as `2`, + `4`, `10`, `90`, and `180`. +- `@ticker`: realtime 24-hour quote updates for Trading Platform quote + UI. +- `@ticker_1h`: realtime rolling 1-hour quote updates for Trading + Platform quote UI. +- `@depth20@100ms`: Trading Platform DOM depth for Binance spot symbols. + +The stream modules share sockets, reference-count subscriptions, debounce +startup churn, and reconnect only while active subscribers exist. + +## Datafeed Methods + +`src/datafeed.js` implements the TradingView methods used by the widgets: + +- `onReady` +- `searchSymbols` +- `resolveSymbol` +- `getBars` +- `subscribeBars` +- `unsubscribeBars` +- `getMarks` +- `getTimescaleMarks` +- `getQuotes` +- `subscribeQuotes` +- `unsubscribeQuotes` +- `subscribeDepth` +- `unsubscribeDepth` + +The Advanced Charts route exposes only chart/search/history/realtime bar +methods. `getQuotes`, `subscribeQuotes`, `unsubscribeQuotes`, `subscribeDepth`, +and `unsubscribeDepth` are exposed only to the Trading Platform route. + +`subscribeDepth` is Trading Platform-specific. It uses live Binance depth first +and only generates synthetic levels while live depth is unavailable. + +## Supported Resolutions + +```js +[ + '1', + '2', + '3', + '4', + '5', + '10', + '15', + '30', + '60', + '90', + '120', + '180', + '240', + '360', + '480', + '720', + '1D', + '3D', + '1W', + '1M', +]; +``` + +Native Binance-backed intervals: + +- `1`, `3`, `5`, `15`, `30`, `60`, `120`, `240`, `360`, `480`, `720`, `1D`, + `3D`, `1W`, `1M`. + +Custom intervals rebuilt locally: + +- `2`, `4`, `10`, `90`, `180`. + +## News + +The Trading Platform widgetbar uses CoinDesk RSS through this local route: + +```text +/api/news/coindesk-rss +``` + +CoinDesk does not provide browser CORS headers for local development, so +`server.mjs` fetches the RSS feed server-side, returns it same-origin, and +strips HTML tags from RSS titles/descriptions before TradingView displays the +items. The free Advanced Charts route does not configure `rss_news_feed`. + +## Trading Platform Extras + +The `/trading` route adds: + +- BrokerDemo runtime from + `third_party/tradingview/broker-sample/dist/bundle.js`. +- `broker_factory` and `broker_config`. +- Trading Platform DOM via `subscribeDepth`. +- LocalStorage-backed save/load adapter. +- Custom alert demo using chart shapes plus broker notifications. +- Account manager, watchlist, details, quote data, data window, news, and + toolbar controls. + +The BrokerDemo bundle source is: +[broker-sample/dist/bundle.js](https://github.com/tradingview/trading_platform/blob/master/broker-sample/dist/bundle.js). + +## Persistence + +TradingView internal settings localStorage is disabled through +`disabled_features` so tutorial defaults are deterministic across `localhost` +and `127.0.0.1`. + +The Trading Platform save/load adapter still stores charts, drawings, templates, +and study templates in browser `localStorage`. + +## Known Constraints + +- Binance public endpoint availability can vary by jurisdiction or network. +- Trading Platform CoinDesk news depends on the local server route, so use + `npm run start`. +- Chart marks and timescale marks are demo data. +- Non-Binance symbols cannot use live Binance DOM depth and will fall back to + generated DOM levels. diff --git a/LICENSE b/LICENSE index 8e550e2..5775c01 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,21 @@ -MIT License - -Copyright (c) 2020 TradingView, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +MIT License + +Copyright (c) 2020 TradingView, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 87f22ed..cfc1a8e 100644 --- a/README.md +++ b/README.md @@ -1,62 +1,127 @@ -# Advanced Charts: Connecting data via the Datafeed API +# TradingView Charting Library Datafeed Example -## Overview +This project demonstrates a Binance-backed TradingView datafeed with two +optional run modes: -This repository contains sample code for the [Datafeed API tutorial], which demonstrates how to implement real-time data streaming in [Advanced Charts]. -As an example, the tutorial describes a connection via the free [CryptoCompare] API that provides data from different crypto exchanges. +- Free Advanced Charts from `tradingview/charting_library`. +- Paid Trading Platform from `tradingview/trading_platform`. -> [!NOTE] -> Advanced Charts is a standalone client-side library that is used to display financial charts, prices, and technical analysis tools. -> Learn more about Advanced Charts on the [TradingView website]. +You only need the TradingView package for the page you want to test. If both +packages are present, both routes work in the same checkout. -## Prerequisites +Last tested: June 2026 with TradingView Advanced Charts 31.2.0, Trading +Platform 31.2.0, Binance public Spot REST/WebSocket APIs, CoinDesk RSS, and the +npm dependencies from `package-lock.json`. -- The [Advanced Charts repository] is private. -Refer to [Getting Access] for more information on how to get the library. -- To use the [CryptoCompare] API, you should create an account and generate a free API key. For more information, refer to the [CryptoCompare documentation](https://www.cryptocompare.com/coins/guides/how-to-use-our-api/). +## Start The Project -## How to run +For a fresh clone, install npm dependencies first, then download the TradingView +runtime you want to use: -Take the following steps to run this project: +```bash +npm install +npm run tv:install:tp -- 31.2.0 # current latest version +npm run start +``` -1. Clone the repository. - Note that for the real project, it is better to use this repository as a submodule in yours. +For the free Advanced Charts page only, use this instead: - ```bash - git clone https://github.com/tradingview/charting-library-tutorial.git - ``` +```bash +npm install +npm run tv:install:ac -- 31.2.0 +npm run start +``` -2. Go to the repository folder and initialize the Git submodule with the library: +Open: - ```bash - git submodule update --init --recursive - ``` +```text +http://127.0.0.1:3000 +http://127.0.0.1:3000/trading +``` - Alternatively, you can download the [library repository] from a ZIP file or clone it using Git. +## Download A TradingView Package -3. Run the following command to serve static files: +If you have GitHub SSH access to TradingView's repositories, the install helper +can download and place the package for you: - ```bash - npx serve - ``` +```bash +npm run tv:install:ac -- 31.2.0 # tested version +npm run tv:install:tp -- 31.2.0 # tested version +``` -## Release notes +Use only the command for the package you need. If you omit the version, the +script downloads `master`. -### September, 2025 +- `tv:install:ac` installs only Advanced Charts. +- `tv:install:tp` installs both Advanced Charts and Trading Platform, so the + homepage still works. -The latest version introduces several key improvements: +Place one or both package folders in the project root: -- **Intraday resolutions**: Added support for minute and hour resolutions. -- **SymbolInfo update**: Removed `full_name` from the `SymbolInfo` object. Now, `ticker` is used instead. -- **Improved search**: `searchSymbols` now properly filters results by user input, selected exchange, and symbol type. -- **Improved `getBars`**: `getBars` now selects the correct API endpoint based on the requested `resolution` (minute, hour, or day), ensuring the most appropriate data is used. -- **Enhanced streaming**: Reworked streaming logic to support [multiple subscriptions] to data updates. +```text +charting-library-tutorial/ + charting_library-master/ + charting_library/ + charting_library.js + trading_platform-master/ + charting_library/ + charting_library.js +``` -[Advanced Charts]: https://www.tradingview.com/charting-library-docs/ -[Datafeed API tutorial]: https://www.tradingview.com/charting-library-docs/latest/tutorials/implement_datafeed_tutorial/ -[CryptoCompare]: https://www.cryptocompare.com/ -[TradingView website]: https://www.tradingview.com/HTML5-stock-forex-bitcoin-charting-library/?feature=technical-analysis-charts -[Advanced Charts repository]: https://github.com/tradingview/charting_library -[Getting Access]: https://www.tradingview.com/charting-library-docs/latest/getting_started/quick-start#getting-access -[multiple subscriptions]: https://www.tradingview.com/charting-library-docs/latest/connecting_data/datafeed-api/required-methods#multiple-subscriptions -[library repository]: https://github.com/tradingview/charting_library +- Use `charting_library-master` for the free Advanced Charts page. +- Use `trading_platform-master` for the Trading Platform page. + +For Trading Platform broker features, the `tv:install:tp` script also copies +TradingView's BrokerDemo bundle when it is present in the package. If you place +packages manually, copy the bundle into this path: + +```text +third_party/tradingview/broker-sample/dist/bundle.js +``` + +The bundle can be found in TradingView's Trading Platform repository: +[broker-sample/dist/bundle.js](https://github.com/tradingview/trading_platform/blob/master/broker-sample/dist/bundle.js). + +## Routes + +- `/`: minimal Advanced Charts example with chart datafeed, theme toggle, and + documentation button. +- `/trading`: Trading Platform example with broker sample, DOM, account manager, + alerts, save/load, watchlist, quotes, CoinDesk news, toolbar controls, and + multi-chart support. + +## Updating TradingView Packages + +With npm: + +```bash +npm run tv:install:ac -- 31.2.0 +npm run tv:install:tp -- 31.2.0 +``` + +If you manually placed `charting_library-master` or `trading_platform-master` in +the project root, run: + +```bash +npm run tv:sync +npm run start +``` + +Hard refresh the browser after package changes so old TradingView chunks are not +reused. + +## Useful Notes + +- `npm run tv:sync` copies package assets into `vendor/tradingview/`. +- `npm run tv:install:ac` installs only the Advanced Charts runtime into + `vendor/tradingview/advanced_charts`. +- `npm run tv:install:tp` installs Advanced Charts and Trading Platform into + `vendor/tradingview/`. +- If only `charting_library-master` exists, only `/` is expected to work. +- If only `trading_platform-master` exists, only `/trading` is expected to work. +- `npm run start` serves clean routes and the CoinDesk RSS proxy used by + `/trading`. +- `npm run start:static` is only a static fallback; Trading Platform CoinDesk + news will not load there. +- More implementation detail is in + [INTEGRATION_DETAILS.md](./INTEGRATION_DETAILS.md). diff --git a/charting_library_cloned_data b/charting_library_cloned_data deleted file mode 160000 index 2383c8c..0000000 --- a/charting_library_cloned_data +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 2383c8c6e8ab6f6197d50d676746e0a06ab713bd diff --git a/index.html b/index.html index 01b5f6c..5025dc3 100644 --- a/index.html +++ b/index.html @@ -1,20 +1,14 @@ - - - - TradingView Advanced Charts example - - - - - - - - - -
-
- + + + + TradingView Charting Library datafeed example + + + + +
+ diff --git a/package.json b/package.json new file mode 100644 index 0000000..87251cc --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "tradingview-charting-library-datafeed-example", + "private": true, + "scripts": { + "start": "node server.mjs", + "start:static": "serve . --listen 3000", + "tv:install:ac": "node scripts/install-tradingview-package.mjs ac", + "tv:install:tp": "node scripts/install-tradingview-package.mjs tp", + "tv:sync": "node scripts/sync-tradingview-assets.mjs", + "tv:sync:ac": "node scripts/sync-tradingview-assets.mjs ac", + "tv:sync:tp": "node scripts/sync-tradingview-assets.mjs tp" + }, + "devDependencies": { + "serve": "^14.2.5", + "serve-handler": "^6.1.7" + } +} diff --git a/scripts/install-tradingview-package.mjs b/scripts/install-tradingview-package.mjs new file mode 100644 index 0000000..644007c --- /dev/null +++ b/scripts/install-tradingview-package.mjs @@ -0,0 +1,323 @@ +// Downloads private TradingView GitHub packages into vendor/tradingview and +// copies the Trading Platform BrokerDemo bundle when available. See README.md +// for setup commands and INTEGRATION_DETAILS.md for the runtime layout. +import { + cp, + mkdir, + mkdtemp, + readdir, + rm, + stat, + writeFile, +} from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..'); + +const outputRoot = path.join(projectRoot, 'vendor', 'tradingview'); + +const RUNTIMES = { + ac: { + label: 'Advanced Charts', + repository: 'charting_library', + outputPath: path.join(outputRoot, 'advanced_charts'), + }, + tp: { + label: 'Trading Platform', + repository: 'trading_platform', + outputPath: path.join(outputRoot, 'trading_platform'), + }, +}; + +const INSTALL_TARGETS = { + ac: [RUNTIMES.ac], + 'advanced-charts': [RUNTIMES.ac], + tp: [RUNTIMES.ac, RUNTIMES.tp], + 'trading-platform': [RUNTIMES.ac, RUNTIMES.tp], +}; + +function printUsage() { + console.log( + [ + 'Usage:', + ' npm run tv:install:ac -- [version-or-git-ref]', + ' npm run tv:install:tp -- [version-or-git-ref]', + '', + 'Examples:', + ' npm run tv:install:ac -- 31.2.0', + ' npm run tv:install:tp -- 31.2.0', + ' npm run tv:install:tp -- master', + '', + 'When omitted, version-or-git-ref defaults to master.', + 'tv:install:ac installs only Advanced Charts.', + 'tv:install:tp installs Advanced Charts and Trading Platform.', + ].join('\n') + ); +} + +function buildPackageSpec(repository, ref = 'master') { + const fragment = /^\d+\.\d+\.\d+$/.test(ref) ? `semver:${ref}` : ref; + + return `git+ssh://git@github.com/tradingview/${repository}.git#${fragment}`; +} + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + stdio: 'inherit', + ...options, + }); + + child.on('error', reject); + child.on('exit', code => { + if (code === 0) { + resolve(); + return; + } + + reject( + new Error( + `${command} ${args.join(' ')} exited with code ${code}` + ) + ); + }); + }); +} + +async function exists(targetPath) { + try { + const stats = await stat(targetPath); + return stats.isDirectory(); + } catch { + return false; + } +} + +async function existsFile(targetPath) { + try { + const stats = await stat(targetPath); + return stats.isFile(); + } catch { + return false; + } +} + +async function getInstalledPackagePaths(nodeModulesPath) { + const entries = await readdir(nodeModulesPath, { withFileTypes: true }); + const packagePaths = []; + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('.')) continue; + + const entryPath = path.join(nodeModulesPath, entry.name); + + if (entry.name.startsWith('@')) { + const scopedEntries = await readdir(entryPath, { + withFileTypes: true, + }); + + scopedEntries.forEach(scopedEntry => { + if (scopedEntry.isDirectory()) { + packagePaths.push(path.join(entryPath, scopedEntry.name)); + } + }); + continue; + } + + packagePaths.push(entryPath); + } + + return packagePaths; +} + +async function findInstalledRuntime(nodeModulesPath, repository) { + const packagePaths = await getInstalledPackagePaths(nodeModulesPath); + const runtimeCandidates = []; + + for (const packagePath of packagePaths) { + const runtimePath = path.join(packagePath, 'charting_library'); + + if (await exists(runtimePath)) { + runtimeCandidates.push({ packagePath, runtimePath }); + } + } + + if (runtimeCandidates.length === 0) { + throw new Error( + 'Installed package did not contain a charting_library/ runtime folder.' + ); + } + + if (repository === 'trading_platform') { + for (const candidate of runtimeCandidates) { + const brokerSamplePath = path.join( + candidate.packagePath, + 'broker-sample', + 'dist', + 'bundle.js' + ); + + if (await existsFile(brokerSamplePath)) { + return candidate; + } + } + } + + const matchingPackageName = runtimeCandidates.find(candidate => + path.basename(candidate.packagePath).includes(repository) + ); + + return matchingPackageName ?? runtimeCandidates[0]; +} + +async function findBrokerSampleBundle(nodeModulesPath, preferredPackagePath) { + const relativeBundlePath = path.join( + 'broker-sample', + 'dist', + 'bundle.js' + ); + const preferredSourcePath = path.join( + preferredPackagePath, + relativeBundlePath + ); + + if (await existsFile(preferredSourcePath)) { + return preferredSourcePath; + } + + const packagePaths = await getInstalledPackagePaths(nodeModulesPath); + + for (const packagePath of packagePaths) { + const sourcePath = path.join(packagePath, relativeBundlePath); + + if (await existsFile(sourcePath)) { + return sourcePath; + } + } + + return null; +} + +async function copyBrokerSampleIfPresent(nodeModulesPath, preferredPackagePath) { + const sourcePath = await findBrokerSampleBundle( + nodeModulesPath, + preferredPackagePath + ); + + if (!sourcePath) return false; + + const destinationPath = path.join( + projectRoot, + 'third_party', + 'tradingview', + 'broker-sample', + 'dist', + 'bundle.js' + ); + + await mkdir(path.dirname(destinationPath), { recursive: true }); + await cp(sourcePath, destinationPath, { force: true }); + + return true; +} + +async function downloadAndCopyRuntime(runtime, ref) { + const packageSpec = buildPackageSpec(runtime.repository, ref); + const tempRoot = await mkdtemp( + path.join(os.tmpdir(), 'tradingview-package-') + ); + + try { + await writeFile( + path.join(tempRoot, 'package.json'), + '{"private":true}\n' + ); + + console.log(`Downloading ${runtime.label} from ${packageSpec}`); + await run( + 'npm', + [ + 'install', + '--ignore-scripts', + '--no-audit', + '--no-fund', + '--package-lock=false', + '--cache', + path.join(tempRoot, '.npm-cache'), + packageSpec, + ], + { cwd: tempRoot } + ); + + const nodeModulesPath = path.join(tempRoot, 'node_modules'); + const { packagePath, runtimePath } = await findInstalledRuntime( + nodeModulesPath, + runtime.repository + ); + + await rm(runtime.outputPath, { recursive: true, force: true }); + await mkdir(path.dirname(runtime.outputPath), { recursive: true }); + await cp(runtimePath, runtime.outputPath, { + recursive: true, + force: true, + }); + + if (runtime.repository === 'trading_platform') { + const copiedBrokerSample = await copyBrokerSampleIfPresent( + nodeModulesPath, + packagePath + ); + if (copiedBrokerSample) { + console.log( + 'Copied broker sample bundle into third_party/tradingview/broker-sample/dist/bundle.js.' + ); + } else { + console.warn( + 'Broker sample bundle was not found in the installed package.' + ); + } + } + + console.log( + `Copied ${runtime.label} runtime into ${path.relative(projectRoot, runtime.outputPath)}.` + ); + } finally { + await rm(tempRoot, { recursive: true, force: true }); + } +} + +async function main() { + const targetKey = process.argv[2]; + const ref = process.argv[3] ?? 'master'; + + if ( + !targetKey || + targetKey === '--help' || + targetKey === '-h' || + ref === '--help' || + ref === '-h' + ) { + printUsage(); + return; + } + + const runtimes = INSTALL_TARGETS[targetKey]; + if (!runtimes) { + printUsage(); + throw new Error(`Unknown TradingView package target: ${targetKey}`); + } + + for (const runtime of runtimes) { + await downloadAndCopyRuntime(runtime, ref); + } +} + +main().catch(error => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/scripts/sync-tradingview-assets.mjs b/scripts/sync-tradingview-assets.mjs new file mode 100644 index 0000000..669e8b2 --- /dev/null +++ b/scripts/sync-tradingview-assets.mjs @@ -0,0 +1,165 @@ +// Syncs locally placed TradingView package folders into vendor/tradingview. +// See README.md for setup commands and INTEGRATION_DETAILS.md for the runtime +// layout this script maintains. +import { cp, mkdir, rm, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const projectRoot = path.resolve(__dirname, '..'); + +const outputRoot = path.join(projectRoot, 'vendor', 'tradingview'); +const advancedChartsOutput = path.join(outputRoot, 'advanced_charts'); +const tradingPlatformOutput = path.join(outputRoot, 'trading_platform'); +const RUNTIME_ALIASES = new Map([ + ['all', ['ac', 'tp']], + ['ac', ['ac']], + ['advanced-charts', ['ac']], + ['charting-library', ['ac']], + ['tp', ['tp']], + ['trading-platform', ['tp']], +]); + +// Checks optional package paths without throwing when a variant is not installed. +async function exists(targetPath) { + try { + const stats = await stat(targetPath); + return stats.isDirectory(); + } catch { + return false; + } +} + +// Replaces generated vendor assets with the exact package contents the user provided. +async function copyDirectory(sourcePath, destinationPath) { + await rm(destinationPath, { recursive: true, force: true }); + await mkdir(path.dirname(destinationPath), { recursive: true }); + await cp(sourcePath, destinationPath, { recursive: true, force: true }); +} + +function formatCheckedPaths(candidatePaths) { + return candidatePaths + .map(candidatePath => `- ${path.relative(projectRoot, candidatePath)}`) + .join('\n'); +} + +function getSelectedRuntimeKeys() { + const args = process.argv.slice(2); + if (args.length === 0) { + return new Set(['ac', 'tp']); + } + + const selected = new Set(); + + args.forEach(arg => { + const aliases = RUNTIME_ALIASES.get(arg); + + if (!aliases) { + throw new Error( + `Unknown runtime "${arg}". Use "ac", "tp", or "all".` + ); + } + + aliases.forEach(key => selected.add(key)); + }); + + return selected; +} + +// Picks the first package layout that exists and explains what is unavailable otherwise. +async function syncRuntime({ label, route, sourcePaths, outputPath }) { + for (const candidatePath of sourcePaths.filter(Boolean)) { + if (await exists(candidatePath)) { + await copyDirectory(candidatePath, outputPath); + console.log( + `Synced ${label} from ${path.relative(projectRoot, candidatePath)}.` + ); + return true; + } + } + + if (await exists(outputPath)) { + console.warn( + [ + `Skipped ${label}; no source package folder was found.`, + `Left existing ${path.relative(projectRoot, outputPath)} unchanged.`, + 'Checked:', + formatCheckedPaths(sourcePaths.filter(Boolean)), + ].join('\n') + ); + return true; + } + + console.warn( + [ + `Skipped ${label}; ${route} will be unavailable until that package is added.`, + 'Checked:', + formatCheckedPaths(sourcePaths.filter(Boolean)), + ].join('\n') + ); + + return false; +} + +// Syncs the two served TradingView runtimes into vendor/. +async function main() { + const selectedRuntimeKeys = getSelectedRuntimeKeys(); + await rm(path.join(outputRoot, 'charting_library'), { + recursive: true, + force: true, + }); + + const runtimes = [ + { + key: 'ac', + label: 'Advanced Charts', + route: '/', + sourcePaths: [ + path.join( + projectRoot, + 'charting_library-master', + 'charting_library' + ), + ], + outputPath: advancedChartsOutput, + }, + { + key: 'tp', + label: 'Trading Platform', + route: '/trading', + sourcePaths: [ + path.join( + projectRoot, + 'trading_platform-master', + 'charting_library' + ), + ], + outputPath: tradingPlatformOutput, + }, + ]; + const syncResults = []; + + for (const runtime of runtimes) { + if (!selectedRuntimeKeys.has(runtime.key)) { + continue; + } + + syncResults.push(await syncRuntime(runtime)); + } + + if (!syncResults.some(Boolean)) { + console.warn( + [ + 'No TradingView runtime assets were synced.', + 'Add charting_library-master for /, or trading_platform-master for /trading, then rerun npm run tv:sync.', + 'Alternatively, use npm run tv:install:ac or npm run tv:install:tp to install directly into vendor/.', + ].join('\n') + ); + } +} + +main().catch(error => { + console.error(error.message); + process.exitCode = 1; +}); diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..a66d593 --- /dev/null +++ b/server.mjs @@ -0,0 +1,195 @@ +import http from 'node:http'; +import process from 'node:process'; +import handler from 'serve-handler'; + +const COINDESK_RSS_URL = 'https://www.coindesk.com/arc/outboundfeeds/rss/'; +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_PORT = 3000; +const NEWS_CACHE_TTL_MS = 60_000; +const HTML_ROUTE_PATHS = new Map([ + ['/index', '/index.html'], + ['/trading', '/trading.html'], +]); + +let cachedNewsResponse = null; + +// Keeps the CoinDesk feed fresh enough for demos without hammering the upstream RSS URL. +function isFreshCache(entry) { + return entry && Date.now() - entry.updatedAt < NEWS_CACHE_TTL_MS; +} + +// Escapes cleaned text before we put it back into RSS CDATA fields. +function toCdata(text) { + return `', ']]]]>')}]]>`; +} + +// Converts numeric HTML entities without letting malformed values break the RSS proxy. +function fromHtmlCodePoint(value, radix = 10) { + const codePoint = Number.parseInt(value, radix); + + return Number.isFinite(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff + ? String.fromCodePoint(codePoint) + : ''; +} + +// Decodes the common HTML entities seen in RSS descriptions before stripping tags. +function decodeHtmlEntities(text) { + return text + .replace(/&#(\d+);/g, (_, value) => fromHtmlCodePoint(value)) + .replace(/&#x([0-9a-f]+);/gi, (_, value) => + fromHtmlCodePoint(value, 16) + ) + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +// Turns HTML-rich RSS summaries into plain text so TradingView does not show raw markup. +function cleanRssText(value) { + return decodeHtmlEntities(value) + .replace(//gi, ' ') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +// Cleans item/channel title and description fields while preserving the RSS envelope. +function sanitizeRssPresentation(xml) { + return xml.replace( + /<(title|description)>([\s\S]*?)<\/\1>/g, + (match, tagName, rawValue) => { + const cdataMatch = rawValue.match(/^$/); + const value = cdataMatch ? cdataMatch[1] : rawValue; + const cleaned = cleanRssText(value); + + return cleaned + ? `<${tagName}>${toCdata(cleaned)}` + : match; + } + ); +} + +// Fetches CoinDesk RSS server-side so the browser can consume it from this app origin. +async function fetchCoinDeskRss() { + if (isFreshCache(cachedNewsResponse)) { + return cachedNewsResponse; + } + + const response = await fetch(COINDESK_RSS_URL, { + headers: { + 'user-agent': 'tradingview-charting-library-datafeed-example/1.0', + }, + }); + + if (!response.ok) { + throw new Error( + `CoinDesk RSS request failed with HTTP ${response.status}` + ); + } + + cachedNewsResponse = { + body: sanitizeRssPresentation(await response.text()), + etag: response.headers.get('etag'), + lastModified: response.headers.get('last-modified'), + updatedAt: Date.now(), + }; + + return cachedNewsResponse; +} + +// Preserves RSS metadata while making the proxy response look like a normal feed. +function writeNewsHeaders(response, payload) { + response.setHeader('Content-Type', 'application/rss+xml; charset=utf-8'); + response.setHeader('Cache-Control', 'public, max-age=60'); + + if (payload.etag) { + response.setHeader('ETag', payload.etag); + } + + if (payload.lastModified) { + response.setHeader('Last-Modified', payload.lastModified); + } +} + +// Avoids stale TradingView chunk hashes while iterating on local library versions. +function writeStaticHeaders(response) { + response.setHeader('Cache-Control', 'no-store, max-age=0'); + response.setHeader('Pragma', 'no-cache'); + response.setHeader('Expires', '0'); +} + +// Handles the one dynamic route in the project: same-origin CoinDesk RSS. +async function handleCoinDeskNews(request, response) { + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.writeHead(405, { Allow: 'GET, HEAD' }); + response.end('Method Not Allowed'); + return; + } + + try { + const payload = await fetchCoinDeskRss(); + writeNewsHeaders(response, payload); + response.writeHead(200); + + if (request.method === 'HEAD') { + response.end(); + return; + } + + response.end(payload.body); + } catch (error) { + console.error('[news proxy] Error:', error); + response.writeHead(502, { + 'Content-Type': 'text/plain; charset=utf-8', + }); + response.end('Unable to load CoinDesk RSS feed.'); + } +} + +// Routes API requests first, then falls through to serve-handler for static files. +async function handleRequest(request, response) { + const url = new URL( + request.url ?? '/', + `http://${request.headers.host ?? `${DEFAULT_HOST}:${DEFAULT_PORT}`}` + ); + + if (url.pathname === '/api/news/coindesk-rss') { + await handleCoinDeskNews(request, response); + return; + } + + if (url.pathname === '/') { + request.url = '/index.html'; + } else if (HTML_ROUTE_PATHS.has(url.pathname)) { + request.url = `${HTML_ROUTE_PATHS.get(url.pathname)}${url.search}`; + } + + writeStaticHeaders(response); + + await handler(request, response, { + public: '.', + cleanUrls: false, + directoryListing: false, + }); +} + +const port = Number.parseInt(process.argv[2] ?? '', 10) || DEFAULT_PORT; +const host = process.argv[3] ?? DEFAULT_HOST; + +const server = http.createServer((request, response) => { + handleRequest(request, response).catch(error => { + console.error('[server] Unhandled error:', error); + response.writeHead(500, { + 'Content-Type': 'text/plain; charset=utf-8', + }); + response.end('Internal Server Error'); + }); +}); + +server.listen(port, host, () => { + console.log(`Server running at http://${host}:${port}`); +}); diff --git a/src/assets/alien.svg b/src/assets/alien.svg new file mode 100644 index 0000000..dd4e7fb --- /dev/null +++ b/src/assets/alien.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/assets/arrow-down-angle-svgrepo-com.svg b/src/assets/arrow-down-angle-svgrepo-com.svg new file mode 100644 index 0000000..1f38e81 --- /dev/null +++ b/src/assets/arrow-down-angle-svgrepo-com.svg @@ -0,0 +1,11 @@ + + + + + + + + \ No newline at end of file diff --git a/src/assets/file.svg b/src/assets/file.svg new file mode 100644 index 0000000..e6e470a --- /dev/null +++ b/src/assets/file.svg @@ -0,0 +1,1597 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/fonts/NanumBarunGothic.otf b/src/assets/fonts/NanumBarunGothic.otf new file mode 100644 index 0000000..80886df Binary files /dev/null and b/src/assets/fonts/NanumBarunGothic.otf differ diff --git a/src/datafeed.js b/src/datafeed.js deleted file mode 100644 index 9d886c7..0000000 --- a/src/datafeed.js +++ /dev/null @@ -1,223 +0,0 @@ -import { - makeApiRequest, - generateSymbol, - parseFullSymbol, -} from './helpers.js'; -import { - subscribeOnStream, - unsubscribeFromStream, -} from './streaming.js'; - -// Use a Map to store the last bar for each symbol subscription. -// This is essential for the streaming logic to update the chart correctly. -const lastBarsCache = new Map(); - -// DatafeedConfiguration implementation -const configurationData = { - // Represents the resolutions for bars supported by your datafeed - supported_resolutions: ['1', '5', '15', '60', '180', '1D', '1W', '1M'], - - // The `exchanges` arguments are used for the `searchSymbols` method if a user selects the exchange - exchanges: [{ - value: 'Bitfinex', - name: 'Bitfinex', - desc: 'Bitfinex', - }, - { - value: 'Kraken', - // Filter name - name: 'Kraken', - // Full exchange name displayed in the filter popup - desc: 'Kraken bitcoin exchange', - }, - ], - // The `symbols_types` arguments are used for the `searchSymbols` method if a user selects this symbol type - symbols_types: [{ - name: 'crypto', - value: 'crypto', - }, - ], -}; - -// Obtains all symbols for all exchanges supported by CryptoCompare API -async function getAllSymbols() { - const data = await makeApiRequest('data/v3/all/exchanges'); - let allSymbols = []; - - for (const exchange of configurationData.exchanges) { - if (data.Data[exchange.value]) { - const pairs = data.Data[exchange.value].pairs; - - for (const leftPairPart of Object.keys(pairs)) { - const symbols = pairs[leftPairPart].map(rightPairPart => { - const symbol = generateSymbol(exchange.value, leftPairPart, rightPairPart); - return { - symbol: symbol.short, - ticker: symbol.full, - description: symbol.short, - exchange: exchange.value, - type: 'crypto' - }; - }); - allSymbols = [...allSymbols, ...symbols]; - } - } - } - return allSymbols; -} - -export default { - onReady: (callback) => { - console.log('[onReady]: Method call'); - setTimeout(() => callback(configurationData)); - }, - - searchSymbols: async ( - userInput, - exchange, - symbolType, - onResultReadyCallback, - ) => { - console.log('[searchSymbols]: Method call'); - const symbols = await getAllSymbols(); - const newSymbols = symbols.filter(symbol => { - const isExchangeValid = exchange === '' || symbol.exchange === exchange; - const isFullSymbolContainsInput = symbol.ticker - .toLowerCase() - .indexOf(userInput.toLowerCase()) !== -1; - return isExchangeValid && isFullSymbolContainsInput; - }); - onResultReadyCallback(newSymbols); - }, - - resolveSymbol: async ( - symbolName, - onSymbolResolvedCallback, - onResolveErrorCallback, - extension - ) => { - console.log('[resolveSymbol]: Method call', symbolName); - const symbols = await getAllSymbols(); - const symbolItem = symbols.find(({ - ticker, - }) => ticker === symbolName); - if (!symbolItem) { - console.log('[resolveSymbol]: Cannot resolve symbol', symbolName); - onResolveErrorCallback("unknown_symbol"); // for ghost icon - return; - } - // Symbol information object - const symbolInfo = { - ticker: symbolItem.ticker, - name: symbolItem.symbol, - description: symbolItem.description, - type: symbolItem.type, - exchange: symbolItem.exchange, - listed_exchange: symbolItem.exchange, - session: '24x7', - timezone: 'Etc/UTC', - minmov: 1, - pricescale: 10000, - has_intraday: true, - intraday_multipliers: ["1", "60"], - has_daily: true, - daily_multipliers: ["1"], - visible_plots_set: "ohlcv", - supported_resolutions: configurationData.supported_resolutions, - volume_precision: 2, - data_status: 'streaming', - }; - - console.log('[resolveSymbol]: Symbol resolved', symbolName); - onSymbolResolvedCallback(symbolInfo); - }, - - getBars: async (symbolInfo, resolution, periodParams, onHistoryCallback, onErrorCallback) => { - const { from, to, firstDataRequest } = periodParams; - console.log('[getBars]: Method call', symbolInfo, resolution, from, to); - const parsedSymbol = parseFullSymbol(symbolInfo.ticker); - - let endpoint; - // Determine the correct endpoint based on the resolution requested by the library - if (resolution === '1D') { - endpoint = 'histoday'; - } else if (resolution === '60') { - endpoint = 'histohour'; - } else if (resolution === '1') { - endpoint = 'histominute'; - } else { - onErrorCallback(`Invalid resolution: ${resolution}`); - return; - } - - const urlParameters = { - e: parsedSymbol.exchange, - fsym: parsedSymbol.fromSymbol, - tsym: parsedSymbol.toSymbol, - toTs: to, - limit: 2000, - }; - - // Example of historical OHLC 5 minute data request: - // https://min-api.cryptocompare.com/data/v2/histominute?fsym=ETH&tsym=USDT&limit=10&e=Binance&api_key="API_KEY" - const query = Object.keys(urlParameters) - .map(name => `${name}=${encodeURIComponent(urlParameters[name])}`) - .join('&'); - - try { - const data = await makeApiRequest(`data/v2/${endpoint}?${query}`); - if ((data.Response && data.Response === 'Error') || !data.Data || !data.Data.Data || data.Data.Data.length === 0) { - // "noData" should be set if there is no data in the requested period - onHistoryCallback([], { noData: true }); - return; - } - - let bars = []; - data.Data.Data.forEach(bar => { - if (bar.time >= from && bar.time < to) { - bars.push({ - time: bar.time * 1000, - low: bar.low, - high: bar.high, - open: bar.open, - close: bar.close, - volume: bar.volumefrom, - }); - } - }); - - if (firstDataRequest) { - lastBarsCache.set(symbolInfo.ticker, { ...bars[bars.length - 1] }); - } - console.log(`[getBars]: returned ${bars.length} bar(s)`); - onHistoryCallback(bars, { noData: false }); - } catch (error) { - console.log('[getBars]: Get error', error); - onErrorCallback(error); - } - }, - - subscribeBars: ( - symbolInfo, - resolution, - onRealtimeCallback, - subscriberUID, - onResetCacheNeededCallback, - ) => { - console.log('[subscribeBars]: Method call with subscriberUID:', subscriberUID); - subscribeOnStream( - symbolInfo, - resolution, - onRealtimeCallback, - subscriberUID, - onResetCacheNeededCallback, - // Pass the last bar from cache if available - lastBarsCache.get(symbolInfo.ticker) - ); - }, - - unsubscribeBars: (subscriberUID) => { - console.log('[unsubscribeBars]: Method call with subscriberUID:', subscriberUID); - unsubscribeFromStream(subscriberUID); - }, -}; diff --git a/src/datafeed/datafeed.js b/src/datafeed/datafeed.js new file mode 100644 index 0000000..888b209 --- /dev/null +++ b/src/datafeed/datafeed.js @@ -0,0 +1,872 @@ +import { + BINANCE_EXCHANGE, + SUPPORTED_RESOLUTIONS, + barStartTime, + generateSymbol, + getResolutionSpec, + intervalToMilliseconds, + makeApiRequest, + parseFullSymbol, +} from './helpers.js'; +import { + subscribeQuotesOnStream, + unsubscribeQuotesFromStream, +} from './quotes.js'; +import { subscribeOnStream, unsubscribeFromStream } from './streaming.js'; + +const lastBarsCache = new Map(); +const quotePriceCache = new Map(); +const quoteTickerState = new Map(); +const depthSubscriptions = new Map(); +const symbolPriceScaleCache = new Map(); +const INTRADAY_MULTIPLIERS = [ + '1', + '3', + '5', + '15', + '30', + '60', + '120', + '240', + '360', + '480', + '720', +]; +const BINANCE_WS_URL = 'wss://stream.binance.com:9443/ws'; +const DEPTH_LEVELS = 20; +const DEPTH_PUSH_INTERVAL_MS = 250; +const DEPTH_RECONNECT_DELAY_MS = 2_000; +const FILE_MARKER_URL = new URL('./assets/file.svg', import.meta.url).href; +const ALIEN_MARKER_URL = new URL('./assets/alien.svg', import.meta.url).href; + +let symbolsCachePromise = null; + +const configurationData = { + supports_timescale_marks: true, + supports_marks: true, + supports_time: true, + supported_resolutions: SUPPORTED_RESOLUTIONS, + exchanges: [ + { + value: BINANCE_EXCHANGE, + name: BINANCE_EXCHANGE, + desc: 'Binance spot market', + }, + ], + symbols_types: [{ name: 'crypto', value: 'crypto' }], +}; + +// Derives a TradingView pricescale from Binance tick size metadata. +function tickSizeToPriceScale(tickSize) { + if (!tickSize) return 100; + + const trimmed = tickSize.replace(/0+$/, ''); + if (!trimmed.includes('.')) return 1; + + return 10 ** trimmed.split('.')[1].length; +} + +// Loads and caches the Binance spot symbol catalog for search and resolve requests. +async function getAllSymbols() { + if (!symbolsCachePromise) { + symbolsCachePromise = (async () => { + const data = await makeApiRequest('api/v3/exchangeInfo'); + + return (data.symbols ?? []) + .filter( + symbol => + symbol.status === 'TRADING' && + symbol.isSpotTradingAllowed !== false + ) + .map(symbol => { + const generated = generateSymbol( + BINANCE_EXCHANGE, + symbol.baseAsset, + symbol.quoteAsset + ); + const priceFilter = symbol.filters?.find( + filter => filter.filterType === 'PRICE_FILTER' + ); + + return { + symbol: generated.short, + full_name: generated.full, + ticker: generated.full, + description: generated.short, + exchange: BINANCE_EXCHANGE, + type: 'crypto', + providerSymbol: symbol.symbol, + priceScale: tickSizeToPriceScale(priceFilter?.tickSize), + }; + }) + .sort((left, right) => left.ticker.localeCompare(right.ticker)); + })(); + } + + return symbolsCachePromise; +} + +// Finds a symbol regardless of whether the library passes short or full ticker text. +function getSymbolInfoItem(symbols, symbolName) { + const needle = symbolName.toLowerCase(); + + return symbols.find( + symbol => + symbol.ticker.toLowerCase() === needle || + symbol.full_name.toLowerCase() === needle || + symbol.symbol.toLowerCase() === needle + ); +} + +// Pages through Binance klines until the requested time window is covered. +async function fetchKlines(symbol, interval, fromMs, toMs) { + const intervalMs = intervalToMilliseconds(interval); + if (!intervalMs && interval !== '1M') { + throw new Error(`Unsupported Binance interval: ${interval}`); + } + + const results = []; + let cursor = fromMs; + let requestCount = 0; + const hardStop = 25; + + while (cursor < toMs && requestCount < hardStop) { + const batch = await makeApiRequest('api/v3/klines', { + symbol, + interval, + startTime: cursor, + endTime: toMs, + limit: 1000, + }); + + if (!Array.isArray(batch) || batch.length === 0) { + break; + } + + results.push(...batch); + + const lastOpenTime = batch[batch.length - 1][0]; + const nextCursor = + interval === '1M' + ? new Date(lastOpenTime).setUTCMonth( + new Date(lastOpenTime).getUTCMonth() + 1 + ) + : lastOpenTime + intervalMs; + + if (nextCursor <= cursor) { + break; + } + + cursor = nextCursor; + requestCount += 1; + + if (batch.length < 1000) { + break; + } + } + + const deduped = new Map(); + results.forEach(entry => { + deduped.set(entry[0], entry); + }); + + return [...deduped.values()].sort((left, right) => left[0] - right[0]); +} + +// Converts raw Binance kline arrays into TradingView bar objects. +function normalizeKlines(klines) { + return klines.map(entry => ({ + time: entry[0], + open: parseFloat(entry[1]), + high: parseFloat(entry[2]), + low: parseFloat(entry[3]), + close: parseFloat(entry[4]), + volume: parseFloat(entry[5]), + })); +} + +// Rebuilds higher custom resolutions from the raw interval bars we fetched. +function aggregateBars(rawBars, resolution) { + const aggregated = []; + + rawBars.forEach(bar => { + const bucketTime = barStartTime(bar.time, resolution); + const current = aggregated[aggregated.length - 1]; + + if (current && current.time === bucketTime) { + current.high = Math.max(current.high, bar.high); + current.low = Math.min(current.low, bar.low); + current.close = bar.close; + current.volume += bar.volume; + return; + } + + aggregated.push({ + time: bucketTime, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + }); + }); + + return aggregated; +} + +// Normalizes single-object and array responses into a consistent array shape. +function asArray(value) { + if (Array.isArray(value)) return value; + return value ? [value] : []; +} + +// Translates Binance day and hour ticker payloads into TradingView quote fields. +function buildQuoteFromState(symbol, state, fallbackQuote = null) { + const dayTicker = state?.dayTicker ?? null; + const hourTicker = state?.hourTicker ?? null; + + const price = dayTicker + ? parseFloat(dayTicker.lastPrice ?? dayTicker.c) + : (fallbackQuote?.price ?? 0); + const bid = dayTicker + ? parseFloat(dayTicker.bidPrice ?? dayTicker.b) + : (fallbackQuote?.bid ?? price); + const ask = dayTicker + ? parseFloat(dayTicker.askPrice ?? dayTicker.a) + : (fallbackQuote?.ask ?? price); + + return { + price, + lp: price, + ask, + bid, + spread: ask - bid, + open_price: dayTicker + ? parseFloat(dayTicker.openPrice ?? dayTicker.o) + : (fallbackQuote?.open_price ?? price), + high_price: dayTicker + ? parseFloat(dayTicker.highPrice ?? dayTicker.h) + : (fallbackQuote?.high_price ?? price), + low_price: dayTicker + ? parseFloat(dayTicker.lowPrice ?? dayTicker.l) + : (fallbackQuote?.low_price ?? price), + prev_close_price: dayTicker + ? parseFloat( + dayTicker.prevClosePrice ?? + dayTicker.x ?? + dayTicker.openPrice ?? + dayTicker.o + ) + : (fallbackQuote?.prev_close_price ?? price), + volume: dayTicker + ? parseFloat(dayTicker.volume ?? dayTicker.v) + : (fallbackQuote?.volume ?? 0), + ch: dayTicker + ? parseFloat(dayTicker.priceChange ?? dayTicker.p ?? 0) + : (fallbackQuote?.ch ?? 0), + chp: dayTicker + ? parseFloat(dayTicker.priceChangePercent ?? dayTicker.P ?? 0) + : (fallbackQuote?.chp ?? 0), + rtc: hourTicker + ? parseFloat(hourTicker.openPrice ?? hourTicker.o) + : (fallbackQuote?.rtc ?? price), + rtc_time: Math.floor( + (hourTicker?.closeTime ?? + hourTicker?.C ?? + dayTicker?.closeTime ?? + dayTicker?.C ?? + Date.now()) / 1000 + ), + rch: hourTicker + ? parseFloat(hourTicker.priceChange ?? hourTicker.p ?? 0) + : (fallbackQuote?.rch ?? 0), + rchp: hourTicker + ? parseFloat(hourTicker.priceChangePercent ?? hourTicker.P ?? 0) + : (fallbackQuote?.rchp ?? 0), + original_name: symbol, + short_name: symbol, + }; +} + +// Remembers symbol precision for the DOM fallback ladder before live depth arrives. +function rememberSymbolPriceScale(symbolItem) { + [symbolItem.ticker, symbolItem.full_name, symbolItem.symbol].forEach( + symbol => { + if (symbol) { + symbolPriceScaleCache.set(symbol, symbolItem.priceScale); + } + } + ); +} + +// Fetches the initial REST snapshots that seed quotes before websocket updates arrive. +async function fetchQuoteSnapshots(symbols) { + const parsedSymbols = symbols + .map(symbol => ({ symbol, parsed: parseFullSymbol(symbol) })) + .filter(entry => entry.parsed); + + if (parsedSymbols.length === 0) { + return []; + } + + const providerSymbols = parsedSymbols.map(entry => entry.parsed.symbol); + const symbolsParam = JSON.stringify(providerSymbols); + + const [dayData, hourData] = await Promise.all([ + makeApiRequest('api/v3/ticker/24hr', { symbols: symbolsParam }), + makeApiRequest('api/v3/ticker', { + symbols: symbolsParam, + windowSize: '1h', + }), + ]); + + const dayMap = new Map(asArray(dayData).map(item => [item.symbol, item])); + const hourMap = new Map(asArray(hourData).map(item => [item.symbol, item])); + + return parsedSymbols.map(({ symbol, parsed }) => { + const state = { + dayTicker: dayMap.get(parsed.symbol) ?? null, + hourTicker: hourMap.get(parsed.symbol) ?? null, + }; + + quoteTickerState.set(symbol, state); + + return { + symbol, + quote: buildQuoteFromState( + symbol, + state, + quotePriceCache.get(symbol) ?? null + ), + }; + }); +} + +export default { + // Publishes the datafeed capabilities TradingView uses during startup. + onReady(callback) { + setTimeout(() => callback(configurationData)); + }, + + // Returns search matches from the cached Binance spot symbol catalog. + async searchSymbols( + userInput, + exchange, + symbolType, + onResultReadyCallback + ) { + const symbols = await getAllSymbols(); + const query = userInput.trim().toLowerCase(); + + const filtered = symbols.filter(symbol => { + const matchesExchange = !exchange || symbol.exchange === exchange; + const matchesType = !symbolType || symbol.type === symbolType; + const matchesQuery = + !query || + symbol.ticker.toLowerCase().includes(query) || + symbol.symbol.toLowerCase().includes(query); + + return matchesExchange && matchesType && matchesQuery; + }); + + onResultReadyCallback(filtered.slice(0, 200)); + }, + + // Resolves a TradingView ticker into the symbol metadata needed to load a chart. + async resolveSymbol( + symbolName, + onSymbolResolvedCallback, + onResolveErrorCallback + ) { + try { + const symbols = await getAllSymbols(); + const symbolItem = getSymbolInfoItem(symbols, symbolName); + + if (!symbolItem) { + console.warn('[resolveSymbol] Cannot resolve:', symbolName); + onResolveErrorCallback('unknown_symbol'); + return; + } + + rememberSymbolPriceScale(symbolItem); + onSymbolResolvedCallback({ + ticker: symbolItem.ticker, + name: symbolItem.symbol, + description: symbolItem.description, + type: symbolItem.type, + exchange: symbolItem.exchange, + listed_exchange: symbolItem.exchange, + session: '24x7', + logo_urls: [], + timezone: 'Etc/UTC', + minmov: 1, + pricescale: symbolItem.priceScale, + format: 'price', + has_intraday: true, + intraday_multipliers: INTRADAY_MULTIPLIERS, + has_daily: true, + daily_multipliers: ['1', '3'], + has_weekly_and_monthly: true, + visible_plots_set: 'ohlcv', + supported_resolutions: configurationData.supported_resolutions, + data_status: 'streaming', + }); + } catch (error) { + console.error('[resolveSymbol] Error:', error); + onResolveErrorCallback('unknown_symbol'); + } + }, + + // Fetches historical bars and rebuilds custom resolutions when needed. + async getBars( + symbolInfo, + resolution, + periodParams, + onHistoryCallback, + onErrorCallback + ) { + const { from, to, firstDataRequest } = periodParams; + + const parsed = parseFullSymbol(symbolInfo.ticker); + if (!parsed) { + onErrorCallback('Cannot parse symbol ticker'); + return; + } + + const spec = getResolutionSpec(resolution); + if (!spec) { + onErrorCallback(`Unsupported resolution: ${resolution}`); + return; + } + + const fromMs = barStartTime(from * 1000, resolution); + const toMs = to * 1000; + + try { + const rawKlines = await fetchKlines( + parsed.symbol, + spec.interval, + fromMs, + toMs + ); + if (rawKlines.length === 0) { + onHistoryCallback([], { noData: true }); + return; + } + + const baseBars = normalizeKlines(rawKlines); + const resolvedBars = + spec.aggregate === 1 + ? baseBars + : aggregateBars(baseBars, resolution); + + const bars = resolvedBars.filter( + bar => bar.time >= from * 1000 && bar.time < to * 1000 + ); + + if (bars.length === 0) { + onHistoryCallback([], { noData: true }); + return; + } + + if (firstDataRequest) { + lastBarsCache.set(symbolInfo.ticker, { + ...bars[bars.length - 1], + }); + } + + onHistoryCallback(bars, { noData: false }); + } catch (error) { + console.error('[getBars] Error:', error); + onErrorCallback(error); + } + }, + + // Starts the realtime stream for the active chart symbol and resolution. + subscribeBars( + symbolInfo, + resolution, + onRealtimeCallback, + subscriberUID, + onResetCacheNeededCallback + ) { + subscribeOnStream( + symbolInfo, + resolution, + onRealtimeCallback, + subscriberUID, + onResetCacheNeededCallback, + lastBarsCache.get(symbolInfo.ticker) ?? null + ); + }, + + // Stops the realtime bar stream when TradingView releases a subscriber. + unsubscribeBars(subscriberUID) { + unsubscribeFromStream(subscriberUID); + }, + + // Supplies example chart markers for the tutorial overlay APIs. + getMarks(symbolInfo, from, to, onDataCallback, _resolution) { + const time = Date.now() / 1000; + const ONE_DAY_SEC = 86_400; + + onDataCallback([ + { + id: 1, + time: to, + borderWidth: 0, + text: [ + 'wallet address, 1m within, buy txs:1, buy total: 123123, avr price: 123123', + ], + imageUrl: FILE_MARKER_URL, + }, + { + id: 2, + time: time - ONE_DAY_SEC * 5, + color: 'green', + label: 'S', + labelFontColor: 'green', + minSize: 10, + text: ['Second marker'], + }, + { + id: 3, + time: time - ONE_DAY_SEC * 4, + color: 'blue', + label: 'T', + labelFontColor: 'blue', + minSize: 9, + text: ['Third marker'], + }, + { + id: 4, + time: time - ONE_DAY_SEC, + color: 'purple', + label: 'F', + labelFontColor: 'purple', + minSize: 20, + text: ['Fourth marker'], + }, + { + id: 5, + time: time - ONE_DAY_SEC * 2, + color: 'orange', + label: 'O', + labelFontColor: 'orange', + minSize: 21, + text: ['Fifth marker'], + }, + ]); + }, + + // Supplies example timescale markers for the tutorial overlay APIs. + getTimescaleMarks(symbolInfo, from, to, onDataCallback, _resolution) { + const now = Date.now() / 1000; + const ONE_DAY_SEC = 86_400; + + function fmt(sec) { + const d = new Date(sec * 1000); + const dd = String(d.getUTCDate()).padStart(2, '0'); + const mm = String(d.getUTCMonth() + 1).padStart(2, '0'); + const yy = String(d.getUTCFullYear()).slice(-2); + return `${dd}/${mm}/${yy}`; + } + + onDataCallback( + Array.from({ length: 15 }, (_, i) => { + const idx = i + 1; + const t = now - ONE_DAY_SEC * idx; + return { + id: `tsm${idx}`, + time: t, + color: idx % 2 === 0 ? '#FFAA00' : '#089981', + label: idx === 1 ? 'A' : 'B', + labelFontColor: '#FFFFFF', + imageUrl: ALIEN_MARKER_URL, + tooltip: [ + fmt(t), + '**Bitcoin logo**', + '_Note_: Short-term volatility', + 'Source: Exchange data', + ], + }; + }) + ); + }, + + // Returns a current quote snapshot for the requested symbol list. + async getQuotes(symbols, onDataCallback, onErrorCallback) { + try { + const snapshots = await fetchQuoteSnapshots(symbols); + + const result = snapshots + .map(({ symbol, quote }) => { + if (!quote) return null; + + quotePriceCache.set(symbol, quote); + return { n: symbol, s: 'ok', v: quote }; + }) + .filter(Boolean); + + setTimeout(() => onDataCallback(result), 10); + } catch (error) { + console.error('[getQuotes] Error:', error); + if (onErrorCallback) onErrorCallback(error); + } + }, + + // Seeds quotes from REST once and then keeps them live with websocket ticker streams. + subscribeQuotes(symbols, fastSymbols, onRealtimeCallback, listenerGUID) { + const trackedSymbols = [...new Set([...symbols, ...fastSymbols])]; + fetchQuoteSnapshots(trackedSymbols) + .then(snapshots => { + const initialQuotes = snapshots + .map(({ symbol, quote }) => { + if (!quote) return null; + + quotePriceCache.set(symbol, quote); + return { s: 'ok', n: symbol, v: quote }; + }) + .filter(Boolean); + + if (initialQuotes.length > 0) { + onRealtimeCallback(initialQuotes); + } + }) + .catch(error => { + console.error('[subscribeQuotes] Bootstrap error:', error); + }); + + subscribeQuotesOnStream( + trackedSymbols, + listenerGUID, + ({ symbol, type, payload }) => { + const currentState = quoteTickerState.get(symbol) ?? { + dayTicker: null, + hourTicker: null, + }; + + if (type === 'day') { + currentState.dayTicker = payload; + } else if (type === 'hour') { + currentState.hourTicker = payload; + } + + quoteTickerState.set(symbol, currentState); + + const quote = buildQuoteFromState( + symbol, + currentState, + quotePriceCache.get(symbol) ?? null + ); + quotePriceCache.set(symbol, quote); + onRealtimeCallback([{ s: 'ok', n: symbol, v: quote }]); + } + ); + }, + + // Tears down quote listeners that are no longer needed by the widget. + unsubscribeQuotes(listenerGUID) { + unsubscribeQuotesFromStream(listenerGUID); + }, + + // Supplies live Binance top-of-book depth for the Trading Platform example. + subscribeDepth(symbol, callback) { + const listenerId = Math.round(Math.random() * 10000000).toString(36); + const subscription = { + symbol, + callback, + socket: null, + reconnectTimerId: null, + latestDepth: null, + closed: false, + intervalId: null, + }; + + subscription.intervalId = window.setInterval(() => { + pushDepthSnapshot(subscription); + }, DEPTH_PUSH_INTERVAL_MS); + + depthSubscriptions.set(listenerId, subscription); + pushDepthSnapshot(subscription); + connectDepthStream(subscription); + + return listenerId; + }, + + // Stops one Trading Platform DOM subscription and releases its socket/timer. + unsubscribeDepth(listenerID) { + const subscription = depthSubscriptions.get(listenerID); + if (!subscription) return; + + subscription.closed = true; + window.clearInterval(subscription.intervalId); + window.clearTimeout(subscription.reconnectTimerId); + subscription.socket?.close(); + depthSubscriptions.delete(listenerID); + }, +}; + +// Opens Binance's partial-book stream for one DOM subscriber and reconnects if it drops. +function connectDepthStream(subscription) { + const parsed = parseFullSymbol(subscription.symbol); + if (!parsed || parsed.exchange !== BINANCE_EXCHANGE) return; + + const streamName = `${parsed.symbol.toLowerCase()}@depth${DEPTH_LEVELS}@100ms`; + const socket = new WebSocket(`${BINANCE_WS_URL}/${streamName}`); + subscription.socket = socket; + + socket.addEventListener('message', event => { + const depth = parseDepthMessage(event.data); + if (!depth) return; + + subscription.latestDepth = depth; + rememberDepthQuote(subscription.symbol, depth); + }); + + socket.addEventListener('close', () => { + if (subscription.closed) return; + + subscription.reconnectTimerId = window.setTimeout(() => { + connectDepthStream(subscription); + }, DEPTH_RECONNECT_DELAY_MS); + }); + + socket.addEventListener('error', () => { + socket.close(); + }); +} + +// Parses Binance partial-book messages into TradingView DOM level arrays. +function parseDepthMessage(data) { + let message; + + try { + message = JSON.parse(data); + } catch { + return null; + } + + const bids = normalizeDepthLevels(message.bids ?? message.b); + const asks = normalizeDepthLevels(message.asks ?? message.a); + if (bids.length === 0 || asks.length === 0) return null; + + return { bids, asks }; +} + +// Converts Binance [price, quantity] tuples into the DOM shape TradingView expects. +function normalizeDepthLevels(levels) { + if (!Array.isArray(levels)) return []; + + return levels + .map(level => { + const price = parseFloat(level[0]); + const volume = parseFloat(level[1]); + + if ( + !Number.isFinite(price) || + !Number.isFinite(volume) || + volume <= 0 + ) { + return null; + } + + return { price, volume }; + }) + .filter(Boolean) + .slice(0, DEPTH_LEVELS); +} + +// Lets quote consumers reuse the best bid/ask learned from the DOM stream. +function rememberDepthQuote(symbol, depth) { + const bid = depth.bids[0]?.price; + const ask = depth.asks[0]?.price; + if (!Number.isFinite(bid) || !Number.isFinite(ask)) return; + + const price = (bid + ask) / 2; + quotePriceCache.set(symbol, { + ...quotePriceCache.get(symbol), + price, + lp: price, + bid, + ask, + spread: ask - bid, + }); +} + +// Pushes either live depth or a temporary synthetic ladder until live depth arrives. +function pushDepthSnapshot(subscription) { + if (subscription.closed) return; + + const snapshot = subscription.latestDepth + ? { snapshot: true, ...subscription.latestDepth } + : buildSyntheticDepthSnapshot(subscription.symbol); + + subscription.callback(snapshot); +} + +// Builds a visible fallback so the DOM widget is not blank before Binance sends data. +function buildSyntheticDepthSnapshot(symbol) { + const latestPrice = getLatestDepthPrice(symbol); + const tickSize = getDepthTickSize(symbol, latestPrice); + + return { + snapshot: true, + bids: generateSyntheticDOMData( + latestPrice - tickSize, + -tickSize, + tickSize + ), + asks: generateSyntheticDOMData( + latestPrice + tickSize, + tickSize, + tickSize + ), + }; +} + +// Chooses the best available anchor price for synthetic DOM fallback levels. +function getLatestDepthPrice(symbol) { + return ( + quotePriceCache.get(symbol)?.price ?? + lastBarsCache.get(symbol)?.close ?? + 100 + ); +} + +// Reuses resolved symbol precision where possible so fallback levels move by valid ticks. +function getDepthTickSize(symbol, latestPrice) { + const priceScale = symbolPriceScaleCache.get(symbol); + if (priceScale) return 1 / priceScale; + + if (latestPrice >= 1000) return 0.1; + if (latestPrice >= 100) return 0.01; + if (latestPrice >= 1) return 0.001; + return 0.0001; +} + +// Generates descending bid or ascending ask levels around the latest known price. +function generateSyntheticDOMData(start, step, tickSize) { + const levels = []; + const amount = 10_000; + + for (let index = 0; index < DEPTH_LEVELS; index += 1) { + const price = start + step * index; + const distanceWeight = (DEPTH_LEVELS - index) / DEPTH_LEVELS; + const jitter = 0.9 + Math.random() * 0.2; + + levels.push({ + price: roundToTick(price, tickSize), + volume: amount * distanceWeight * jitter, + }); + } + + return levels; +} + +// Rounds fallback prices to the inferred tick precision. +function roundToTick(price, tickSize) { + const decimals = Math.max(0, Math.ceil(-Math.log10(tickSize))); + + return Number(price.toFixed(decimals)); +} diff --git a/src/datafeed/helpers.js b/src/datafeed/helpers.js new file mode 100644 index 0000000..70be63d --- /dev/null +++ b/src/datafeed/helpers.js @@ -0,0 +1,276 @@ +// Shared helpers for the Binance-backed TradingView datafeed. + +export const BINANCE_EXCHANGE = 'Binance'; + +const BINANCE_API_URL = 'https://api.binance.com/'; +const DAY_MS = 24 * 60 * 60 * 1000; + +export const SUPPORTED_RESOLUTIONS = [ + '1', + '2', + '3', + '4', + '5', + '10', + '15', + '30', + '60', + '90', + '120', + '180', + '240', + '360', + '480', + '720', + '1D', + '3D', + '1W', + '1M', +]; + +const BINANCE_INTERVAL_MS = Object.freeze({ + '1m': 60 * 1000, + '3m': 3 * 60 * 1000, + '5m': 5 * 60 * 1000, + '15m': 15 * 60 * 1000, + '30m': 30 * 60 * 1000, + '1h': 60 * 60 * 1000, + '2h': 2 * 60 * 60 * 1000, + '4h': 4 * 60 * 60 * 1000, + '6h': 6 * 60 * 60 * 1000, + '8h': 8 * 60 * 60 * 1000, + '12h': 12 * 60 * 60 * 1000, + '1d': DAY_MS, + '3d': 3 * DAY_MS, + '1w': 7 * DAY_MS, +}); + +const RESOLUTION_SPECS = Object.freeze({ + 1: { + interval: '1m', + aggregate: 1, + streamType: 'kline', + streamInterval: '1m', + }, + 2: { interval: '1m', aggregate: 2, streamType: 'trade' }, + 3: { + interval: '3m', + aggregate: 1, + streamType: 'kline', + streamInterval: '3m', + }, + 4: { interval: '1m', aggregate: 4, streamType: 'trade' }, + 5: { + interval: '5m', + aggregate: 1, + streamType: 'kline', + streamInterval: '5m', + }, + 10: { interval: '5m', aggregate: 2, streamType: 'trade' }, + 15: { + interval: '15m', + aggregate: 1, + streamType: 'kline', + streamInterval: '15m', + }, + 30: { + interval: '30m', + aggregate: 1, + streamType: 'kline', + streamInterval: '30m', + }, + 60: { + interval: '1h', + aggregate: 1, + streamType: 'kline', + streamInterval: '1h', + }, + 90: { interval: '30m', aggregate: 3, streamType: 'trade' }, + 120: { + interval: '2h', + aggregate: 1, + streamType: 'kline', + streamInterval: '2h', + }, + 180: { interval: '1h', aggregate: 3, streamType: 'trade' }, + 240: { + interval: '4h', + aggregate: 1, + streamType: 'kline', + streamInterval: '4h', + }, + 360: { + interval: '6h', + aggregate: 1, + streamType: 'kline', + streamInterval: '6h', + }, + 480: { + interval: '8h', + aggregate: 1, + streamType: 'kline', + streamInterval: '8h', + }, + 720: { + interval: '12h', + aggregate: 1, + streamType: 'kline', + streamInterval: '12h', + }, + '1D': { + interval: '1d', + aggregate: 1, + streamType: 'kline', + streamInterval: '1d', + }, + '3D': { + interval: '3d', + aggregate: 1, + streamType: 'kline', + streamInterval: '3d', + }, + '1W': { + interval: '1w', + aggregate: 1, + streamType: 'kline', + streamInterval: '1w', + }, + '1M': { + interval: '1M', + aggregate: 1, + streamType: 'kline', + streamInterval: '1M', + }, +}); + +// Sends a REST request to Binance and normalizes transport errors. +export async function makeApiRequest(path, params = {}) { + try { + const url = new URL(path, BINANCE_API_URL); + + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + }); + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + return response.json(); + } catch (error) { + throw new Error(`Binance request error: ${error.message}`); + } +} + +// Splits a TradingView ticker into exchange, base, quote, and provider symbol parts. +export function parseFullSymbol(fullSymbol) { + const match = fullSymbol.match(/^([^:]+):([^/]+)\/([^/]+)$/); + if (!match) return null; + + const exchange = match[1]; + const fromSymbol = match[2].toUpperCase(); + const toSymbol = match[3].toUpperCase(); + + return { + exchange, + fromSymbol, + toSymbol, + symbol: `${fromSymbol}${toSymbol}`, + }; +} + +// Builds the symbol shapes used by TradingView search and resolve flows. +export function generateSymbol(exchange, fromSymbol, toSymbol) { + const base = fromSymbol.toUpperCase(); + const quote = toSymbol.toUpperCase(); + const short = `${base}/${quote}`; + + return { + short, + full: `${exchange}:${short}`, + symbol: `${base}${quote}`, + }; +} + +// Maps a TradingView resolution to the Binance interval and stream strategy behind it. +export function getResolutionSpec(resolution) { + return RESOLUTION_SPECS[resolution] ?? null; +} + +// Converts a native Binance interval into milliseconds when the duration is fixed. +export function intervalToMilliseconds(interval) { + return BINANCE_INTERVAL_MS[interval] ?? null; +} + +// Converts a TradingView resolution into milliseconds when the duration is fixed. +export function resolutionToMilliseconds(resolution) { + if (resolution === '1D') return DAY_MS; + if (resolution === '3D') return 3 * DAY_MS; + if (resolution === '1W') return 7 * DAY_MS; + if (resolution === '1M') return null; + + const minutes = parseInt(resolution, 10); + if (!Number.isNaN(minutes)) { + return minutes * 60 * 1000; + } + + const hourMatch = resolution.match(/^(\d+)H$/i); + if (hourMatch) { + return parseInt(hourMatch[1], 10) * 60 * 60 * 1000; + } + + return null; +} + +// Rounds a timestamp down to the opening time of its containing bar. +export function barStartTime(timestampMs, resolution) { + if (resolution === '1D') { + const d = new Date(timestampMs); + d.setUTCHours(0, 0, 0, 0); + return d.getTime(); + } + + if (resolution === '3D') { + const d = new Date(timestampMs); + d.setUTCHours(0, 0, 0, 0); + return Math.floor(d.getTime() / (3 * DAY_MS)) * (3 * DAY_MS); + } + + if (resolution === '1W') { + const d = new Date(timestampMs); + const day = d.getUTCDay(); + const mondayOffset = day === 0 ? 6 : day - 1; + d.setUTCDate(d.getUTCDate() - mondayOffset); + d.setUTCHours(0, 0, 0, 0); + return d.getTime(); + } + + if (resolution === '1M') { + const d = new Date(timestampMs); + d.setUTCDate(1); + d.setUTCHours(0, 0, 0, 0); + return d.getTime(); + } + + const intervalMs = resolutionToMilliseconds(resolution); + if (!intervalMs) return timestampMs; + + return Math.floor(timestampMs / intervalMs) * intervalMs; +} + +// Advances a bar timestamp to the next bar boundary for the same resolution. +export function getNextBarTime(barTimeMs, resolution) { + if (resolution === '1M') { + const d = new Date(barTimeMs); + d.setUTCMonth(d.getUTCMonth() + 1); + return d.getTime(); + } + + const intervalMs = resolutionToMilliseconds(resolution); + if (!intervalMs) return barTimeMs; + + return barTimeMs + intervalMs; +} diff --git a/src/datafeed/quotes.js b/src/datafeed/quotes.js new file mode 100644 index 0000000..7bca408 --- /dev/null +++ b/src/datafeed/quotes.js @@ -0,0 +1,286 @@ +import { BINANCE_EXCHANGE, parseFullSymbol } from './helpers.js'; + +const WSS_URL = 'wss://stream.binance.com:9443/ws'; +const MAX_RECONNECT_DELAY = 30_000; +const SOCKET_CONNECT_DELAY_MS = 100; + +const streamRefCounts = new Map(); +const streamToMeta = new Map(); +const providerSymbolToFullSymbol = new Map(); +const symbolToListeners = new Map(); +const listenerToSymbols = new Map(); +const listenerCallbacks = new Map(); + +let socket = null; +let reconnectDelay = 1_000; +let reconnectTimer = null; +let connectTimer = null; +let requestId = 0; + +// Generates monotonically increasing ids for Binance websocket control messages. +function nextRequestId() { + requestId += 1; + return requestId; +} + +// Builds the 24h rolling ticker stream name for a Binance spot symbol. +function getTickerStreamName(providerSymbol) { + return `${providerSymbol.toLowerCase()}@ticker`; +} + +// Builds the 1h rolling ticker stream name for a Binance spot symbol. +function getHourTickerStreamName(providerSymbol) { + return `${providerSymbol.toLowerCase()}@ticker_1h`; +} + +// Sends a websocket subscribe command when the socket is ready. +function sendSubscribe(ws, streamName) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + ws.send( + JSON.stringify({ + method: 'SUBSCRIBE', + params: [streamName], + id: nextRequestId(), + }) + ); +} + +// Sends a websocket unsubscribe command when the last listener goes away. +function sendUnsubscribe(ws, streamName) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + ws.send( + JSON.stringify({ + method: 'UNSUBSCRIBE', + params: [streamName], + id: nextRequestId(), + }) + ); +} + +// Checks whether any TradingView listener still needs Binance quote updates. +function hasActiveStreams() { + return [...streamRefCounts.values()].some(count => count > 0); +} + +// Defers opening Binance until TradingView's initial quote-subscription churn settles. +function ensureSocket() { + if ( + socket && + (socket.readyState === WebSocket.CONNECTING || + socket.readyState === WebSocket.OPEN) + ) { + return socket; + } + + if (!connectTimer) { + connectTimer = setTimeout(() => { + connectTimer = null; + + if (hasActiveStreams()) { + socket = createSocket(); + } + }, SOCKET_CONNECT_DELAY_MS); + } + + return socket; +} + +// Cancels pending reconnect/start work when TradingView releases every quote stream. +// Already-open sockets are left alone; if Binance closes them while idle, we do not reconnect. +function stopSocketWorkIfIdle() { + if (hasActiveStreams()) return; + + if (connectTimer) { + clearTimeout(connectTimer); + connectTimer = null; + } + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + reconnectDelay = 1_000; +} + +// Creates the shared quote socket and restores active stream subscriptions after reconnects. +function createSocket() { + if (connectTimer) { + clearTimeout(connectTimer); + connectTimer = null; + } + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + const ws = new WebSocket(WSS_URL); + + ws.addEventListener('open', () => { + reconnectDelay = 1_000; + + streamRefCounts.forEach((count, streamName) => { + if (count > 0) { + sendSubscribe(ws, streamName); + } + }); + }); + + ws.addEventListener('close', () => { + if (socket === ws) { + socket = null; + } + + if (!hasActiveStreams()) return; + + reconnectTimer = setTimeout(() => { + reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); + socket = createSocket(); + }, reconnectDelay); + }); + + ws.addEventListener('error', () => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }); + + ws.addEventListener('message', onMessage); + return ws; +} + +// Increments the reference count for a quote stream shared across listeners. +function addStreamRef(fullSymbol, providerSymbol, streamName, type) { + const count = streamRefCounts.get(streamName) ?? 0; + streamRefCounts.set(streamName, count + 1); + streamToMeta.set(streamName, { fullSymbol, providerSymbol, type }); + providerSymbolToFullSymbol.set(providerSymbol, fullSymbol); + + if (count === 0) { + sendSubscribe(ensureSocket(), streamName); + } +} + +// Decrements the reference count and unsubscribes once a stream is no longer shared. +function removeStreamRef(streamName) { + const count = streamRefCounts.get(streamName); + if (!count) return; + + if (count > 1) { + streamRefCounts.set(streamName, count - 1); + return; + } + + streamRefCounts.delete(streamName); + const meta = streamToMeta.get(streamName); + streamToMeta.delete(streamName); + sendUnsubscribe(socket, streamName); + stopSocketWorkIfIdle(); + + if (!meta) return; + + const stillReferenced = [...streamToMeta.values()].some( + item => item.providerSymbol === meta.providerSymbol + ); + + if (!stillReferenced) { + providerSymbolToFullSymbol.delete(meta.providerSymbol); + } +} + +// Routes Binance quote events to the listeners registered for that symbol. +function onMessage(event) { + let message; + + try { + message = JSON.parse(event.data); + } catch { + return; + } + + if (message.result === null || message.id !== undefined) { + return; + } + + const fullSymbol = providerSymbolToFullSymbol.get(message.s); + if (!fullSymbol) return; + + const listeners = symbolToListeners.get(fullSymbol); + if (!listeners || listeners.size === 0) return; + + const type = + message.e === '24hrTicker' + ? 'day' + : message.e === '1hTicker' + ? 'hour' + : null; + if (!type) return; + + listeners.forEach(listenerGUID => { + const callback = listenerCallbacks.get(listenerGUID); + callback?.({ symbol: fullSymbol, type, payload: message }); + }); +} + +// Subscribes quote listeners to shared Binance day and hour ticker streams. +export function subscribeQuotesOnStream(symbols, listenerGUID, onEvent) { + listenerCallbacks.set(listenerGUID, onEvent); + + const trackedSymbols = new Set(); + + symbols.forEach(fullSymbol => { + const parsed = parseFullSymbol(fullSymbol); + if (!parsed || parsed.exchange !== BINANCE_EXCHANGE) return; + + trackedSymbols.add(fullSymbol); + + if (!symbolToListeners.has(fullSymbol)) { + symbolToListeners.set(fullSymbol, new Set()); + } + symbolToListeners.get(fullSymbol).add(listenerGUID); + + addStreamRef( + fullSymbol, + parsed.symbol, + getTickerStreamName(parsed.symbol), + 'day' + ); + addStreamRef( + fullSymbol, + parsed.symbol, + getHourTickerStreamName(parsed.symbol), + 'hour' + ); + }); + + listenerToSymbols.set(listenerGUID, trackedSymbols); + stopSocketWorkIfIdle(); +} + +// Removes quote listeners and releases any streams they no longer need. +export function unsubscribeQuotesFromStream(listenerGUID) { + const trackedSymbols = listenerToSymbols.get(listenerGUID); + listenerCallbacks.delete(listenerGUID); + listenerToSymbols.delete(listenerGUID); + + if (!trackedSymbols) return; + + trackedSymbols.forEach(fullSymbol => { + const parsed = parseFullSymbol(fullSymbol); + if (!parsed) return; + + const listeners = symbolToListeners.get(fullSymbol); + if (listeners) { + listeners.delete(listenerGUID); + if (listeners.size === 0) { + symbolToListeners.delete(fullSymbol); + } + } + + removeStreamRef(getTickerStreamName(parsed.symbol)); + removeStreamRef(getHourTickerStreamName(parsed.symbol)); + }); +} diff --git a/src/datafeed/streaming.js b/src/datafeed/streaming.js new file mode 100644 index 0000000..92df788 --- /dev/null +++ b/src/datafeed/streaming.js @@ -0,0 +1,361 @@ +import { + barStartTime, + getNextBarTime, + getResolutionSpec, + parseFullSymbol, +} from './helpers.js'; + +const UPDATE_FREQUENCY = 250; +const WSS_URL = 'wss://stream.binance.com:9443/ws'; +const MAX_RECONNECT_DELAY = 30_000; +const SOCKET_CONNECT_DELAY_MS = 100; + +const streamToSubscription = new Map(); +const subscriberToStream = new Map(); + +let socket = null; +let reconnectDelay = 1_000; +let reconnectTimer = null; +let connectTimer = null; +let hasConnectedBefore = false; +let requestId = 0; + +// Generates monotonically increasing ids for Binance websocket control messages. +function nextRequestId() { + requestId += 1; + return requestId; +} + +// Checks whether any chart pane currently needs realtime bar updates. +function hasActiveStreams() { + return streamToSubscription.size > 0; +} + +// Defers the first socket open until the chart's initial subscribe/unsubscribe churn settles. +function ensureSocket() { + if ( + socket && + (socket.readyState === WebSocket.CONNECTING || + socket.readyState === WebSocket.OPEN) + ) { + return socket; + } + + if (!connectTimer) { + connectTimer = setTimeout(() => { + connectTimer = null; + + if (hasActiveStreams()) { + socket = createSocket(); + } + }, SOCKET_CONNECT_DELAY_MS); + } + + return socket; +} + +// Stops pending connection work when every bar subscriber has gone away. +function stopSocketWorkIfIdle() { + if (hasActiveStreams()) return; + + if (connectTimer) { + clearTimeout(connectTimer); + connectTimer = null; + } + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + reconnectDelay = 1_000; +} + +// Creates the shared Binance websocket and restores subscriptions after reconnects. +function createSocket() { + if (connectTimer) { + clearTimeout(connectTimer); + connectTimer = null; + } + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + const ws = new WebSocket(WSS_URL); + + ws.addEventListener('open', () => { + if (hasConnectedBefore) { + streamToSubscription.forEach(item => { + item.handlers.forEach(handler => { + handler.onResetCacheNeededCallback?.(); + }); + }); + } + + hasConnectedBefore = true; + reconnectDelay = 1_000; + + streamToSubscription.forEach((_, streamName) => { + sendSubscribe(ws, streamName); + }); + }); + + ws.addEventListener('close', () => { + if (socket === ws) { + socket = null; + } + + if (!hasActiveStreams()) return; + + reconnectTimer = setTimeout(() => { + reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY); + socket = createSocket(); + }, reconnectDelay); + }); + + ws.addEventListener('error', () => { + if (ws.readyState === WebSocket.OPEN) { + ws.close(); + } + }); + + ws.addEventListener('message', onMessage); + + return ws; +} + +// Sends a websocket subscribe command when the socket is ready. +function sendSubscribe(ws, streamName) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + ws.send( + JSON.stringify({ + method: 'SUBSCRIBE', + params: [streamName], + id: nextRequestId(), + }) + ); +} + +// Sends a websocket unsubscribe command when the last listener goes away. +function sendUnsubscribe(ws, streamName) { + if (!ws || ws.readyState !== WebSocket.OPEN) return; + + ws.send( + JSON.stringify({ + method: 'UNSUBSCRIBE', + params: [streamName], + id: nextRequestId(), + }) + ); +} + +// Builds the Binance trade stream name for resolutions we assemble from ticks. +function getTradeStreamName(providerSymbol) { + return `${providerSymbol.toLowerCase()}@trade`; +} + +// Builds the Binance kline stream name for resolutions Binance publishes directly. +function getKlineStreamName(providerSymbol, interval) { + return `${providerSymbol.toLowerCase()}@kline_${interval}`; +} + +// Chooses the websocket stream type that should drive a given resolution. +function getStreamName(providerSymbol, resolution) { + const spec = getResolutionSpec(resolution); + if (!spec) return null; + + if (spec.streamType === 'trade') { + return getTradeStreamName(providerSymbol); + } + + return getKlineStreamName(providerSymbol, spec.streamInterval); +} + +// Updates an in-flight bar using raw trade ticks for rebuilt resolutions. +function updateHandlerFromTrade(handler, message) { + const price = parseFloat(message.p); + const quantity = parseFloat(message.q); + const tradeTimeMs = message.T; + const lastBar = handler.lastBar; + const currentBarStart = barStartTime(tradeTimeMs, handler.resolution); + + if (!lastBar || currentBarStart > lastBar.time) { + handler.lastBar = { + time: currentBarStart, + open: price, + high: price, + low: price, + close: price, + volume: quantity, + }; + handler.isDirty = true; + return; + } + + if (tradeTimeMs >= getNextBarTime(lastBar.time, handler.resolution)) { + handler.lastBar = { + time: currentBarStart, + open: price, + high: price, + low: price, + close: price, + volume: quantity, + }; + handler.isDirty = true; + return; + } + + handler.lastBar = { + ...lastBar, + high: Math.max(lastBar.high, price), + low: Math.min(lastBar.low, price), + close: price, + volume: (lastBar.volume || 0) + quantity, + }; + handler.isDirty = true; +} + +// Replaces subscriber bars with Binance's server-built kline payloads. +function updateHandlersFromKline(subscription, message) { + const kline = message.k; + const bar = { + time: kline.t, + open: parseFloat(kline.o), + high: parseFloat(kline.h), + low: parseFloat(kline.l), + close: parseFloat(kline.c), + volume: parseFloat(kline.v), + }; + + subscription.handlers.forEach(handler => { + handler.lastBar = bar; + handler.isDirty = true; + }); +} + +// Routes incoming websocket events to the matching bar subscribers. +function onMessage(event) { + let message; + + try { + message = JSON.parse(event.data); + } catch { + return; + } + + if (message.result === null || message.id !== undefined) { + return; + } + + if (message.e === 'trade') { + const streamName = getTradeStreamName(message.s); + const subscription = streamToSubscription.get(streamName); + if (!subscription) return; + + subscription.handlers.forEach(handler => { + updateHandlerFromTrade(handler, message); + }); + return; + } + + if (message.e === 'kline' && message.k?.s && message.k?.i) { + const streamName = getKlineStreamName(message.k.s, message.k.i); + const subscription = streamToSubscription.get(streamName); + if (!subscription) return; + + updateHandlersFromKline(subscription, message); + } +} + +// Batches websocket bursts before notifying TradingView, reducing chart redraw pressure. +setInterval(() => { + streamToSubscription.forEach(subscription => { + subscription.handlers.forEach(handler => { + if (!handler.isDirty || !handler.lastBar) return; + + handler.callback(handler.lastBar); + handler.isDirty = false; + }); + }); +}, UPDATE_FREQUENCY); + +// Subscribes a chart listener to the Binance stream that powers its resolution. +export function subscribeOnStream( + symbolInfo, + resolution, + onRealtimeCallback, + subscriberUID, + onResetCacheNeededCallback, + lastBar +) { + if (!symbolInfo?.ticker) { + console.error('[subscribeBars] Invalid symbolInfo:', symbolInfo); + return; + } + + const parsed = parseFullSymbol(symbolInfo.ticker); + if (!parsed) { + console.error( + '[subscribeBars] Cannot parse ticker:', + symbolInfo.ticker + ); + return; + } + + const streamName = getStreamName(parsed.symbol, resolution); + if (!streamName) { + console.error('[subscribeBars] Unsupported resolution:', resolution); + return; + } + + const handler = { + id: subscriberUID, + callback: onRealtimeCallback, + onResetCacheNeededCallback, + resolution, + lastBar: lastBar ?? null, + isDirty: false, + }; + + const existing = streamToSubscription.get(streamName); + if (existing) { + existing.handlers.push(handler); + subscriberToStream.set(subscriberUID, streamName); + return; + } + + streamToSubscription.set(streamName, { + streamName, + handlers: [handler], + }); + subscriberToStream.set(subscriberUID, streamName); + + sendSubscribe(ensureSocket(), streamName); +} + +// Removes a chart subscriber and closes the stream when nobody is left on it. +export function unsubscribeFromStream(subscriberUID) { + const streamName = subscriberToStream.get(subscriberUID); + if (!streamName) return; + + const subscription = streamToSubscription.get(streamName); + if (!subscription) { + subscriberToStream.delete(subscriberUID); + return; + } + + subscription.handlers = subscription.handlers.filter( + handler => handler.id !== subscriberUID + ); + subscriberToStream.delete(subscriberUID); + + if (subscription.handlers.length === 0) { + sendUnsubscribe(socket, streamName); + streamToSubscription.delete(streamName); + stopSocketWorkIfIdle(); + return; + } +} diff --git a/src/helpers.js b/src/helpers.js deleted file mode 100644 index d6520ba..0000000 --- a/src/helpers.js +++ /dev/null @@ -1,37 +0,0 @@ -// Get a CryptoCompare API key CryptoCompare https://www.cryptocompare.com/coins/guides/how-to-use-our-api/ -export const apiKey = -""; -// Makes requests to CryptoCompare API -export async function makeApiRequest(path) { - try { - const url = new URL(`https://min-api.cryptocompare.com/${path}`); - url.searchParams.append('api_key',apiKey) - const response = await fetch(url.toString()); - return response.json(); - } catch (error) { - throw new Error(`CryptoCompare request error: ${error.status}`); - } -} - -// Generates a symbol ID from a pair of the coins -export function generateSymbol(exchange, fromSymbol, toSymbol) { - const short = `${fromSymbol}/${toSymbol}`; - return { - short, - full: `${exchange}:${short}`, - }; -} - -// Returns all parts of the symbol -export function parseFullSymbol(fullSymbol) { - const match = fullSymbol.match(/^(\w+):(\w+)\/(\w+)$/); - if (!match) { - return null; - } - - return { - exchange: match[1], - fromSymbol: match[2], - toSymbol: match[3], - }; -} diff --git a/src/main.js b/src/main.js index d0cdff5..eddb08e 100644 --- a/src/main.js +++ b/src/main.js @@ -1,24 +1,11 @@ -// Datafeed implementation -import Datafeed from './datafeed.js'; +import { widget as createWidget } from '/vendor/tradingview/advanced_charts/charting_library.esm.js'; +import { createAdvancedChartOptions } from './widget-options.js'; +import { installThemeToolbar } from './toolbar.js'; -window.tvWidget = new TradingView.widget({ - symbol: 'Bitfinex:BTC/USD', // Default symbol - interval: '1D', // Default interval - fullscreen: true, // Displays the chart in the fullscreen mode - container: 'tv_chart_container', // Reference to an attribute of the DOM element - datafeed: Datafeed, - library_path: '../charting_library_cloned_data/charting_library/', -}); +// Boots the minimal tutorial chart: shared widget options plus the theme toggle. +function initChart() { + const wdg = new createWidget(createAdvancedChartOptions()); + installThemeToolbar(wdg); +} -// Wait for the chart to be ready -tvWidget.onChartReady(() => { - console.log('Chart is ready'); - const chart = tvWidget.activeChart(); - - // Subscribe to interval changes and then clear cache - chart.onIntervalChanged().subscribe(null, () => { - tvWidget.resetCache(); - chart.resetData(); - }); -}); -window.frames[0].focus(); +window.addEventListener('DOMContentLoaded', initChart, { once: true }); diff --git a/src/news.js b/src/news.js new file mode 100644 index 0000000..e22dab7 --- /dev/null +++ b/src/news.js @@ -0,0 +1,16 @@ +const COINDESK_RSS_PROXY_URL = '/api/news/coindesk-rss'; + +// CoinDesk does not send browser CORS headers, so the local server exposes it same-origin. +// TradingView reads this object directly through the rss_news_feed widget option. +export const CRYPTO_RSS_NEWS_FEED = { + default: { + url: COINDESK_RSS_PROXY_URL, + name: 'CoinDesk', + }, + crypto: { + url: COINDESK_RSS_PROXY_URL, + name: 'CoinDesk', + }, +}; + +export const CRYPTO_RSS_TITLE = 'CoinDesk'; diff --git a/src/save-load-adapter.js b/src/save-load-adapter.js new file mode 100644 index 0000000..3b8f698 --- /dev/null +++ b/src/save-load-adapter.js @@ -0,0 +1,332 @@ +const STORAGE_KEYS = { + charts: 'LocalStorageSaveLoadAdapter_charts', + studyTemplates: 'LocalStorageSaveLoadAdapter_studyTemplates', + drawingTemplates: 'LocalStorageSaveLoadAdapter_drawingTemplates', + chartTemplates: 'LocalStorageSaveLoadAdapter_chartTemplates', + drawings: 'LocalStorageSaveLoadAdapter_drawings', +}; + +// Clones saved chart/template content before returning it to the widget. +function cloneContent(content) { + if (content == null) return content; + if (typeof structuredClone === 'function') return structuredClone(content); + + return JSON.parse(JSON.stringify(content)); +} + +// TradingView may pass drawing sources as either a Map or a plain object. +function sourceEntries(sources) { + if (!sources) return []; + if (sources instanceof Map) return sources.entries(); + + return Object.entries(sources); +} + +// Implements TradingView's save/load adapter contract using browser localStorage. +export class LocalStorageSaveLoadAdapter { + // Loads the persisted state once and periodically flushes later mutations. + constructor({ flushIntervalMs = 1000 } = {}) { + this._charts = this._getFromLocalStorage(STORAGE_KEYS.charts, []); + this._studyTemplates = this._getFromLocalStorage( + STORAGE_KEYS.studyTemplates, + [] + ); + this._drawingTemplates = this._getFromLocalStorage( + STORAGE_KEYS.drawingTemplates, + [] + ); + this._chartTemplates = this._getFromLocalStorage( + STORAGE_KEYS.chartTemplates, + [] + ); + this._drawings = this._getFromLocalStorage(STORAGE_KEYS.drawings, {}); + this._isDirty = false; + + this._flushTimerId = window.setInterval(() => { + if (!this._isDirty) return; + + this._saveAllToLocalStorage(); + this._isDirty = false; + }, flushIntervalMs); + } + + // Lists chart metadata for the Load Chart dialog. + getAllCharts() { + return Promise.resolve(this._charts); + } + + // Removes a saved chart and rejects when TradingView asks for an unknown id. + removeChart(id) { + const initialLength = this._charts.length; + this._charts = this._charts.filter(chart => chart.id !== id); + + if (this._charts.length === initialLength) { + return Promise.reject(new Error('The chart does not exist')); + } + + this._isDirty = true; + return Promise.resolve(); + } + + // Stores chart JSON and returns the stable id TradingView should use later. + saveChart(chartData) { + const id = chartData.id || this._generateUniqueChartId(); + chartData.id = id; + const savedChartData = { + ...chartData, + id, + timestamp: Math.round(Date.now() / 1000), + }; + + this._charts = this._charts.filter(chart => chart.id !== id); + this._charts.push(savedChartData); + this._isDirty = true; + + return Promise.resolve(id); + } + + // Returns the serialized chart payload for a saved chart id. + getChartContent(id) { + const chart = this._charts.find(item => item.id === id); + + if (!chart) { + return Promise.reject(new Error('The chart does not exist')); + } + + return Promise.resolve(chart.content); + } + + // Removes a named study template from localStorage-backed state. + removeStudyTemplate(studyTemplateData) { + const initialLength = this._studyTemplates.length; + this._studyTemplates = this._studyTemplates.filter( + template => template.name !== studyTemplateData.name + ); + + if (this._studyTemplates.length === initialLength) { + return Promise.reject( + new Error('The study template does not exist') + ); + } + + this._isDirty = true; + return Promise.resolve(); + } + + // Loads the saved content for a named study template. + getStudyTemplateContent(studyTemplateData) { + const template = this._studyTemplates.find( + item => item.name === studyTemplateData.name + ); + + if (!template) { + return Promise.reject( + new Error('The study template does not exist') + ); + } + + return Promise.resolve(template.content); + } + + // Saves or replaces a named study template. + saveStudyTemplate(studyTemplateData) { + this._studyTemplates = this._studyTemplates.filter( + template => template.name !== studyTemplateData.name + ); + this._studyTemplates.push(studyTemplateData); + this._isDirty = true; + + return Promise.resolve(); + } + + // Lists study template metadata for TradingView dialogs. + getAllStudyTemplates() { + return Promise.resolve(this._studyTemplates); + } + + // Removes a drawing template for a specific drawing tool. + removeDrawingTemplate(toolName, templateName) { + const initialLength = this._drawingTemplates.length; + this._drawingTemplates = this._drawingTemplates.filter( + template => + template.name !== templateName || template.toolName !== toolName + ); + + if (this._drawingTemplates.length === initialLength) { + return Promise.reject( + new Error('The drawing template does not exist') + ); + } + + this._isDirty = true; + return Promise.resolve(); + } + + // Loads a drawing-template payload for a specific drawing tool. + loadDrawingTemplate(toolName, templateName) { + const template = this._drawingTemplates.find( + item => item.name === templateName && item.toolName === toolName + ); + + if (!template) { + return Promise.reject( + new Error('The drawing template does not exist') + ); + } + + return Promise.resolve(template.content); + } + + // Saves or replaces a drawing template for a specific drawing tool. + saveDrawingTemplate(toolName, templateName, content) { + this._drawingTemplates = this._drawingTemplates.filter( + template => + template.name !== templateName || template.toolName !== toolName + ); + this._drawingTemplates.push({ + name: templateName, + content, + toolName, + }); + this._isDirty = true; + + return Promise.resolve(); + } + + // Lists the drawing template names TradingView can show in the UI. + getDrawingTemplates() { + return Promise.resolve( + this._drawingTemplates.map(template => template.name) + ); + } + + // Lists chart template names for the template selector. + async getAllChartTemplates() { + return this._chartTemplates.map(template => template.name); + } + + // Saves or replaces a chart template payload. + async saveChartTemplate(templateName, content) { + const template = this._chartTemplates.find( + item => item.name === templateName + ); + + if (template) { + template.content = content; + } else { + this._chartTemplates.push({ name: templateName, content }); + } + + this._isDirty = true; + } + + // Removes a chart template by name. + async removeChartTemplate(templateName) { + this._chartTemplates = this._chartTemplates.filter( + template => template.name !== templateName + ); + this._isDirty = true; + } + + // Returns a cloned chart-template payload so callers cannot mutate storage directly. + async getChartTemplateContent(templateName) { + const template = this._chartTemplates.find( + item => item.name === templateName + ); + + return { + content: cloneContent(template?.content), + }; + } + + // Stores drawings separately from the chart when saveload_separate_drawings_storage is enabled. + async saveLineToolsAndGroups(layoutId, chartId, state) { + const key = this._getDrawingKey(layoutId, chartId); + const drawings = sourceEntries(state?.sources); + + if (!drawings) return; + + this._drawings[key] ??= {}; + + for (const [sourceKey, sourceState] of drawings) { + if (sourceState === null) { + delete this._drawings[key][sourceKey]; + } else { + this._drawings[key][sourceKey] = sourceState; + } + } + + this._isDirty = true; + } + + // Loads drawings for the current layout/chart pair in TradingView's expected Map shape. + async loadLineToolsAndGroups(layoutId, chartId) { + if (!layoutId) return null; + + const rawSources = + this._drawings[this._getDrawingKey(layoutId, chartId)]; + if (!rawSources) return null; + + return { + sources: new Map(Object.entries(rawSources)), + }; + } + + // Flushes any pending writes and stops the periodic localStorage timer. + destroy() { + window.clearInterval(this._flushTimerId); + + if (this._isDirty) { + this._saveAllToLocalStorage(); + this._isDirty = false; + } + } + + // Creates a collision-resistant enough id for a browser-only demo. + _generateUniqueChartId() { + const existingIds = new Set(this._charts.map(chart => chart.id)); + + while (true) { + const uid = Math.random().toString(16).slice(2); + if (!existingIds.has(uid)) return uid; + } + } + + // Reads localStorage defensively so corrupted demo data does not break startup. + _getFromLocalStorage(key, fallback) { + try { + const dataFromStorage = window.localStorage.getItem(key); + return dataFromStorage ? JSON.parse(dataFromStorage) : fallback; + } catch { + return fallback; + } + } + + // Writes a single state bucket into localStorage. + _saveToLocalStorage(key, data) { + window.localStorage.setItem(key, JSON.stringify(data)); + } + + // Persists every state bucket together to keep charts/templates/drawings in sync. + _saveAllToLocalStorage() { + this._saveToLocalStorage(STORAGE_KEYS.charts, this._charts); + this._saveToLocalStorage( + STORAGE_KEYS.studyTemplates, + this._studyTemplates + ); + this._saveToLocalStorage( + STORAGE_KEYS.drawingTemplates, + this._drawingTemplates + ); + this._saveToLocalStorage( + STORAGE_KEYS.chartTemplates, + this._chartTemplates + ); + this._saveToLocalStorage(STORAGE_KEYS.drawings, this._drawings); + } + + // Namespaces drawing state by layout and chart id, matching TradingView's adapter calls. + _getDrawingKey(layoutId, chartId) { + return `${layoutId}/${chartId}`; + } +} diff --git a/src/streaming.js b/src/streaming.js deleted file mode 100644 index 1310758..0000000 --- a/src/streaming.js +++ /dev/null @@ -1,188 +0,0 @@ -import { parseFullSymbol, apiKey } from './helpers.js'; - -const socket = new WebSocket( - 'wss://streamer.cryptocompare.com/v2?api_key=' + apiKey -); -// Example ▼ {"TYPE":"20","MESSAGE":"STREAMERWELCOME","SERVER_UPTIME_SECONDS":1262462,"SERVER_NAME":"08","SERVER_TIME_MS":1753184197855,"CLIENT_ID":2561280,"DATA_FORMAT":"JSON","SOCKET_ID":"7zUlXfWU+zH7uX7ViDS2","SOCKETS_ACTIVE":1,"SOCKETS_REMAINING":0,"RATELIMIT_MAX_SECOND":30,"RATELIMIT_MAX_MINUTE":60,"RATELIMIT_MAX_HOUR":1200,"RATELIMIT_MAX_DAY":10000,"RATELIMIT_MAX_MONTH":20000,"RATELIMIT_REMAINING_SECOND":29,"RATELIMIT_REMAINING_MINUTE":59,"RATELIMIT_REMAINING_HOUR":1199,"RATELIMIT_REMAINING_DAY":9999,"RATELIMIT_REMAINING_MONTH":19867} - -const channelToSubscription = new Map(); - -socket.addEventListener('open', () => { - console.log('[socket] Connected'); -}); - -socket.addEventListener('close', (reason) => { - console.log('[socket] Disconnected:', reason); -}); - -socket.addEventListener('error', (error) => { - console.log('[socket] Error:', error); -}); - -// Calculates the start time of the bar based on the resolution -function getNextBarTime(barTime, resolution) { - const date = new Date(barTime); - const interval = parseInt(resolution); - - if (resolution === '1D') { - date.setUTCDate(date.getUTCDate() + 1); - date.setUTCHours(0, 0, 0, 0); - } else if (!isNaN(interval)) { // Handles '1' and '60' (minutes) - // Add the interval to the current bar's time - date.setUTCMinutes(date.getUTCMinutes() + interval); - } - return date.getTime(); -} - -socket.addEventListener('message', (event) => { - const data = JSON.parse(event.data); - - const { - TYPE: eventType, - M: exchange, - FSYM: fromSymbol, - TSYM: toSymbol, - TS: tradeTime, // This is a UNIX timestamp in seconds - P: tradePrice, - Q: tradeVolume, - } = data; - - // Handle Trade event updates only - if (parseInt(eventType) !== 0) { - return; - } - // example TYPE:"0" - // M:"Coinbase" - // FSYM:"BTC" - // TSYM:"USD" - // F:"1" - // ID:"852793745" - // TS:1753190418 - // Q:0.34637342 - // P:119283.1 - // TOTAL:41316.495295202 - // RTS:1753190418 - // CCSEQ:852777369 - // TSNS:654000000 - // RTSNS:708000000 - - // Description of Q parameters: - // The from asset (base symbol / coin) volume of the trade - // (for a BTC-USD trade, how much BTC was traded at the trade price) - - const channelString = `0~${exchange}~${fromSymbol}~${toSymbol}`; - const subscriptionItem = channelToSubscription.get(channelString); - - if (subscriptionItem === undefined) { - return; - } - - const lastBar = subscriptionItem.lastBar; - - // The resolution will be '1', '60', or '1D' - const nextBarTime = getNextBarTime(lastBar.time, subscriptionItem.resolution); - - let bar; - // If the trade time is greater than or equal to the next bar's start time, create a new bar - if (tradeTime * 1000 >= nextBarTime) { - bar = { - time: nextBarTime, - open: tradePrice, - high: tradePrice, - low: tradePrice, - close: tradePrice, - volume: tradeVolume, - }; - } else { - // Otherwise, update the last bar - bar = { - ...lastBar, - high: Math.max(lastBar.high, tradePrice), - low: Math.min(lastBar.low, tradePrice), - close: tradePrice, - volume: (lastBar.volume || 0) + tradeVolume, - }; - } - subscriptionItem.lastBar = bar; - - // Send data to every subscriber of that symbol - subscriptionItem.handlers.forEach((handler) => handler.callback(bar)); -}) - -export function subscribeOnStream( - symbolInfo, - resolution, - onRealtimeCallback, - subscriberUID, - onResetCacheNeededCallback, - lastBar -) { - // Valid SymbolInfo - if (!symbolInfo || !symbolInfo.ticker) { - console.error('[subscribeBars]: Invalid symbolInfo:', symbolInfo); - return; - } - const parsedSymbol = parseFullSymbol(symbolInfo.ticker); - - // Subscribe to the trade channel to build bars ourselves - const channelString = `0~${parsedSymbol.exchange}~${parsedSymbol.fromSymbol}~${parsedSymbol.toSymbol}`; - - const handler = { - id: subscriberUID, - callback: onRealtimeCallback, - }; - - let subscriptionItem = channelToSubscription.get(channelString); - if (subscriptionItem) { - console.log('Updating existing subscription with new resolution:', resolution); - subscriptionItem.resolution = resolution; - subscriptionItem.lastBar = lastBar; - subscriptionItem.handlers.push(handler); - return; - } - - subscriptionItem = { - subscriberUID, - resolution, - lastBar, - handlers: [handler], - }; - - channelToSubscription.set(channelString, subscriptionItem); - console.log('[subscribeBars]: Subscribe to streaming. Channel:', channelString); - - const subRequest = { - action: 'SubAdd', - subs: [channelString], - }; - console.log('[subscribeBars]: Sending subscription request:', subRequest); - // Only send SubAdd if the socket is open - if (socket.readyState === WebSocket.OPEN) { - socket.send(JSON.stringify(subRequest)); - } -} - - -export function unsubscribeFromStream(subscriberUID) { - for (const channelString of channelToSubscription.keys()) { - const subscriptionItem = channelToSubscription.get(channelString); - const handlerIndex = subscriptionItem.handlers.findIndex( - (handler) => handler.id === subscriberUID - ); - - if (handlerIndex !== -1) { - subscriptionItem.handlers.splice(handlerIndex, 1); - - if (subscriptionItem.handlers.length === 0) { - console.log('[unsubscribeBars]: Unsubscribe from streaming. Channel:', channelString); - const subRequest = { - action: 'SubRemove', - subs: [channelString], - }; - socket.send(JSON.stringify(subRequest)); - channelToSubscription.delete(channelString); - break; - } - } - } -} diff --git a/src/theme.js b/src/theme.js new file mode 100644 index 0000000..c232ef9 --- /dev/null +++ b/src/theme.js @@ -0,0 +1,201 @@ +// Allows reviewers to force a theme with ?theme=dark or ?theme=light. +function getRequestedTheme() { + const requestedTheme = new URLSearchParams(window.location.search).get( + 'theme' + ); + return requestedTheme === 'dark' || requestedTheme === 'light' + ? requestedTheme + : null; +} + +// Falls back to the browser preference when the URL does not force a theme. +function prefersDarkTheme() { + return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false; +} + +// TradingView loads custom widget CSS through a URL, so we expose this string as a Blob. +const customCSS = ` + @font-face { + font-family: 'NanumBarunGothic'; + src: url('/src/assets/fonts/NanumBarunGothic.otf') format('opentype'); + font-weight: normal; + font-style: normal; + } + + #documentation-toolbar-button { + all: unset; + position: relative; + color: #fff; + font-size: 14px; + font-weight: 400; + line-height: 18px; + letter-spacing: 0.15408px; + padding: 5px 12px; + border-radius: 80px; + background: #2962ff; + cursor: pointer; + } + + #documentation-toolbar-button:hover { + background: #1e53e5; + } + + #documentation-toolbar-button:active { + background: #1948cc; + } + + #theme-toggle { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + } + + .switcher { + display: inline-block; + position: relative; + flex: 0 0 auto; + width: 38px; + height: 20px; + vertical-align: middle; + z-index: 0; + -webkit-tap-highlight-color: transparent; + } + + .switcher input { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + z-index: 1; + cursor: default; + } + + .switcher .thumb-wrapper { + display: block; + border-radius: 20px; + position: relative; + z-index: 0; + width: 100%; + height: 100%; + } + + .switcher .track { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + border-radius: 20px; + background-color: #a3a6af; + } + + #theme-switch:checked + .thumb-wrapper .track { + background-color: #2962ff; + } + + .switcher .thumb { + display: block; + width: 14px; + height: 14px; + border-radius: 14px; + transition-duration: 250ms; + transition-property: transform; + transition-timing-function: ease-out; + transform: translate(3px, 3px); + background: #fff; + } + + [dir=rtl] .switcher .thumb { + transform: translate(-3px, 3px); + } + + .switcher input:checked + .thumb-wrapper .thumb { + transform: translate(21px, 3px); + } + + [dir=rtl] .switcher input:checked + .thumb-wrapper .thumb { + transform: translate(-21px, 3px); + } + + #documentation-toolbar-button:focus-visible::before, + .switcher:focus-within::before { + content: ''; + display: block; + position: absolute; + top: -2px; + right: -2px; + bottom: -2px; + left: -2px; + border-radius: 16px; + outline: #2962ff solid 2px; + } + + button[data-qa-id="dom-price-step-dropdown-button"] { + pointer-events: none !important; + background: transparent !important; + } + + button[data-qa-id="dom-price-step-dropdown-button"] span[role="img"] { + display: none !important; + } +`; + +const cssBlob = new Blob([customCSS], { + type: 'text/css', +}); + +export const cssBlobUrl = URL.createObjectURL(cssBlob); +export const theme = + getRequestedTheme() ?? (prefersDarkTheme() ? 'dark' : 'light'); + +// Keeps chart-pane colors synchronized with the resolved widget theme. +export function getChartOverrides(currentTheme = theme) { + if (currentTheme === 'dark') { + return { + 'paneProperties.backgroundType': 'gradient', + 'paneProperties.background': '#111827', + 'paneProperties.backgroundGradientStartColor': '#36364a', + 'paneProperties.backgroundGradientEndColor': '#353924', + 'paneProperties.vertGridProperties.color': + 'rgba(255, 255, 255, 0.08)', + 'paneProperties.horzGridProperties.color': + 'rgba(255, 255, 255, 0.08)', + 'paneProperties.crossHairProperties.color': '#b9c0cc', + 'paneProperties.crossHairProperties.transparency': 40, + 'scalesProperties.textColor': '#ffffff', + 'scalesProperties.lineColor': 'rgba(255, 255, 255, 0.14)', + 'scalesProperties.fontSize': 14, + 'time_scale.show_bar_countdown': true, + 'mainSeriesProperties.showPrevClosePriceLine': true, + 'backgrounds.outOfSession.color': 'rgba(16, 20, 32, 0.2)', + 'mainSeriesProperties.baselineStyle.topLineColor': + 'rgba(205, 17, 33, 0.2)', + 'mainSeriesProperties.baselineStyle.bottomLineColor': + 'rgba(10, 32, 6, 0.2)', + }; + } + + return { + 'paneProperties.backgroundType': 'solid', + 'paneProperties.background': '#f8fafc', + 'paneProperties.backgroundGradientStartColor': '#f8fafc', + 'paneProperties.backgroundGradientEndColor': '#eef2f7', + 'paneProperties.vertGridProperties.color': 'rgba(15, 23, 42, 0.08)', + 'paneProperties.horzGridProperties.color': 'rgba(15, 23, 42, 0.08)', + 'paneProperties.crossHairProperties.color': '#475569', + 'paneProperties.crossHairProperties.transparency': 35, + 'scalesProperties.textColor': '#1f2933', + 'scalesProperties.lineColor': 'rgba(15, 23, 42, 0.16)', + 'scalesProperties.fontSize': 14, + 'time_scale.show_bar_countdown': true, + 'mainSeriesProperties.showPrevClosePriceLine': true, + 'backgrounds.outOfSession.color': 'rgba(239, 243, 247, 0.75)', + 'mainSeriesProperties.baselineStyle.topLineColor': + 'rgba(205, 17, 33, 0.16)', + 'mainSeriesProperties.baselineStyle.bottomLineColor': + 'rgba(10, 112, 41, 0.16)', + }; +} diff --git a/src/toolbar.js b/src/toolbar.js new file mode 100644 index 0000000..3e5bb8d --- /dev/null +++ b/src/toolbar.js @@ -0,0 +1,114 @@ +import { getChartOverrides, theme as initialTheme } from './theme.js'; + +// Makes custom toolbar controls participate in TradingView's keyboard navigation model. +function enableRovingTabindex(...elements) { + const handleMainElement = event => { + event.target.tabIndex = 0; + }; + const handleSecondaryElement = event => { + event.target.tabIndex = -1; + }; + + elements.filter(Boolean).forEach(element => { + element.addEventListener( + 'roving-tabindex:main-element', + handleMainElement + ); + element.addEventListener( + 'roving-tabindex:secondary-element', + handleSecondaryElement + ); + }); +} + +// Adds a theme switch and reapplies chart overrides so pane colors follow the UI theme. +export function addThemeToggle(widget) { + const themeToggleEl = widget.createButton({ + useTradingViewStyle: false, + align: 'right', + }); + + themeToggleEl.dataset.internalAllowKeyboardNavigation = 'true'; + themeToggleEl.id = 'theme-toggle'; + themeToggleEl.innerHTML = ` +
+ + + + + +
`; + themeToggleEl.title = 'Toggle theme'; + + const checkboxEl = themeToggleEl.querySelector('#theme-switch'); + const labelEl = themeToggleEl.querySelector('#theme-switch-label'); + + function updateLabel() { + labelEl.textContent = checkboxEl.checked ? 'Dark theme' : 'Light theme'; + } + + checkboxEl.checked = + typeof widget.getTheme === 'function' + ? widget.getTheme() === 'dark' + : initialTheme === 'dark'; + updateLabel(); + checkboxEl.addEventListener('change', async function () { + const themeToSet = this.checked ? 'dark' : 'light'; + this.disabled = true; + + try { + await widget.changeTheme(themeToSet, { disableUndo: true }); + widget.applyOverrides(getChartOverrides(themeToSet)); + } finally { + this.disabled = false; + updateLabel(); + } + }); + + return checkboxEl; +} + +// Adds a small documentation shortcut to the optional trading toolbar. +export function addDocumentationButton(widget) { + const element = widget.createButton({ + useTradingViewStyle: false, + align: 'right', + }); + + element.dataset.internalAllowKeyboardNavigation = 'true'; + element.innerHTML = + ''; + element.title = 'View the documentation site'; + element.addEventListener('click', () => { + window.open( + 'https://www.tradingview.com/charting-library-docs/', + '_blank' + ); + }); + + return element.querySelector('#documentation-toolbar-button'); +} + +// Installs the small Advanced Charts toolbar: theme toggle plus docs shortcut. +export function installThemeToolbar(widget) { + widget.headerReady().then(() => { + const themeSwitchCheckbox = addThemeToggle(widget); + const documentationButton = addDocumentationButton(widget); + + enableRovingTabindex(themeSwitchCheckbox, documentationButton); + }); +} + +// Installs only the documentation shortcut for pages that want a tiny toolbar. +export function installDocumentationToolbar(widget) { + widget.headerReady().then(() => { + const documentationButton = addDocumentationButton(widget); + + enableRovingTabindex(documentationButton); + }); +} + +// Exposes roving-tabindex wiring for pages that compose multiple controls. +export function wireRovingTabindex(...elements) { + enableRovingTabindex(...elements); +} diff --git a/src/trading.js b/src/trading.js new file mode 100644 index 0000000..f78104b --- /dev/null +++ b/src/trading.js @@ -0,0 +1,400 @@ +import Datafeed from './datafeed/datafeed.js'; +import { LocalStorageSaveLoadAdapter } from './save-load-adapter.js'; +import { + addDocumentationButton, + addThemeToggle, + wireRovingTabindex, +} from './toolbar.js'; +import { createTradingPlatformOptions } from './widget-options.js'; +import { widget as createWidget } from '/vendor/tradingview/trading_platform/charting_library.esm.js'; + +import '../third_party/tradingview/broker-sample/dist/bundle.js'; + +const BROKER_CONFIG = { + configFlags: { + supportPositions: true, + supportMultiposition: true, + supportReversePosition: true, + supportNativeReversePosition: true, + supportPartialClosePosition: true, + supportClosePosition: true, + supportPLUpdate: true, + showQuantityInsteadOfAmount: true, + supportEditAmount: true, + supportOrdersHistory: false, + supportModifyOrderPrice: true, + supportModifyBrackets: true, + supportOrderBrackets: true, + supportPositionBrackets: true, + supportModifyDuration: true, + supportAddBracketsToExistingOrder: true, + supportTrailingStop: true, + supportModifyTrailingStop: true, + supportStopLimitOrders: false, + supportCancelOrderForNonTradableSymbol: true, + supportLevel2Data: true, + show_symbol_logos: true, + supportMultipleExitLevels: true, + }, + tradedGroupConfig: { + supportAdaptiveLayout: true, + }, + durations: [ + { name: 'DAY', value: 'DAY' }, + { name: 'IOC', value: 'IOC' }, + ], +}; + +const WATCHLIST_SYMBOLS = [ + '###Binance', + 'Binance:BTC/USDT', + 'Binance:ETH/USDT', + 'Binance:BNB/USDT', + 'Binance:SOL/USDT', + 'Binance:ADA/USDT', + 'Binance:ETH/BTC', + 'Binance:LTC/USDT', + 'Binance:XRP/USDT', + 'Binance:XRP/BTC', +]; + +function formatAlertPrice(price, chart) { + const fallback = price.toFixed(2); + + try { + const formatter = + chart?.getSeries?.()?.priceFormatter?.() ?? + chart?.priceFormatter?.(); + const formattedPrice = formatter?.format?.(price); + + return formattedPrice || fallback; + } catch { + return fallback; + } +} + +// Converts the external BrokerDemo sample into Trading Platform widget options. +function createBrokerOptions(onHostReady) { + const BrokerDemo = globalThis.Brokers?.BrokerDemo; + + if (!BrokerDemo) { + return { + widgetOptions: {}, + }; + } + + class CustomBroker extends BrokerDemo { + isTradable() { + return Promise.resolve(true); + } + } + + return { + widgetOptions: { + broker_factory(host) { + onHostReady(host); + return new CustomBroker(host, Datafeed); + }, + broker_config: BROKER_CONFIG, + }, + }; +} + +// Keeps the custom alert demo self-contained: context-menu creation, crossing detection, cleanup. +function createAlertController({ getWidget, notify }) { + let activeAlerts = []; + let previousPrice = null; + let lastPlusClickPrice = null; + + return { + contextMenu: { + items_processor: async (items, actionsFactory, params) => { + if (params.menuName !== 'CrosshairMenuView') return items; + if ( + lastPlusClickPrice === null || + lastPlusClickPrice === undefined + ) + return items; + + const price = lastPlusClickPrice; + const chart = getWidget()?.activeChart?.(); + const alertAction = actionsFactory.createAction({ + actionId: 'create_custom_alert', + label: `Add Alert at ${formatAlertPrice(price, chart)}`, + onExecute: async () => { + const widget = getWidget(); + const chart = widget.activeChart(); + const lineId = await chart.createShape( + { price }, + { + shape: 'horizontal_line', + overrides: { + linecolor: '#ff9800', + linewidth: 2, + linestyle: 1, + }, + } + ); + + activeAlerts.push({ id: lineId, price }); + }, + }); + + items.push(actionsFactory.createSeparator()); + items.push(alertAction); + + return items; + }, + }, + + attach(widget) { + const chart = widget.activeChart(); + + widget.subscribe('onPlusClick', params => { + if (params.price !== undefined) { + lastPlusClickPrice = params.price; + } + }); + + widget.subscribe('onTick', tick => { + const currentPrice = tick.close ?? tick.price ?? tick.last; + if (currentPrice === undefined) return; + + if (previousPrice !== null) { + activeAlerts = activeAlerts.filter(alert => { + const crossedUp = + previousPrice < alert.price && + currentPrice >= alert.price; + const crossedDown = + previousPrice > alert.price && + currentPrice <= alert.price; + + if (!crossedUp && !crossedDown) return true; + + const alertPrice = formatAlertPrice(alert.price, chart); + notify( + 'Alert Triggered', + `Price crossed your alert at ${alertPrice}` + ); + try { + chart.removeEntity(alert.id); + } catch { + // The line may already be gone if the user removed it manually. + } + + return false; + }); + } + + previousPrice = currentPrice; + }); + + widget.subscribe('drawing_event', (id, type) => { + if (type !== 'points_changed') return; + + window.setTimeout(() => { + const alert = activeAlerts.find(alert => alert.id === id); + if (!alert) return; + + const shape = chart.getShapeById(id); + const newPrice = shape?.getPoints()[0]?.price; + if (newPrice === undefined) return; + + alert.price = newPrice; + shape.setProperties({ + text: `Alert: ${formatAlertPrice(newPrice, chart)}`, + }); + }, 50); + }); + }, + }; +} + +// Shows a user-visible hint when Trading Platform broker features do not initialize. +function showTradingPlatformWarning() { + const existingWarning = document.querySelector('#trading-platform-warning'); + if (existingWarning) return; + + const warning = document.createElement('div'); + warning.id = 'trading-platform-warning'; + warning.textContent = + 'Trading Platform broker features did not initialize. Make sure trading_platform-master was synced and the broker sample bundle exists.'; + warning.style.cssText = [ + 'position:fixed', + 'z-index:10000', + 'right:16px', + 'bottom:16px', + 'max-width:380px', + 'padding:12px 14px', + 'border-radius:10px', + 'background:#fff7ed', + 'color:#7c2d12', + 'box-shadow:0 12px 28px rgba(15,23,42,0.18)', + 'font:13px/1.4 sans-serif', + ].join(';'); + + document.body.append(warning); +} + +// Restores the chart to a clean single-pane view while preserving the current symbol. +function addResetButton(widget) { + const button = widget.createButton({ align: 'right' }); + + button.textContent = 'Reset'; + button.addEventListener('click', () => { + const chart = widget.activeChart(); + const symbol = chart.symbol(); + + chart.removeAllStudies(); + chart.removeAllShapes(); + widget.setLayout('s'); + chart.setSymbol(symbol); + widget.resetCache(); + chart.resetData(); + }); +} + +// Adds a tiny fixed watchlist shortcut without distracting from the datafeed example. +function addSymbolDropdown(widget) { + widget.createDropdown({ + title: 'Select symbol', + align: 'right', + tooltip: 'Select one of the symbols to load the chart with', + icon: 'arrow', + useTradingViewStyle: true, + items: [ + { + title: 'BTC/USDT (1D)', + onSelect: () => { + widget.activeChart().setSymbol('Binance:BTC/USDT', '1D'); + }, + }, + { + title: 'ETH/USDT (1D)', + onSelect: () => { + widget.activeChart().setSymbol('Binance:ETH/USDT', '1D'); + }, + }, + ], + }); +} + +// Toggles user drawings and studies together so reviewers can quickly inspect a clean chart. +function addVisibilityButton(widget) { + let visualsHidden = false; + const button = widget.createButton({ align: 'right' }); + + function updateLabel() { + button.textContent = visualsHidden + ? 'Show Indicators/Drawings' + : 'Hide Indicators/Drawings'; + } + + updateLabel(); + button.addEventListener('click', () => { + visualsHidden = !visualsHidden; + widget.hideAllDrawingTools().setValue(visualsHidden); + widget + .activeChart() + .getAllStudies() + .forEach(study => { + widget + .activeChart() + .getStudyById(study.id) + .setVisible(!visualsHidden); + }); + updateLabel(); + }); +} + +// Installs trading toolbar controls after the TradingView header has finished mounting. +function installToolbar(widget) { + widget.headerReady().then(() => { + addResetButton(widget); + addSymbolDropdown(widget); + addVisibilityButton(widget); + + const themeSwitchCheckbox = addThemeToggle(widget); + const documentationButton = addDocumentationButton(widget); + + wireRovingTabindex(themeSwitchCheckbox, documentationButton); + }); +} + +// Keep custom chart subscriptions together so future event wiring has one obvious home. +function installChartReadySubscriptions(widget, alertController) { + widget.subscribe('onAutoSaveNeeded', () => { + if (typeof widget.saveChartToServer === 'function') { + widget.saveChartToServer(null, null, { + defaultChartName: 'Default', + }); + } + }); + + alertController.attach(widget); + + // --------------------------------------------------------------------------- + // Custom subscription events + // Add project-specific TradingView subscriptions here. This runs inside + // widget.onChartReady, so widget.activeChart() and broker-backed events are + // ready to use. + // + // Examples: + // widget.subscribe("study_event", (entityId, eventType) => {}); + // widget.activeChart().onSymbolChanged().subscribe(null, () => {}); + // widget.activeChart().onIntervalChanged().subscribe(null, (interval) => {}); + // --------------------------------------------------------------------------- +} + +// Boots the Trading Platform page with broker, DOM, save/load, alerts, and toolbar wiring. +async function initTradingPlatformChart() { + let wdg; + + let brokerHost = null; + const saveLoadAdapter = new LocalStorageSaveLoadAdapter(); + const brokerOptions = createBrokerOptions(host => { + brokerHost = host; + }); + const alertController = createAlertController({ + getWidget: () => wdg, + notify: (title, message) => { + brokerHost?.showNotification?.(title, message, 1); + }, + }); + + wdg = new createWidget( + createTradingPlatformOptions({ + widgetbar: { + details: true, + watchlist: true, + datawindow: true, + news: true, + watchlist_settings: { + default_symbols: WATCHLIST_SYMBOLS, + readonly: false, + }, + }, + save_load_adapter: saveLoadAdapter, + load_last_chart: false, + auto_save_delay: 5, + context_menu: alertController.contextMenu, + ...brokerOptions.widgetOptions, + }) + ); + + wdg.onChartReady(() => { + window.setTimeout(() => { + if (!brokerHost) { + showTradingPlatformWarning(); + } + }, 1000); + + installChartReadySubscriptions(wdg, alertController); + }); + + installToolbar(wdg); +} + +window.addEventListener('DOMContentLoaded', initTradingPlatformChart, { + once: true, +}); diff --git a/src/widget-options.js b/src/widget-options.js new file mode 100644 index 0000000..14fa3ff --- /dev/null +++ b/src/widget-options.js @@ -0,0 +1,118 @@ +import Datafeed from './datafeed/datafeed.js'; +import { CRYPTO_RSS_NEWS_FEED, CRYPTO_RSS_TITLE } from './news.js'; +import { cssBlobUrl, getChartOverrides, theme } from './theme.js'; + +const SHARED_ENABLED_FEATURES = [ + 'custom_resolutions', + 'allow_arbitrary_symbol_search_input', + 'display_data_mode', + 'use_symbol_name_for_header_toolbar', + 'chart_drag_export', +]; + +const SHARED_DISABLED_FEATURES = [ + 'use_localstorage_for_settings', + 'save_chart_properties_to_local_storage', + 'volume_force_overlay', +]; + +const TRADING_PLATFORM_ENABLED_FEATURES = [ + 'dom_widget', + 'saveload_separate_drawings_storage', + 'pre_post_market_price_line', + 'legend_last_day_change', +]; + +const TRADING_PLATFORM_DISABLED_FEATURES = [ + 'open_account_manager', + 'show_right_widgets_panel_by_default', +]; + +// The free Advanced Charts page does not have widgetbar quote/news/DOM UI, so expose +// only the chart datafeed methods it can use. +function createAdvancedChartsDatafeed(datafeed) { + const clone = { ...datafeed }; + + delete clone.getQuotes; + delete clone.subscribeQuotes; + delete clone.unsubscribeQuotes; + delete clone.subscribeDepth; + delete clone.unsubscribeDepth; + + return clone; +} + +const ADVANCED_CHARTS_DATAFEED = createAdvancedChartsDatafeed(Datafeed); + +// Deduplicates feature flags after individual pages add their own options. +function unique(values) { + return [...new Set(values)]; +} + +// Builds the common widget constructor payload used by the minimal and trading pages. +export function createWidgetOptions({ + datafeed = Datafeed, + enabledFeatures = [], + disabledFeatures = [], + chartOverrides = {}, + libraryPath = 'vendor/tradingview/advanced_charts/', + ...options +} = {}) { + return { + symbol: 'Binance:ETH/USDT', + interval: '1D', + fullscreen: true, + container: 'tv_chart_container', + datafeed, + library_path: libraryPath, + locale: 'en', + symbol_search_request_delay: 1000, + theme, + custom_css_url: cssBlobUrl, + custom_font_family: "'NanumBarunGothic', sans-serif", + enabled_features: unique([ + ...SHARED_ENABLED_FEATURES, + ...enabledFeatures, + ]), + disabled_features: unique([ + ...SHARED_DISABLED_FEATURES, + ...disabledFeatures, + ]), + overrides: { + ...getChartOverrides(theme), + ...chartOverrides, + }, + ...options, + }; +} + +// Builds the regular Advanced Charts experience without Trading Platform-only options. +export function createAdvancedChartOptions({ ...options } = {}) { + return createWidgetOptions({ + ...options, + datafeed: ADVANCED_CHARTS_DATAFEED, + libraryPath: 'vendor/tradingview/advanced_charts/', + }); +} + +// Builds the Trading Platform experience with broker, account-manager, and layout UI enabled. +export function createTradingPlatformOptions({ + enabledFeatures = [], + disabledFeatures = [], + ...options +} = {}) { + return createWidgetOptions({ + ...options, + libraryPath: 'vendor/tradingview/trading_platform/', + rss_news_feed: CRYPTO_RSS_NEWS_FEED, + rss_news_title: CRYPTO_RSS_TITLE, + enabledFeatures: [ + ...TRADING_PLATFORM_ENABLED_FEATURES, + ...enabledFeatures, + ], + disabledFeatures: [ + ...TRADING_PLATFORM_DISABLED_FEATURES, + ...disabledFeatures, + ], + }); +} diff --git a/trading.html b/trading.html new file mode 100644 index 0000000..cad6e2e --- /dev/null +++ b/trading.html @@ -0,0 +1,14 @@ + + + + TradingView Trading Platform datafeed example + + + + +
+ +