├── examples ├── node_modules │ ├── ws │ │ ├── browser.js │ │ ├── wrapper.mjs │ │ ├── index.js │ │ ├── lib │ │ │ ├── constants.js │ │ │ ├── limiter.js │ │ │ ├── subprotocol.js │ │ │ ├── buffer-util.js │ │ │ ├── validation.js │ │ │ ├── stream.js │ │ │ ├── extension.js │ │ │ ├── event-target.js │ │ │ └── permessage-deflate.js │ │ ├── LICENSE │ │ ├── package.json │ │ └── README.md │ └── .package-lock.json ├── package.json └── websocket-client.js ├── .gitignore ├── tsconfig.json ├── docker-compose.yml ├── tradingview_vendor ├── main.js ├── utils.js ├── protocol.js ├── types.js ├── quote │ ├── market.js │ └── session.js ├── classes │ ├── BuiltInIndicator.js │ ├── PineIndicator.js │ └── PinePermManager.js ├── client.js └── chart │ ├── graphicParser.js │ └── study.js ├── .env ├── env.example ├── Dockerfile ├── package.json ├── src ├── logger.ts ├── push.ts ├── metrics.ts ├── config.ts ├── main.ts ├── health-api.ts ├── tradingview.ts ├── websocket.ts └── health.ts └── README.md /examples/node_modules/ws/browser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = function () { 4 | throw new Error( 5 | 'ws does not work in the browser. Browser clients must use the native ' + 6 | 'WebSocket object' 7 | ); 8 | }; 9 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "version": "1.0.0", 4 | "main": "websocket-client.js", 5 | "scripts": { 6 | "test": "echo \"Error: no test specified\" && exit 1" 7 | }, 8 | "keywords": [], 9 | "author": "", 10 | "license": "ISC", 11 | "description": "", 12 | "dependencies": { 13 | "ws": "^8.18.2" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/node_modules/ws/wrapper.mjs: -------------------------------------------------------------------------------- 1 | import createWebSocketStream from './lib/stream.js'; 2 | import Receiver from './lib/receiver.js'; 3 | import Sender from './lib/sender.js'; 4 | import WebSocket from './lib/websocket.js'; 5 | import WebSocketServer from './lib/websocket-server.js'; 6 | 7 | export { createWebSocketStream, Receiver, Sender, WebSocket, WebSocketServer }; 8 | export default WebSocket; 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node modules 2 | node_modules/ 3 | examples/node_modules/ 4 | 5 | # Build output 6 | dist/ 7 | 8 | # Logs 9 | logs/ 10 | *.log 11 | 12 | # Dependency locks (optional, if you want to ignore them) 13 | # package-lock.json 14 | tmp/ 15 | .DS_Store 16 | .env 17 | .env.* 18 | 19 | # Ignore OS files 20 | Thumbs.db 21 | 22 | # Ignore test coverage 23 | coverage/ 24 | 25 | # Ignore Docker artifacts 26 | *.local 27 | package-lock.json 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "CommonJS", 5 | "moduleResolution": "Node", 6 | "outDir": "dist", 7 | "rootDir": "src", 8 | "strict": false, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "skipLibCheck": true, 12 | "noImplicitAny": false 13 | }, 14 | "include": ["src/**/*"], 15 | "exclude": ["node_modules", "dist"] 16 | } -------------------------------------------------------------------------------- /examples/node_modules/ws/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const WebSocket = require('./lib/websocket'); 4 | 5 | WebSocket.createWebSocketStream = require('./lib/stream'); 6 | WebSocket.Server = require('./lib/websocket-server'); 7 | WebSocket.Receiver = require('./lib/receiver'); 8 | WebSocket.Sender = require('./lib/sender'); 9 | 10 | WebSocket.WebSocket = WebSocket; 11 | WebSocket.WebSocketServer = WebSocket.Server; 12 | 13 | module.exports = WebSocket; 14 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | tv-fetcher: 5 | build: . 6 | ports: 7 | - "8081:8081" # WebSocket API 8 | - "9100:9100" # Prometheus метрики 9 | environment: 10 | - TV_API_PROXY= 11 | - TV_API_TIMEOUT_MS=10000 12 | - WEBSOCKET_PORT=8081 13 | - WEBSOCKET_ENABLED=true 14 | - METRICS_PORT=9100 15 | - LOG_LEVEL=info 16 | - LOG_FILE=/app/logs/tv-fetcher.log 17 | volumes: 18 | - ./logs:/app/logs 19 | restart: unless-stopped -------------------------------------------------------------------------------- /tradingview_vendor/main.js: -------------------------------------------------------------------------------- 1 | const miscRequests = require('./miscRequests'); 2 | const Client = require('./client'); 3 | const BuiltInIndicator = require('./classes/BuiltInIndicator'); 4 | const PineIndicator = require('./classes/PineIndicator'); 5 | const PinePermManager = require('./classes/PinePermManager'); 6 | 7 | module.exports = { ...miscRequests }; 8 | module.exports.Client = Client; 9 | module.exports.BuiltInIndicator = BuiltInIndicator; 10 | module.exports.PineIndicator = PineIndicator; 11 | module.exports.PinePermManager = PinePermManager; -------------------------------------------------------------------------------- /examples/node_modules/ws/lib/constants.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments']; 4 | const hasBlob = typeof Blob !== 'undefined'; 5 | 6 | if (hasBlob) BINARY_TYPES.push('blob'); 7 | 8 | module.exports = { 9 | BINARY_TYPES, 10 | EMPTY_BUFFER: Buffer.alloc(0), 11 | GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', 12 | hasBlob, 13 | kForOnEventAttribute: Symbol('kIsForOnEventAttribute'), 14 | kListener: Symbol('kListener'), 15 | kStatusCode: Symbol('status-code'), 16 | kWebSocket: Symbol('websocket'), 17 | NOOP: () => {} 18 | }; 19 | -------------------------------------------------------------------------------- /tradingview_vendor/utils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /** 3 | * Generates a session id 4 | * @function genSessionID 5 | * @param {String} type Session type 6 | * @returns {string} 7 | */ 8 | genSessionID(type = 'xs') { 9 | let r = ''; 10 | const c = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 11 | for (let i = 0; i < 12; i += 1) r += c.charAt(Math.floor(Math.random() * c.length)); 12 | return `${type}_${r}`; 13 | }, 14 | 15 | genAuthCookies(sessionId = '', signature = '') { 16 | if (!sessionId) return ''; 17 | if (!signature) return `sessionid=${sessionId}`; 18 | return `sessionid=${sessionId};sessionid_sign=${signature}`; 19 | }, 20 | }; 21 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | TV_API_PROXY= 2 | TV_API_TIMEOUT_MS=10000 3 | #SUBSCRIPTIONS=[{"symbol":"BINANCE:BTCUSDT","timeframe":"1"},{"symbol":"BINANCE:ETHUSDT","timeframe":"5"}] 4 | #BACKEND_ENDPOINT=http://localhost:3000/api/prices 5 | #BACKEND_API_KEY=secret_key_123 6 | METRICS_PORT=9100 7 | LOG_LEVEL=info 8 | LOG_FILE=./logs/tv-fetcher.log 9 | WEBSOCKET_ENABLED=true 10 | WEBSOCKET_PORT=8081 11 | 12 | LOG_LEVEL=debug 13 | DEBUG_PRICES=true 14 | 15 | # Health Monitoring System 16 | HEALTH_CHECK_INTERVAL_MS=60000 17 | HEALTH_STALE_THRESHOLD_MULTIPLIER=3 18 | HEALTH_AUTO_RECOVERY_ENABLED=true 19 | HEALTH_MAX_RECOVERY_ATTEMPTS=3 20 | HEALTH_FULL_RECONNECT_THRESHOLD=3 21 | HEALTH_FULL_RECONNECT_COOLDOWN_MS=600000 22 | HEALTH_API_PORT=8082 -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | # TradingView API Configuration 2 | TV_API_PROXY= 3 | TV_API_TIMEOUT_MS=10000 4 | 5 | # Initial Subscriptions (JSON array) 6 | SUBSCRIPTIONS=[{"symbol":"BINANCE:BTCUSDT","timeframe":"1"}] 7 | 8 | # Backend Integration 9 | BACKEND_ENDPOINT= 10 | BACKEND_API_KEY= 11 | 12 | # WebSocket Configuration 13 | WEBSOCKET_PORT=8081 14 | WEBSOCKET_ENABLED=true 15 | 16 | # Metrics 17 | METRICS_PORT=9100 18 | 19 | # Logging 20 | LOG_LEVEL=info 21 | LOG_FILE=./logs/tv-fetcher.log 22 | DEBUG_PRICES=false 23 | PRICES_LOG_FILE=./logs/prices.log 24 | 25 | # Health Monitoring System 26 | HEALTH_CHECK_INTERVAL_MS=60000 27 | HEALTH_STALE_THRESHOLD_MULTIPLIER=3 28 | HEALTH_AUTO_RECOVERY_ENABLED=true 29 | HEALTH_MAX_RECOVERY_ATTEMPTS=3 30 | HEALTH_FULL_RECONNECT_THRESHOLD=3 31 | HEALTH_FULL_RECONNECT_COOLDOWN_MS=600000 32 | HEALTH_API_PORT=8082 -------------------------------------------------------------------------------- /examples/node_modules/.package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "examples", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "node_modules/ws": { 8 | "version": "8.18.2", 9 | "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz", 10 | "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", 11 | "license": "MIT", 12 | "engines": { 13 | "node": ">=10.0.0" 14 | }, 15 | "peerDependencies": { 16 | "bufferutil": "^4.0.1", 17 | "utf-8-validate": ">=5.0.2" 18 | }, 19 | "peerDependenciesMeta": { 20 | "bufferutil": { 21 | "optional": true 22 | }, 23 | "utf-8-validate": { 24 | "optional": true 25 | } 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Stage 1: Build 2 | FROM node:20-alpine AS build 3 | 4 | WORKDIR /app 5 | 6 | # Install dependencies including TypeScript 7 | COPY package*.json ./ 8 | RUN npm ci 9 | 10 | # Install TypeScript globally 11 | RUN npm install -g typescript 12 | 13 | # Copy source code 14 | COPY . . 15 | 16 | # Build the application 17 | RUN tsc --project tsconfig.json --skipLibCheck 18 | 19 | # Stage 2: Production 20 | FROM node:20-alpine 21 | 22 | WORKDIR /app 23 | 24 | # Copy only production dependencies 25 | COPY package*.json ./ 26 | RUN npm ci --omit=dev 27 | 28 | # Copy built application and assets 29 | COPY --from=build /app/dist ./dist 30 | COPY --from=build /app/tradingview_vendor ./tradingview_vendor 31 | COPY --from=build /app/examples ./examples 32 | 33 | # Create logs directory 34 | RUN mkdir -p logs 35 | 36 | EXPOSE 8081 9100 37 | 38 | CMD ["node", "dist/main.js"] -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tv-fetcher", 3 | "version": "1.0.0", 4 | "main": "dist/main.js", 5 | "scripts": { 6 | "start": "node dist/main.js", 7 | "dev": "ts-node src/main.ts", 8 | "test": "jest", 9 | "build": "npx tsc --project tsconfig.json --skipLibCheck", 10 | "test:ws": "node examples/websocket-client.js" 11 | }, 12 | "dependencies": { 13 | "@types/ws": "^8.18.1", 14 | "axios": "^1.6.0", 15 | "dotenv": "^16.5.0", 16 | "express": "^5.1.0", 17 | "express-ws": "^5.0.2", 18 | "https-proxy-agent": "^7.0.6", 19 | "jszip": "^3.10.1", 20 | "prom-client": "^14.0.0", 21 | "socks-proxy-agent": "^8.0.5", 22 | "winston": "^3.17.0", 23 | "ws": "^8.18.2" 24 | }, 25 | "devDependencies": { 26 | "@types/express": "^4.17.21", 27 | "@types/node": "^22.15.19", 28 | "jest": "^29.0.0", 29 | "ts-node": "^10.0.0", 30 | "typescript": "^5.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/node_modules/ws/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2011 Einar Otto Stangvik 2 | Copyright (c) 2013 Arnout Kazemier and contributors 3 | Copyright (c) 2016 Luigi Pinca and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /examples/node_modules/ws/lib/limiter.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const kDone = Symbol('kDone'); 4 | const kRun = Symbol('kRun'); 5 | 6 | /** 7 | * A very simple job queue with adjustable concurrency. Adapted from 8 | * https://github.com/STRML/async-limiter 9 | */ 10 | class Limiter { 11 | /** 12 | * Creates a new `Limiter`. 13 | * 14 | * @param {Number} [concurrency=Infinity] The maximum number of jobs allowed 15 | * to run concurrently 16 | */ 17 | constructor(concurrency) { 18 | this[kDone] = () => { 19 | this.pending--; 20 | this[kRun](); 21 | }; 22 | this.concurrency = concurrency || Infinity; 23 | this.jobs = []; 24 | this.pending = 0; 25 | } 26 | 27 | /** 28 | * Adds a job to the queue. 29 | * 30 | * @param {Function} job The job to run 31 | * @public 32 | */ 33 | add(job) { 34 | this.jobs.push(job); 35 | this[kRun](); 36 | } 37 | 38 | /** 39 | * Removes a job from the queue and runs it if possible. 40 | * 41 | * @private 42 | */ 43 | [kRun]() { 44 | if (this.pending === this.concurrency) return; 45 | 46 | if (this.jobs.length) { 47 | const job = this.jobs.shift(); 48 | 49 | this.pending++; 50 | job(this[kDone]); 51 | } 52 | } 53 | } 54 | 55 | module.exports = Limiter; 56 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import winston from 'winston'; 2 | import { config } from './config'; 3 | 4 | const transports = [ 5 | new winston.transports.Console({ 6 | format: winston.format.combine( 7 | winston.format.colorize(), 8 | winston.format.simple() 9 | ), 10 | }), 11 | new winston.transports.File({ 12 | filename: config.log.file, 13 | level: config.log.level, 14 | maxsize: 5 * 1024 * 1024, // 5MB 15 | maxFiles: 5, 16 | format: winston.format.combine( 17 | winston.format.timestamp(), 18 | winston.format.json() 19 | ), 20 | }), 21 | ]; 22 | 23 | export const logger = winston.createLogger({ 24 | level: config.log.level, 25 | format: winston.format.combine( 26 | winston.format.timestamp(), 27 | winston.format.errors({ stack: true }), 28 | winston.format.splat(), 29 | winston.format.json() 30 | ), 31 | transports, 32 | }); 33 | 34 | let priceLogger = logger; 35 | if (config.debugPrices) { 36 | priceLogger = winston.createLogger({ 37 | level: 'info', 38 | format: winston.format.combine( 39 | winston.format.timestamp(), 40 | winston.format.printf(({ timestamp, message }) => `${timestamp} ${message}`) 41 | ), 42 | transports: [ 43 | new winston.transports.File({ 44 | filename: config.pricesLogFile, 45 | maxsize: 5 * 1024 * 1024, 46 | maxFiles: 5, 47 | }) 48 | ] 49 | }); 50 | } 51 | export { priceLogger }; -------------------------------------------------------------------------------- /tradingview_vendor/protocol.js: -------------------------------------------------------------------------------- 1 | const JSZip = require('jszip'); 2 | 3 | /** 4 | * @typedef {Object} TWPacket 5 | * @prop {string} [m] Packet type 6 | * @prop {[session: string, {}]} [p] Packet data 7 | */ 8 | 9 | const cleanerRgx = /~h~/g; 10 | const splitterRgx = /~m~[0-9]{1,}~m~/g; 11 | 12 | module.exports = { 13 | /** 14 | * Parse websocket packet 15 | * @function parseWSPacket 16 | * @param {string} str Websocket raw data 17 | * @returns {TWPacket[]} TradingView packets 18 | */ 19 | parseWSPacket(str) { 20 | str = str.toString(); // Приведение к строке для совместимости 21 | return str.replace(cleanerRgx, '').split(splitterRgx) 22 | .map((p) => { 23 | if (!p) return false; 24 | try { 25 | return JSON.parse(p); 26 | } catch (error) { 27 | console.warn('Cant parse', p); 28 | return false; 29 | } 30 | }) 31 | .filter((p) => p); 32 | }, 33 | 34 | /** 35 | * Format websocket packet 36 | * @function formatWSPacket 37 | * @param {TWPacket} packet TradingView packet 38 | * @returns {string} Websocket raw data 39 | */ 40 | formatWSPacket(packet) { 41 | const msg = typeof packet === 'object' 42 | ? JSON.stringify(packet) 43 | : packet; 44 | return `~m~${msg.length}~m~${msg}`; 45 | }, 46 | 47 | /** 48 | * Parse compressed data 49 | * @function parseCompressed 50 | * @param {string} data Compressed data 51 | * @returns {Promise<{}>} Parsed data 52 | */ 53 | async parseCompressed(data) { 54 | const zip = new JSZip(); 55 | return JSON.parse( 56 | await ( 57 | await zip.loadAsync(data, { base64: true }) 58 | ).file('').async('text'), 59 | ); 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /examples/node_modules/ws/lib/subprotocol.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { tokenChars } = require('./validation'); 4 | 5 | /** 6 | * Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names. 7 | * 8 | * @param {String} header The field value of the header 9 | * @return {Set} The subprotocol names 10 | * @public 11 | */ 12 | function parse(header) { 13 | const protocols = new Set(); 14 | let start = -1; 15 | let end = -1; 16 | let i = 0; 17 | 18 | for (i; i < header.length; i++) { 19 | const code = header.charCodeAt(i); 20 | 21 | if (end === -1 && tokenChars[code] === 1) { 22 | if (start === -1) start = i; 23 | } else if ( 24 | i !== 0 && 25 | (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */ 26 | ) { 27 | if (end === -1 && start !== -1) end = i; 28 | } else if (code === 0x2c /* ',' */) { 29 | if (start === -1) { 30 | throw new SyntaxError(`Unexpected character at index ${i}`); 31 | } 32 | 33 | if (end === -1) end = i; 34 | 35 | const protocol = header.slice(start, end); 36 | 37 | if (protocols.has(protocol)) { 38 | throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); 39 | } 40 | 41 | protocols.add(protocol); 42 | start = end = -1; 43 | } else { 44 | throw new SyntaxError(`Unexpected character at index ${i}`); 45 | } 46 | } 47 | 48 | if (start === -1 || end !== -1) { 49 | throw new SyntaxError('Unexpected end of input'); 50 | } 51 | 52 | const protocol = header.slice(start, i); 53 | 54 | if (protocols.has(protocol)) { 55 | throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`); 56 | } 57 | 58 | protocols.add(protocol); 59 | return protocols; 60 | } 61 | 62 | module.exports = { parse }; 63 | -------------------------------------------------------------------------------- /tradingview_vendor/types.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {string} MarketSymbol Market symbol (like: 'BTCEUR' or 'KRAKEN:BTCEUR') 3 | */ 4 | 5 | /** 6 | * @typedef {'Etc/UTC' | 'exchange' 7 | * | 'Pacific/Honolulu' | 'America/Juneau' | 'America/Los_Angeles' 8 | * | 'America/Phoenix' | 'America/Vancouver' | 'US/Mountain' 9 | * | 'America/El_Salvador' | 'America/Bogota' | 'America/Chicago' 10 | * | 'America/Lima' | 'America/Mexico_City' | 'America/Caracas' 11 | * | 'America/New_York' | 'America/Toronto' | 'America/Argentina/Buenos_Aires' 12 | * | 'America/Santiago' | 'America/Sao_Paulo' | 'Atlantic/Reykjavik' 13 | * | 'Europe/Dublin' | 'Africa/Lagos' | 'Europe/Lisbon' | 'Europe/London' 14 | * | 'Europe/Amsterdam' | 'Europe/Belgrade' | 'Europe/Berlin' 15 | * | 'Europe/Brussels' | 'Europe/Copenhagen' | 'Africa/Johannesburg' 16 | * | 'Africa/Cairo' | 'Europe/Luxembourg' | 'Europe/Madrid' | 'Europe/Malta' 17 | * | 'Europe/Oslo' | 'Europe/Paris' | 'Europe/Rome' | 'Europe/Stockholm' 18 | * | 'Europe/Warsaw' | 'Europe/Zurich' | 'Europe/Athens' | 'Asia/Bahrain' 19 | * | 'Europe/Helsinki' | 'Europe/Istanbul' | 'Asia/Jerusalem' | 'Asia/Kuwait' 20 | * | 'Europe/Moscow' | 'Asia/Qatar' | 'Europe/Riga' | 'Asia/Riyadh' 21 | * | 'Europe/Tallinn' | 'Europe/Vilnius' | 'Asia/Tehran' | 'Asia/Dubai' 22 | * | 'Asia/Muscat' | 'Asia/Ashkhabad' | 'Asia/Kolkata' | 'Asia/Almaty' 23 | * | 'Asia/Bangkok' | 'Asia/Jakarta' | 'Asia/Ho_Chi_Minh' | 'Asia/Chongqing' 24 | * | 'Asia/Hong_Kong' | 'Australia/Perth' | 'Asia/Shanghai' | 'Asia/Singapore' 25 | * | 'Asia/Taipei' | 'Asia/Seoul' | 'Asia/Tokyo' | 'Australia/Brisbane' 26 | * | 'Australia/Adelaide' | 'Australia/Sydney' | 'Pacific/Norfolk' 27 | * | 'Pacific/Auckland' | 'Pacific/Fakaofo' | 'Pacific/Chatham'} Timezone (Chart) timezone 28 | */ 29 | 30 | /** 31 | * @typedef {'1' | '3' | '5' | '15' | '30' 32 | * | '45' | '60' | '120' | '180' | '240' 33 | * | '1D' | '1W' | '1M' | 'D' | 'W' | 'M'} TimeFrame 34 | */ 35 | 36 | module.exports = {}; 37 | -------------------------------------------------------------------------------- /examples/node_modules/ws/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ws", 3 | "version": "8.18.2", 4 | "description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js", 5 | "keywords": [ 6 | "HyBi", 7 | "Push", 8 | "RFC-6455", 9 | "WebSocket", 10 | "WebSockets", 11 | "real-time" 12 | ], 13 | "homepage": "https://github.com/websockets/ws", 14 | "bugs": "https://github.com/websockets/ws/issues", 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/websockets/ws.git" 18 | }, 19 | "author": "Einar Otto Stangvik (http://2x.io)", 20 | "license": "MIT", 21 | "main": "index.js", 22 | "exports": { 23 | ".": { 24 | "browser": "./browser.js", 25 | "import": "./wrapper.mjs", 26 | "require": "./index.js" 27 | }, 28 | "./package.json": "./package.json" 29 | }, 30 | "browser": "browser.js", 31 | "engines": { 32 | "node": ">=10.0.0" 33 | }, 34 | "files": [ 35 | "browser.js", 36 | "index.js", 37 | "lib/*.js", 38 | "wrapper.mjs" 39 | ], 40 | "scripts": { 41 | "test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js", 42 | "integration": "mocha --throw-deprecation test/*.integration.js", 43 | "lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\"" 44 | }, 45 | "peerDependencies": { 46 | "bufferutil": "^4.0.1", 47 | "utf-8-validate": ">=5.0.2" 48 | }, 49 | "peerDependenciesMeta": { 50 | "bufferutil": { 51 | "optional": true 52 | }, 53 | "utf-8-validate": { 54 | "optional": true 55 | } 56 | }, 57 | "devDependencies": { 58 | "benchmark": "^2.1.4", 59 | "bufferutil": "^4.0.1", 60 | "eslint": "^9.0.0", 61 | "eslint-config-prettier": "^10.0.1", 62 | "eslint-plugin-prettier": "^5.0.0", 63 | "globals": "^16.0.0", 64 | "mocha": "^8.4.0", 65 | "nyc": "^15.0.0", 66 | "prettier": "^3.0.0", 67 | "utf-8-validate": "^6.0.0" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/push.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { config } from './config'; 3 | import { logger } from './logger'; 4 | import { barsPushedTotal, httpPushLatency } from './metrics'; 5 | import type { Bar } from './tradingview'; 6 | import type { WebSocketServer } from './websocket'; 7 | import { TradingViewClient } from './tradingview'; 8 | 9 | // Optional WebSocket server 10 | let wsServer: WebSocketServer | null = null; 11 | 12 | // TradingViewClient singleton 13 | let tvClient: TradingViewClient | null = null; 14 | 15 | // Set WebSocket server 16 | export function setWebSocketServer(server: WebSocketServer) { 17 | wsServer = server; 18 | logger.info('WebSocket server set for push service'); 19 | } 20 | 21 | export function setTradingViewClient(client: TradingViewClient) { 22 | tvClient = client; 23 | logger.info('TradingView client set for push service'); 24 | } 25 | 26 | export function getTradingViewClient(): TradingViewClient | null { 27 | return tvClient; 28 | } 29 | 30 | // Function to push a bar to API and WebSocket clients 31 | export async function pushBar(bar: Bar) { 32 | // If WebSocket server is set, broadcast bar to clients 33 | if (wsServer) { 34 | wsServer.broadcastBar(bar); 35 | } 36 | 37 | // If backend endpoint is not configured, do not push via HTTP 38 | if (!config.backend.endpoint) { 39 | return; 40 | } 41 | 42 | const payload = { 43 | symbol: bar.symbol, 44 | time: bar.time, 45 | open: bar.open, 46 | high: bar.high, 47 | low: bar.low, 48 | close: bar.close, 49 | volume: bar.volume, 50 | timeframe: bar.timeframe, 51 | }; 52 | const headers = { 53 | 'Content-Type': 'application/json', 54 | 'X-Api-Key': config.backend.apiKey, 55 | }; 56 | let attempt = 0; 57 | const maxAttempts = 1 + (config as any).retry?.httpRetry?.attempts || 3; 58 | const backoffSec = (config as any).retry?.httpRetry?.backoffSec || 1; 59 | while (attempt < maxAttempts) { 60 | const end = httpPushLatency.startTimer(); 61 | try { 62 | await axios.post(config.backend.endpoint, payload, { headers }); 63 | barsPushedTotal.inc(); 64 | logger.debug('Pushed bar: %o', payload); 65 | end(); 66 | return; 67 | } catch (err) { 68 | end(); 69 | logger.error('Failed to push bar (attempt %d): %s', attempt + 1, (err as Error).message); 70 | attempt++; 71 | if (attempt < maxAttempts) await new Promise(res => setTimeout(res, backoffSec * 1000)); 72 | } 73 | } 74 | logger.error('Giving up on pushing bar after %d attempts', maxAttempts); 75 | } -------------------------------------------------------------------------------- /examples/websocket-client.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws'); 2 | 3 | // Connect to WebSocket server 4 | const ws = new WebSocket('ws://localhost:8081'); 5 | 6 | // Handle connection open 7 | ws.on('open', function open() { 8 | console.log('Connected to TradingView Fetcher WebSocket API'); 9 | 10 | // Request subscription list 11 | console.log('Requesting subscription list...'); 12 | ws.send(JSON.stringify({ 13 | action: 'list', 14 | requestId: 'initial-list' 15 | })); 16 | 17 | // After 3 seconds, subscribe to BINANCE:XRPUSDT 18 | setTimeout(() => { 19 | console.log('Subscribing to BINANCE:XRPUSDT...'); 20 | ws.send(JSON.stringify({ 21 | action: 'subscribe', 22 | symbol: 'BINANCE:XRPUSDT', 23 | timeframe: '5', // 5 minutes 24 | requestId: 'sub-xrp' 25 | })); 26 | }, 3000); 27 | 28 | // After 10 seconds, unsubscribe from BINANCE:XRPUSDT 29 | setTimeout(() => { 30 | console.log('Unsubscribing from BINANCE:XRPUSDT...'); 31 | ws.send(JSON.stringify({ 32 | action: 'unsubscribe', 33 | symbol: 'BINANCE:XRPUSDT', 34 | timeframe: '5', 35 | requestId: 'unsub-xrp' 36 | })); 37 | }, 10000); 38 | 39 | // After 15 seconds, request updated subscription list 40 | setTimeout(() => { 41 | console.log('Requesting updated subscription list...'); 42 | ws.send(JSON.stringify({ 43 | action: 'list', 44 | requestId: 'final-list' 45 | })); 46 | }, 15000); 47 | }); 48 | 49 | // Bar counter 50 | let barCount = 0; 51 | 52 | // Handle incoming messages 53 | ws.on('message', function incoming(data) { 54 | const message = JSON.parse(data); 55 | 56 | if (message.type === 'bar') { 57 | barCount++; 58 | console.log(`Received bar #${barCount}: ${message.bar.symbol}/${message.bar.timeframe} - closing price: ${message.bar.close}`); 59 | // Limit number of messages 60 | if (barCount > 15) { 61 | console.log('Received enough bars, closing connection'); 62 | ws.close(); 63 | } 64 | } else { 65 | // For non-bar messages, print full content 66 | console.log('Received message:', message); 67 | } 68 | }); 69 | 70 | // Handle errors 71 | ws.on('error', function error(err) { 72 | console.error('WebSocket error:', err); 73 | }); 74 | 75 | // Handle connection close 76 | ws.on('close', function close() { 77 | console.log('Connection closed'); 78 | process.exit(0); 79 | }); 80 | 81 | // Handle Ctrl+C 82 | process.on('SIGINT', () => { 83 | console.log('Interrupt, closing connection'); 84 | ws.close(); 85 | process.exit(0); 86 | }); -------------------------------------------------------------------------------- /src/metrics.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { Registry, Counter, Gauge, Histogram } from 'prom-client'; 3 | import { logger } from './logger'; 4 | 5 | // Create a registry 6 | const registry = new Registry(); 7 | 8 | // Collect default metrics 9 | registry.setDefaultLabels({ 10 | app: 'tv-fetcher' 11 | }); 12 | 13 | // Bars pushed counter 14 | export const barsPushedTotal = new Counter({ 15 | name: 'bars_pushed_total', 16 | help: 'Total number of bars pushed to backend', 17 | registers: [registry] 18 | }); 19 | 20 | // WebSocket connections counter 21 | export const wsConnectsTotal = new Counter({ 22 | name: 'ws_connects_total', 23 | help: 'Total number of TradingView WebSocket connections', 24 | registers: [registry] 25 | }); 26 | 27 | // WebSocket error counter 28 | export const wsErrorsTotal = new Counter({ 29 | name: 'ws_errors_total', 30 | help: 'Total number of TradingView WebSocket errors', 31 | registers: [registry] 32 | }); 33 | 34 | // Active subscriptions gauge 35 | export const subscriptionsGauge = new Gauge({ 36 | name: 'active_subscriptions', 37 | help: 'Number of active TradingView subscriptions', 38 | registers: [registry] 39 | }); 40 | 41 | // HTTP push latency 42 | export const httpPushLatency = new Histogram({ 43 | name: 'http_push_latency_seconds', 44 | help: 'Latency of HTTP push requests to backend', 45 | buckets: [0.01, 0.05, 0.1, 0.5, 1, 5], 46 | registers: [registry] 47 | }); 48 | 49 | // Health metrics 50 | export const staleSubscriptionsGauge = new Gauge({ 51 | name: 'stale_subscriptions', 52 | help: 'Number of stale subscriptions detected', 53 | registers: [registry] 54 | }); 55 | 56 | export const recoveryAttemptsTotal = new Counter({ 57 | name: 'recovery_attempts_total', 58 | help: 'Total number of recovery attempts', 59 | registers: [registry] 60 | }); 61 | 62 | export const successfulRecoveriesTotal = new Counter({ 63 | name: 'successful_recoveries_total', 64 | help: 'Total number of successful recoveries', 65 | registers: [registry] 66 | }); 67 | 68 | export const failedRecoveriesTotal = new Counter({ 69 | name: 'failed_recoveries_total', 70 | help: 'Total number of failed recoveries', 71 | registers: [registry] 72 | }); 73 | 74 | export const fullReconnectsTotal = new Counter({ 75 | name: 'full_reconnects_total', 76 | help: 'Total number of full TradingView reconnections', 77 | registers: [registry] 78 | }); 79 | 80 | export const lastDataReceivedGauge = new Gauge({ 81 | name: 'last_data_received_seconds', 82 | help: 'Time since last data was received for a subscription', 83 | labelNames: ['symbol', 'timeframe'], 84 | registers: [registry] 85 | }); 86 | 87 | // Function to start metrics server 88 | export function startMetricsServer(port: number) { 89 | const app = express(); 90 | 91 | // Metrics endpoint 92 | app.get('/metrics', async (req, res) => { 93 | res.set('Content-Type', registry.contentType); 94 | res.end(await registry.metrics()); 95 | }); 96 | 97 | // Start server 98 | app.listen(port, () => { 99 | logger.info(`Metrics server started on port ${port}`); 100 | }); 101 | } -------------------------------------------------------------------------------- /tradingview_vendor/quote/market.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {'loaded' | 'data' | 'error'} MarketEvent 3 | */ 4 | 5 | /** 6 | * @param {import('./session').QuoteSessionBridge} quoteSession 7 | */ 8 | 9 | module.exports = (quoteSession) => class QuoteMarket { 10 | #symbolListeners = quoteSession.symbolListeners; 11 | 12 | #symbol; 13 | 14 | #session; 15 | 16 | #symbolKey; 17 | 18 | #symbolListenerID = 0; 19 | 20 | #lastData = {}; 21 | 22 | #callbacks = { 23 | loaded: [], 24 | data: [], 25 | 26 | event: [], 27 | error: [], 28 | }; 29 | 30 | /** 31 | * @param {MarketEvent} ev Client event 32 | * @param {...{}} data Packet data 33 | */ 34 | #handleEvent(ev, ...data) { 35 | this.#callbacks[ev].forEach((e) => e(...data)); 36 | this.#callbacks.event.forEach((e) => e(ev, ...data)); 37 | } 38 | 39 | #handleError(...msgs) { 40 | if (this.#callbacks.error.length === 0) console.error(...msgs); 41 | else this.#handleEvent('error', ...msgs); 42 | } 43 | 44 | /** 45 | * @param {string} symbol Market symbol (like: 'BTCEUR' or 'KRAKEN:BTCEUR') 46 | * @param {string} session Market session (like: 'regular' or 'extended') 47 | */ 48 | constructor(symbol, session = 'regular') { 49 | this.#symbol = symbol; 50 | this.#session = session; 51 | this.#symbolKey = `=${JSON.stringify({ session, symbol })}`; 52 | 53 | if (!this.#symbolListeners[this.#symbolKey]) { 54 | this.#symbolListeners[this.#symbolKey] = []; 55 | quoteSession.send('quote_add_symbols', [ 56 | quoteSession.sessionID, 57 | this.#symbolKey, 58 | ]); 59 | } 60 | 61 | this.#symbolListenerID = this.#symbolListeners[this.#symbolKey].length; 62 | 63 | this.#symbolListeners[this.#symbolKey][this.#symbolListenerID] = (packet) => { 64 | if (global.TW_DEBUG) console.log('§90§30§105 MARKET §0 DATA', packet); 65 | 66 | if (packet.type === 'qsd' && packet.data[1].s === 'ok') { 67 | this.#lastData = { 68 | ...this.#lastData, 69 | ...packet.data[1].v, 70 | }; 71 | this.#handleEvent('data', this.#lastData); 72 | return; 73 | } 74 | 75 | if (packet.type === 'quote_completed') { 76 | this.#handleEvent('loaded'); 77 | return; 78 | } 79 | 80 | if (packet.type === 'qsd' && packet.data[1].s === 'error') { 81 | this.#handleError('Market error', packet.data); 82 | } 83 | }; 84 | } 85 | 86 | /** 87 | * When quote market is loaded 88 | * @param {() => void} cb Callback 89 | * @event 90 | */ 91 | onLoaded(cb) { 92 | this.#callbacks.loaded.push(cb); 93 | } 94 | 95 | /** 96 | * When quote data is received 97 | * @param {(data: {}) => void} cb Callback 98 | * @event 99 | */ 100 | onData(cb) { 101 | this.#callbacks.data.push(cb); 102 | } 103 | 104 | /** 105 | * When quote event happens 106 | * @param {(...any) => void} cb Callback 107 | * @event 108 | */ 109 | onEvent(cb) { 110 | this.#callbacks.event.push(cb); 111 | } 112 | 113 | /** 114 | * When quote error happens 115 | * @param {(...any) => void} cb Callback 116 | * @event 117 | */ 118 | onError(cb) { 119 | this.#callbacks.error.push(cb); 120 | } 121 | 122 | /** Close this listener */ 123 | close() { 124 | if (this.#symbolListeners[this.#symbolKey].length <= 1) { 125 | quoteSession.send('quote_remove_symbols', [ 126 | quoteSession.sessionID, 127 | this.#symbolKey, 128 | ]); 129 | } 130 | delete this.#symbolListeners[this.#symbolKey][this.#symbolListenerID]; 131 | } 132 | }; 133 | -------------------------------------------------------------------------------- /examples/node_modules/ws/lib/buffer-util.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { EMPTY_BUFFER } = require('./constants'); 4 | 5 | const FastBuffer = Buffer[Symbol.species]; 6 | 7 | /** 8 | * Merges an array of buffers into a new buffer. 9 | * 10 | * @param {Buffer[]} list The array of buffers to concat 11 | * @param {Number} totalLength The total length of buffers in the list 12 | * @return {Buffer} The resulting buffer 13 | * @public 14 | */ 15 | function concat(list, totalLength) { 16 | if (list.length === 0) return EMPTY_BUFFER; 17 | if (list.length === 1) return list[0]; 18 | 19 | const target = Buffer.allocUnsafe(totalLength); 20 | let offset = 0; 21 | 22 | for (let i = 0; i < list.length; i++) { 23 | const buf = list[i]; 24 | target.set(buf, offset); 25 | offset += buf.length; 26 | } 27 | 28 | if (offset < totalLength) { 29 | return new FastBuffer(target.buffer, target.byteOffset, offset); 30 | } 31 | 32 | return target; 33 | } 34 | 35 | /** 36 | * Masks a buffer using the given mask. 37 | * 38 | * @param {Buffer} source The buffer to mask 39 | * @param {Buffer} mask The mask to use 40 | * @param {Buffer} output The buffer where to store the result 41 | * @param {Number} offset The offset at which to start writing 42 | * @param {Number} length The number of bytes to mask. 43 | * @public 44 | */ 45 | function _mask(source, mask, output, offset, length) { 46 | for (let i = 0; i < length; i++) { 47 | output[offset + i] = source[i] ^ mask[i & 3]; 48 | } 49 | } 50 | 51 | /** 52 | * Unmasks a buffer using the given mask. 53 | * 54 | * @param {Buffer} buffer The buffer to unmask 55 | * @param {Buffer} mask The mask to use 56 | * @public 57 | */ 58 | function _unmask(buffer, mask) { 59 | for (let i = 0; i < buffer.length; i++) { 60 | buffer[i] ^= mask[i & 3]; 61 | } 62 | } 63 | 64 | /** 65 | * Converts a buffer to an `ArrayBuffer`. 66 | * 67 | * @param {Buffer} buf The buffer to convert 68 | * @return {ArrayBuffer} Converted buffer 69 | * @public 70 | */ 71 | function toArrayBuffer(buf) { 72 | if (buf.length === buf.buffer.byteLength) { 73 | return buf.buffer; 74 | } 75 | 76 | return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length); 77 | } 78 | 79 | /** 80 | * Converts `data` to a `Buffer`. 81 | * 82 | * @param {*} data The data to convert 83 | * @return {Buffer} The buffer 84 | * @throws {TypeError} 85 | * @public 86 | */ 87 | function toBuffer(data) { 88 | toBuffer.readOnly = true; 89 | 90 | if (Buffer.isBuffer(data)) return data; 91 | 92 | let buf; 93 | 94 | if (data instanceof ArrayBuffer) { 95 | buf = new FastBuffer(data); 96 | } else if (ArrayBuffer.isView(data)) { 97 | buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength); 98 | } else { 99 | buf = Buffer.from(data); 100 | toBuffer.readOnly = false; 101 | } 102 | 103 | return buf; 104 | } 105 | 106 | module.exports = { 107 | concat, 108 | mask: _mask, 109 | toArrayBuffer, 110 | toBuffer, 111 | unmask: _unmask 112 | }; 113 | 114 | /* istanbul ignore else */ 115 | if (!process.env.WS_NO_BUFFER_UTIL) { 116 | try { 117 | const bufferUtil = require('bufferutil'); 118 | 119 | module.exports.mask = function (source, mask, output, offset, length) { 120 | if (length < 48) _mask(source, mask, output, offset, length); 121 | else bufferUtil.mask(source, mask, output, offset, length); 122 | }; 123 | 124 | module.exports.unmask = function (buffer, mask) { 125 | if (buffer.length < 32) _unmask(buffer, mask); 126 | else bufferUtil.unmask(buffer, mask); 127 | }; 128 | } catch (e) { 129 | // Continue regardless of the error. 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import { type HealthMonitorConfig } from './health'; 3 | 4 | dotenv.config(); 5 | 6 | export interface Subscription { 7 | symbol: string; 8 | timeframe: string; 9 | } 10 | 11 | export interface Config { 12 | tvApi: { 13 | proxy: string | null; 14 | timeoutMs: number; 15 | }; 16 | subscriptions: Subscription[]; 17 | backend: { 18 | endpoint: string; 19 | apiKey: string; 20 | }; 21 | metrics: { 22 | port: number; 23 | }; 24 | log: { 25 | level: string; 26 | file: string; 27 | }; 28 | websocket: { 29 | port: number; 30 | enabled: boolean; 31 | }; 32 | debugPrices: boolean; 33 | pricesLogFile: string; 34 | health: HealthMonitorConfig; 35 | } 36 | 37 | // Default configuration values for health monitoring 38 | const DEFAULT_HEALTH_CONFIG = { 39 | checkIntervalMs: 60000, // Check every minute 40 | staleThresholdMultiplier: 3, // Consider stale after 3x the expected interval 41 | autoRecoveryEnabled: true, // Try to recover automatically 42 | maxRecoveryAttempts: 3, // Max 3 recovery attempts per subscription 43 | fullReconnectThreshold: 3, // Number of stale subscriptions that triggers a full reconnect 44 | fullReconnectCooldownMs: 600000, // 10 minutes between full reconnects 45 | apiPort: 8082, // Health API port 46 | }; 47 | 48 | function parseSubscriptions(): Subscription[] { 49 | const raw = process.env.SUBSCRIPTIONS; 50 | if (!raw) return []; 51 | try { 52 | return JSON.parse(raw); 53 | } catch { 54 | throw new Error('SUBSCRIPTIONS must be valid JSON'); 55 | } 56 | } 57 | 58 | function normalizeTimeframe(sub: Subscription): Subscription { 59 | let { timeframe } = sub; 60 | 61 | // TradingView API specifics: '1m' -> '1', '1h' -> '60', etc. 62 | if (timeframe.endsWith('m')) { 63 | timeframe = timeframe.replace('m', ''); 64 | } else if (timeframe.endsWith('h')) { 65 | timeframe = (parseInt(timeframe) * 60).toString(); 66 | } else if (timeframe === '1d' || timeframe === 'd') { 67 | timeframe = 'D'; 68 | } else if (timeframe === '1w' || timeframe === 'w') { 69 | timeframe = 'W'; 70 | } else if (timeframe === '1M' || timeframe === 'M') { 71 | timeframe = 'M'; 72 | } 73 | 74 | return { ...sub, timeframe }; 75 | } 76 | 77 | // Parse health monitor config from environment variables 78 | export function getHealthMonitorConfig(): HealthMonitorConfig { 79 | return { 80 | checkIntervalMs: parseInt(process.env.HEALTH_CHECK_INTERVAL_MS || '') || DEFAULT_HEALTH_CONFIG.checkIntervalMs, 81 | staleThresholdMultiplier: parseFloat(process.env.HEALTH_STALE_THRESHOLD_MULTIPLIER || '') || DEFAULT_HEALTH_CONFIG.staleThresholdMultiplier, 82 | autoRecoveryEnabled: process.env.HEALTH_AUTO_RECOVERY_ENABLED !== 'false', 83 | maxRecoveryAttempts: parseInt(process.env.HEALTH_MAX_RECOVERY_ATTEMPTS || '') || DEFAULT_HEALTH_CONFIG.maxRecoveryAttempts, 84 | fullReconnectThreshold: parseInt(process.env.HEALTH_FULL_RECONNECT_THRESHOLD || '') || DEFAULT_HEALTH_CONFIG.fullReconnectThreshold, 85 | fullReconnectCooldownMs: parseInt(process.env.HEALTH_FULL_RECONNECT_COOLDOWN_MS || '') || DEFAULT_HEALTH_CONFIG.fullReconnectCooldownMs, 86 | apiPort: parseInt(process.env.HEALTH_API_PORT || '') || DEFAULT_HEALTH_CONFIG.apiPort, 87 | }; 88 | } 89 | 90 | export const config: Config = { 91 | tvApi: { 92 | proxy: process.env.TV_API_PROXY || null, 93 | timeoutMs: Number(process.env.TV_API_TIMEOUT_MS) || 10000, 94 | }, 95 | subscriptions: parseSubscriptions().map(normalizeTimeframe), 96 | backend: { 97 | endpoint: process.env.BACKEND_ENDPOINT || '', 98 | apiKey: process.env.BACKEND_API_KEY || '', 99 | }, 100 | metrics: { 101 | port: Number(process.env.METRICS_PORT) || 9100, 102 | }, 103 | log: { 104 | level: process.env.LOG_LEVEL || 'info', 105 | file: process.env.LOG_FILE || './logs/tv-fetcher.log', 106 | }, 107 | websocket: { 108 | port: Number(process.env.WEBSOCKET_PORT) || 8081, 109 | enabled: process.env.WEBSOCKET_ENABLED !== 'false', 110 | }, 111 | debugPrices: process.env.DEBUG_PRICES === 'true', 112 | pricesLogFile: process.env.PRICES_LOG_FILE || './logs/prices.log', 113 | health: getHealthMonitorConfig(), 114 | }; -------------------------------------------------------------------------------- /tradingview_vendor/classes/BuiltInIndicator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {'Volume@tv-basicstudies-241' 3 | * | 'VbPFixed@tv-basicstudies-241' 4 | * | 'VbPFixed@tv-basicstudies-241!' 5 | * | 'VbPFixed@tv-volumebyprice-53!' 6 | * | 'VbPSessions@tv-volumebyprice-53' 7 | * | 'VbPSessionsRough@tv-volumebyprice-53!' 8 | * | 'VbPSessionsDetailed@tv-volumebyprice-53!' 9 | * | 'VbPVisible@tv-volumebyprice-53'} BuiltInIndicatorType Built-in indicator type 10 | */ 11 | 12 | /** 13 | * @typedef {'rowsLayout' | 'rows' | 'volume' 14 | * | 'vaVolume' | 'subscribeRealtime' 15 | * | 'first_bar_time' | 'first_visible_bar_time' 16 | * | 'last_bar_time' | 'last_visible_bar_time' 17 | * | 'extendPocRight'} BuiltInIndicatorOption Built-in indicator Option 18 | */ 19 | 20 | const defaultValues = { 21 | 'Volume@tv-basicstudies-241': { 22 | length: 20, 23 | col_prev_close: false, 24 | }, 25 | 'VbPFixed@tv-basicstudies-241': { 26 | rowsLayout: 'Number Of Rows', 27 | rows: 24, 28 | volume: 'Up/Down', 29 | vaVolume: 70, 30 | subscribeRealtime: false, 31 | first_bar_time: NaN, 32 | last_bar_time: Date.now(), 33 | extendToRight: false, 34 | mapRightBoundaryToBarStartTime: true, 35 | }, 36 | 'VbPFixed@tv-basicstudies-241!': { 37 | rowsLayout: 'Number Of Rows', 38 | rows: 24, 39 | volume: 'Up/Down', 40 | vaVolume: 70, 41 | subscribeRealtime: false, 42 | first_bar_time: NaN, 43 | last_bar_time: Date.now(), 44 | }, 45 | 'VbPFixed@tv-volumebyprice-53!': { 46 | rowsLayout: 'Number Of Rows', 47 | rows: 24, 48 | volume: 'Up/Down', 49 | vaVolume: 70, 50 | subscribeRealtime: false, 51 | first_bar_time: NaN, 52 | last_bar_time: Date.now(), 53 | }, 54 | 'VbPSessions@tv-volumebyprice-53': { 55 | rowsLayout: 'Number Of Rows', 56 | rows: 24, 57 | volume: 'Up/Down', 58 | vaVolume: 70, 59 | extendPocRight: false, 60 | }, 61 | 'VbPSessionsRough@tv-volumebyprice-53!': { 62 | volume: 'Up/Down', 63 | vaVolume: 70, 64 | }, 65 | 'VbPSessionsDetailed@tv-volumebyprice-53!': { 66 | volume: 'Up/Down', 67 | vaVolume: 70, 68 | subscribeRealtime: false, 69 | first_visible_bar_time: NaN, 70 | last_visible_bar_time: Date.now(), 71 | }, 72 | 'VbPVisible@tv-volumebyprice-53': { 73 | rowsLayout: 'Number Of Rows', 74 | rows: 24, 75 | volume: 'Up/Down', 76 | vaVolume: 70, 77 | subscribeRealtime: false, 78 | first_visible_bar_time: NaN, 79 | last_visible_bar_time: Date.now(), 80 | }, 81 | }; 82 | 83 | /** @class */ 84 | module.exports = class BuiltInIndicator { 85 | /** @type {BuiltInIndicatorType} */ 86 | #type; 87 | 88 | /** @return {BuiltInIndicatorType} Indicator script */ 89 | get type() { 90 | return this.#type; 91 | } 92 | 93 | /** @type {Object} */ 94 | #options = {}; 95 | 96 | /** @return {Object} Indicator script */ 97 | get options() { 98 | return this.#options; 99 | } 100 | 101 | /** 102 | * @param {BuiltInIndicatorType} type Buit-in indocator raw type 103 | */ 104 | constructor(type = '') { 105 | if (!type) throw new Error(`Wrong buit-in indicator type "${type}".`); 106 | 107 | this.#type = type; 108 | if (defaultValues[type]) this.#options = { ...defaultValues[type] }; 109 | } 110 | 111 | /** 112 | * Set an option 113 | * @param {BuiltInIndicatorOption} key The option you want to change 114 | * @param {*} value The new value of the property 115 | * @param {boolean} FORCE Ignore type and key verifications 116 | */ 117 | setOption(key, value, FORCE = false) { 118 | if (FORCE) { 119 | this.#options[key] = value; 120 | return; 121 | } 122 | 123 | if (defaultValues[this.#type] && defaultValues[this.#type][key] !== undefined) { 124 | const requiredType = typeof defaultValues[this.#type][key]; 125 | const valType = typeof value; 126 | if (requiredType !== valType) { 127 | throw new Error(`Wrong '${key}' value type '${valType}' (must be '${requiredType}')`); 128 | } 129 | } 130 | 131 | if (defaultValues[this.#type] && defaultValues[this.#type][key] === undefined) { 132 | throw new Error(`Option '${key}' is denied with '${this.#type}' indicator`); 133 | } 134 | 135 | this.#options[key] = value; 136 | } 137 | }; 138 | -------------------------------------------------------------------------------- /tradingview_vendor/classes/PineIndicator.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} IndicatorInput 3 | * @property {string} name Input name 4 | * @property {string} inline Input inline name 5 | * @property {string} [internalID] Input internal ID 6 | * @property {string} [tooltip] Input tooltip 7 | * @property {'text' | 'source' | 'integer' 8 | * | 'float' | 'resolution' | 'bool' | 'color' 9 | * } type Input type 10 | * @property {string | number | boolean} value Input default value 11 | * @property {boolean} isHidden If the input is hidden 12 | * @property {boolean} isFake If the input is fake 13 | * @property {string[]} [options] Input options if the input is a select 14 | */ 15 | 16 | /** 17 | * @typedef {Object} Indicator 18 | * @property {string} pineId Indicator ID 19 | * @property {string} pineVersion Indicator version 20 | * @property {string} description Indicator description 21 | * @property {string} shortDescription Indicator short description 22 | * @property {Object} inputs Indicator inputs 23 | * @property {Object} plots Indicator plots 24 | * @property {string} script Indicator script 25 | */ 26 | 27 | /** 28 | * @typedef {'Script@tv-scripting-101!' 29 | * | 'StrategyScript@tv-scripting-101!'} IndicatorType Indicator type 30 | */ 31 | 32 | /** @class */ 33 | module.exports = class PineIndicator { 34 | #options; 35 | 36 | /** @type {IndicatorType} */ 37 | #type = 'Script@tv-scripting-101!'; 38 | 39 | /** @param {Indicator} options Indicator */ 40 | constructor(options) { 41 | this.#options = options; 42 | } 43 | 44 | /** @return {string} Indicator ID */ 45 | get pineId() { 46 | return this.#options.pineId; 47 | } 48 | 49 | /** @return {string} Indicator version */ 50 | get pineVersion() { 51 | return this.#options.pineVersion; 52 | } 53 | 54 | /** @return {string} Indicator description */ 55 | get description() { 56 | return this.#options.description; 57 | } 58 | 59 | /** @return {string} Indicator short description */ 60 | get shortDescription() { 61 | return this.#options.shortDescription; 62 | } 63 | 64 | /** @return {Object} Indicator inputs */ 65 | get inputs() { 66 | return this.#options.inputs; 67 | } 68 | 69 | /** @return {Object} Indicator plots */ 70 | get plots() { 71 | return this.#options.plots; 72 | } 73 | 74 | /** @return {IndicatorType} Indicator script */ 75 | get type() { 76 | return this.#type; 77 | } 78 | 79 | /** 80 | * Set the indicator type 81 | * @param {IndicatorType} type Indicator type 82 | */ 83 | setType(type = 'Script@tv-scripting-101!') { 84 | this.#type = type; 85 | } 86 | 87 | /** @return {string} Indicator script */ 88 | get script() { 89 | return this.#options.script; 90 | } 91 | 92 | /** 93 | * Set an option 94 | * @param {number | string} key The key can be ID of the property (`in_{ID}`), 95 | * the inline name or the internalID. 96 | * @param {*} value The new value of the property 97 | */ 98 | setOption(key, value) { 99 | let propI = ''; 100 | 101 | if (this.#options.inputs[`in_${key}`]) propI = `in_${key}`; 102 | else if (this.#options.inputs[key]) propI = key; 103 | else { 104 | propI = Object.keys(this.#options.inputs).find((I) => ( 105 | this.#options.inputs[I].inline === key 106 | || this.#options.inputs[I].internalID === key 107 | )); 108 | } 109 | 110 | if (propI && this.#options.inputs[propI]) { 111 | const input = this.#options.inputs[propI]; 112 | 113 | const types = { 114 | bool: 'Boolean', 115 | integer: 'Number', 116 | float: 'Number', 117 | text: 'String', 118 | }; 119 | 120 | // eslint-disable-next-line valid-typeof 121 | if (types[input.type] && typeof value !== types[input.type].toLowerCase()) { 122 | throw new Error(`Input '${input.name}' (${propI}) must be a ${types[input.type]} !`); 123 | } 124 | 125 | if (input.options && !input.options.includes(value)) { 126 | throw new Error(`Input '${input.name}' (${propI}) must be one of these values:`, input.options); 127 | } 128 | 129 | input.value = value; 130 | } else throw new Error(`Input '${key}' not found (${propI}).`); 131 | } 132 | }; 133 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { config } from './config'; 2 | import { logger } from './logger'; 3 | import { startMetricsServer } from './metrics'; 4 | import { TradingViewClient } from './tradingview'; 5 | import { pushBar, setWebSocketServer, setTradingViewClient } from './push'; 6 | import { WebSocketServer } from './websocket'; 7 | import { TradingViewHealthMonitor } from './health'; 8 | import { HealthApiServer } from './health-api'; 9 | 10 | logger.info('tv-fetcher starting...'); 11 | logger.info('Config: %o', config); 12 | 13 | // Start metrics server for monitoring 14 | const metricsPort = config.metrics.port; 15 | startMetricsServer(metricsPort); 16 | 17 | // Create TradingView client 18 | let tvClient: TradingViewClient; 19 | 20 | // Health monitor instance 21 | let healthMonitor: TradingViewHealthMonitor; 22 | 23 | // Health API server 24 | let healthApiServer: HealthApiServer; 25 | 26 | // Create WebSocket server if enabled 27 | let wsServer: WebSocketServer | null = null; 28 | if (config.websocket.enabled) { 29 | const wsPort = config.websocket.port; 30 | wsServer = new WebSocketServer(); 31 | setWebSocketServer(wsServer); 32 | 33 | // Handle subscriptions via WebSocket 34 | wsServer.on('subscribe', async (subscription) => { 35 | logger.info('WebSocket requested subscription: %o', subscription); 36 | await tvClient.subscribe(subscription); 37 | }); 38 | 39 | wsServer.on('unsubscribe', async ({ symbol, timeframe }) => { 40 | logger.info('WebSocket requested unsubscription: %s/%s', symbol, timeframe); 41 | await tvClient.unsubscribe(symbol, timeframe); 42 | }); 43 | 44 | logger.info('WebSocket server started on port %d', wsPort); 45 | } 46 | 47 | // Function to start and subscribe to initial symbols 48 | async function start() { 49 | // Create TradingView client 50 | tvClient = new TradingViewClient(); 51 | setTradingViewClient(tvClient); 52 | 53 | // Create health monitor 54 | healthMonitor = new TradingViewHealthMonitor(tvClient, config.health); 55 | 56 | // Create health API server 57 | healthApiServer = new HealthApiServer(config.health.apiPort); 58 | healthApiServer.setTradingViewClient(tvClient); 59 | healthApiServer.setHealthMonitor(healthMonitor); 60 | 61 | // Handle health monitor events 62 | healthMonitor.on('stale_subscriptions', ({ total, stale, recovered }) => { 63 | logger.warn('Health monitor detected %d/%d stale subscriptions, recovered %d', stale, total, recovered); 64 | }); 65 | 66 | healthMonitor.on('max_recovery_attempts', (subscription) => { 67 | logger.error( 68 | 'Health monitor reached max recovery attempts for %s/%s, manual intervention may be needed', 69 | subscription.symbol, subscription.timeframe 70 | ); 71 | }); 72 | 73 | // Handle errors 74 | tvClient.on('error', (err) => { 75 | logger.error('TradingView error: %s', (err as Error).message); 76 | }); 77 | 78 | // Handle disconnection 79 | tvClient.on('disconnect', () => { 80 | logger.warn('TradingView disconnected, reconnecting...'); 81 | }); 82 | 83 | // Handle receiving bars 84 | tvClient.on('bar', async (bar) => { 85 | try { 86 | await pushBar(bar); 87 | } catch (err) { 88 | logger.error('Push error: %s', (err as Error).message); 89 | } 90 | }); 91 | 92 | // Connect 93 | await tvClient.connect(); 94 | 95 | // If there are initial subscriptions in the configuration, subscribe 96 | if (config.subscriptions.length > 0) { 97 | logger.info('Subscribing to initial %d pairs from config', config.subscriptions.length); 98 | await tvClient.updateSubscriptions(config.subscriptions); 99 | } 100 | } 101 | 102 | // Start application 103 | start().catch((err) => { 104 | logger.error('Failed to start: %s', err.message); 105 | process.exit(1); 106 | }); 107 | 108 | // Handle termination signals 109 | process.on('SIGINT', () => { 110 | logger.info('SIGINT received, shutting down...'); 111 | if (healthApiServer) healthApiServer.stop(); 112 | if (healthMonitor) healthMonitor.stop(); 113 | if (wsServer) wsServer.close(); 114 | if (tvClient) tvClient.close(); 115 | process.exit(0); 116 | }); 117 | 118 | process.on('SIGTERM', () => { 119 | logger.info('SIGTERM received, shutting down...'); 120 | if (healthApiServer) healthApiServer.stop(); 121 | if (healthMonitor) healthMonitor.stop(); 122 | if (wsServer) wsServer.close(); 123 | if (tvClient) tvClient.close(); 124 | process.exit(0); 125 | }); -------------------------------------------------------------------------------- /examples/node_modules/ws/lib/validation.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { isUtf8 } = require('buffer'); 4 | 5 | const { hasBlob } = require('./constants'); 6 | 7 | // 8 | // Allowed token characters: 9 | // 10 | // '!', '#', '$', '%', '&', ''', '*', '+', '-', 11 | // '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~' 12 | // 13 | // tokenChars[32] === 0 // ' ' 14 | // tokenChars[33] === 1 // '!' 15 | // tokenChars[34] === 0 // '"' 16 | // ... 17 | // 18 | // prettier-ignore 19 | const tokenChars = [ 20 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15 21 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31 22 | 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47 23 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63 24 | 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79 25 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95 26 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111 27 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127 28 | ]; 29 | 30 | /** 31 | * Checks if a status code is allowed in a close frame. 32 | * 33 | * @param {Number} code The status code 34 | * @return {Boolean} `true` if the status code is valid, else `false` 35 | * @public 36 | */ 37 | function isValidStatusCode(code) { 38 | return ( 39 | (code >= 1000 && 40 | code <= 1014 && 41 | code !== 1004 && 42 | code !== 1005 && 43 | code !== 1006) || 44 | (code >= 3000 && code <= 4999) 45 | ); 46 | } 47 | 48 | /** 49 | * Checks if a given buffer contains only correct UTF-8. 50 | * Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by 51 | * Markus Kuhn. 52 | * 53 | * @param {Buffer} buf The buffer to check 54 | * @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false` 55 | * @public 56 | */ 57 | function _isValidUTF8(buf) { 58 | const len = buf.length; 59 | let i = 0; 60 | 61 | while (i < len) { 62 | if ((buf[i] & 0x80) === 0) { 63 | // 0xxxxxxx 64 | i++; 65 | } else if ((buf[i] & 0xe0) === 0xc0) { 66 | // 110xxxxx 10xxxxxx 67 | if ( 68 | i + 1 === len || 69 | (buf[i + 1] & 0xc0) !== 0x80 || 70 | (buf[i] & 0xfe) === 0xc0 // Overlong 71 | ) { 72 | return false; 73 | } 74 | 75 | i += 2; 76 | } else if ((buf[i] & 0xf0) === 0xe0) { 77 | // 1110xxxx 10xxxxxx 10xxxxxx 78 | if ( 79 | i + 2 >= len || 80 | (buf[i + 1] & 0xc0) !== 0x80 || 81 | (buf[i + 2] & 0xc0) !== 0x80 || 82 | (buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong 83 | (buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF) 84 | ) { 85 | return false; 86 | } 87 | 88 | i += 3; 89 | } else if ((buf[i] & 0xf8) === 0xf0) { 90 | // 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx 91 | if ( 92 | i + 3 >= len || 93 | (buf[i + 1] & 0xc0) !== 0x80 || 94 | (buf[i + 2] & 0xc0) !== 0x80 || 95 | (buf[i + 3] & 0xc0) !== 0x80 || 96 | (buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong 97 | (buf[i] === 0xf4 && buf[i + 1] > 0x8f) || 98 | buf[i] > 0xf4 // > U+10FFFF 99 | ) { 100 | return false; 101 | } 102 | 103 | i += 4; 104 | } else { 105 | return false; 106 | } 107 | } 108 | 109 | return true; 110 | } 111 | 112 | /** 113 | * Determines whether a value is a `Blob`. 114 | * 115 | * @param {*} value The value to be tested 116 | * @return {Boolean} `true` if `value` is a `Blob`, else `false` 117 | * @private 118 | */ 119 | function isBlob(value) { 120 | return ( 121 | hasBlob && 122 | typeof value === 'object' && 123 | typeof value.arrayBuffer === 'function' && 124 | typeof value.type === 'string' && 125 | typeof value.stream === 'function' && 126 | (value[Symbol.toStringTag] === 'Blob' || 127 | value[Symbol.toStringTag] === 'File') 128 | ); 129 | } 130 | 131 | module.exports = { 132 | isBlob, 133 | isValidStatusCode, 134 | isValidUTF8: _isValidUTF8, 135 | tokenChars 136 | }; 137 | 138 | if (isUtf8) { 139 | module.exports.isValidUTF8 = function (buf) { 140 | return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf); 141 | }; 142 | } /* istanbul ignore else */ else if (!process.env.WS_NO_UTF_8_VALIDATE) { 143 | try { 144 | const isValidUTF8 = require('utf-8-validate'); 145 | 146 | module.exports.isValidUTF8 = function (buf) { 147 | return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf); 148 | }; 149 | } catch (e) { 150 | // Continue regardless of the error. 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /tradingview_vendor/quote/session.js: -------------------------------------------------------------------------------- 1 | const { genSessionID } = require('../utils'); 2 | 3 | const quoteMarketConstructor = require('./market'); 4 | 5 | /** @typedef {Object} SymbolListeners */ 6 | 7 | /** 8 | * @typedef {Object} QuoteSessionBridge 9 | * @prop {string} sessionID 10 | * @prop {SymbolListeners} symbolListeners 11 | * @prop {import('../client').SendPacket} send 12 | */ 13 | 14 | /** 15 | * @typedef {'base-currency-logoid' 16 | * | 'ch' | 'chp' | 'currency-logoid' | 'provider_id' 17 | * | 'currency_code' | 'current_session' | 'description' 18 | * | 'exchange' | 'format' | 'fractional' | 'is_tradable' 19 | * | 'language' | 'local_description' | 'logoid' | 'lp' 20 | * | 'lp_time' | 'minmov' | 'minmove2' | 'original_name' 21 | * | 'pricescale' | 'pro_name' | 'short_name' | 'type' 22 | * | 'update_mode' | 'volume' | 'ask' | 'bid' | 'fundamentals' 23 | * | 'high_price' | 'low_price' | 'open_price' | 'prev_close_price' 24 | * | 'rch' | 'rchp' | 'rtc' | 'rtc_time' | 'status' | 'industry' 25 | * | 'basic_eps_net_income' | 'beta_1_year' | 'market_cap_basic' 26 | * | 'earnings_per_share_basic_ttm' | 'price_earnings_ttm' 27 | * | 'sector' | 'dividends_yield' | 'timezone' | 'country_code' 28 | * } quoteField Quote data field 29 | */ 30 | 31 | /** @param {'all' | 'price'} fieldsType */ 32 | function getQuoteFields(fieldsType) { 33 | if (fieldsType === 'price') { 34 | return ['lp']; 35 | } 36 | 37 | return [ 38 | 'base-currency-logoid', 'ch', 'chp', 'currency-logoid', 39 | 'currency_code', 'current_session', 'description', 40 | 'exchange', 'format', 'fractional', 'is_tradable', 41 | 'language', 'local_description', 'logoid', 'lp', 42 | 'lp_time', 'minmov', 'minmove2', 'original_name', 43 | 'pricescale', 'pro_name', 'short_name', 'type', 44 | 'update_mode', 'volume', 'ask', 'bid', 'fundamentals', 45 | 'high_price', 'low_price', 'open_price', 'prev_close_price', 46 | 'rch', 'rchp', 'rtc', 'rtc_time', 'status', 'industry', 47 | 'basic_eps_net_income', 'beta_1_year', 'market_cap_basic', 48 | 'earnings_per_share_basic_ttm', 'price_earnings_ttm', 49 | 'sector', 'dividends_yield', 'timezone', 'country_code', 50 | 'provider_id', 51 | ]; 52 | } 53 | 54 | /** 55 | * @param {import('../client').ClientBridge} client 56 | */ 57 | module.exports = (client) => class QuoteSession { 58 | #sessionID = genSessionID('qs'); 59 | 60 | /** Parent client */ 61 | #client = client; 62 | 63 | /** @type {SymbolListeners} */ 64 | #symbolListeners = {}; 65 | 66 | /** 67 | * @typedef {Object} quoteSessionOptions Quote Session options 68 | * @prop {'all' | 'price'} [fields] Asked quote fields 69 | * @prop {quoteField[]} [customFields] List of asked quote fields 70 | */ 71 | 72 | /** 73 | * @param {quoteSessionOptions} options Quote settings options 74 | */ 75 | constructor(options = {}) { 76 | this.#client.sessions[this.#sessionID] = { 77 | type: 'quote', 78 | onData: (packet) => { 79 | if (global.TW_DEBUG) console.log('§90§30§102 QUOTE SESSION §0 DATA', packet); 80 | 81 | if (packet.type === 'quote_completed') { 82 | const symbolKey = packet.data[1]; 83 | if (!this.#symbolListeners[symbolKey]) { 84 | this.#client.send('quote_remove_symbols', [this.#sessionID, symbolKey]); 85 | return; 86 | } 87 | this.#symbolListeners[symbolKey].forEach((h) => h(packet)); 88 | } 89 | 90 | if (packet.type === 'qsd') { 91 | const symbolKey = packet.data[1].n; 92 | if (!this.#symbolListeners[symbolKey]) { 93 | this.#client.send('quote_remove_symbols', [this.#sessionID, symbolKey]); 94 | return; 95 | } 96 | this.#symbolListeners[symbolKey].forEach((h) => h(packet)); 97 | } 98 | }, 99 | }; 100 | 101 | const fields = (options.customFields && options.customFields.length > 0 102 | ? options.customFields 103 | : getQuoteFields(options.fields) 104 | ); 105 | 106 | this.#client.send('quote_create_session', [this.#sessionID]); 107 | this.#client.send('quote_set_fields', [this.#sessionID, ...fields]); 108 | } 109 | 110 | /** @type {QuoteSessionBridge} */ 111 | #quoteSession = { 112 | sessionID: this.#sessionID, 113 | symbolListeners: this.#symbolListeners, 114 | send: (t, p) => this.#client.send(t, p), 115 | }; 116 | 117 | /** @constructor */ 118 | Market = quoteMarketConstructor(this.#quoteSession); 119 | 120 | /** Delete the quote session */ 121 | delete() { 122 | this.#client.send('quote_delete_session', [this.#sessionID]); 123 | delete this.#client.sessions[this.#sessionID]; 124 | } 125 | }; 126 | -------------------------------------------------------------------------------- /examples/node_modules/ws/lib/stream.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */ 2 | 'use strict'; 3 | 4 | const WebSocket = require('./websocket'); 5 | const { Duplex } = require('stream'); 6 | 7 | /** 8 | * Emits the `'close'` event on a stream. 9 | * 10 | * @param {Duplex} stream The stream. 11 | * @private 12 | */ 13 | function emitClose(stream) { 14 | stream.emit('close'); 15 | } 16 | 17 | /** 18 | * The listener of the `'end'` event. 19 | * 20 | * @private 21 | */ 22 | function duplexOnEnd() { 23 | if (!this.destroyed && this._writableState.finished) { 24 | this.destroy(); 25 | } 26 | } 27 | 28 | /** 29 | * The listener of the `'error'` event. 30 | * 31 | * @param {Error} err The error 32 | * @private 33 | */ 34 | function duplexOnError(err) { 35 | this.removeListener('error', duplexOnError); 36 | this.destroy(); 37 | if (this.listenerCount('error') === 0) { 38 | // Do not suppress the throwing behavior. 39 | this.emit('error', err); 40 | } 41 | } 42 | 43 | /** 44 | * Wraps a `WebSocket` in a duplex stream. 45 | * 46 | * @param {WebSocket} ws The `WebSocket` to wrap 47 | * @param {Object} [options] The options for the `Duplex` constructor 48 | * @return {Duplex} The duplex stream 49 | * @public 50 | */ 51 | function createWebSocketStream(ws, options) { 52 | let terminateOnDestroy = true; 53 | 54 | const duplex = new Duplex({ 55 | ...options, 56 | autoDestroy: false, 57 | emitClose: false, 58 | objectMode: false, 59 | writableObjectMode: false 60 | }); 61 | 62 | ws.on('message', function message(msg, isBinary) { 63 | const data = 64 | !isBinary && duplex._readableState.objectMode ? msg.toString() : msg; 65 | 66 | if (!duplex.push(data)) ws.pause(); 67 | }); 68 | 69 | ws.once('error', function error(err) { 70 | if (duplex.destroyed) return; 71 | 72 | // Prevent `ws.terminate()` from being called by `duplex._destroy()`. 73 | // 74 | // - If the `'error'` event is emitted before the `'open'` event, then 75 | // `ws.terminate()` is a noop as no socket is assigned. 76 | // - Otherwise, the error is re-emitted by the listener of the `'error'` 77 | // event of the `Receiver` object. The listener already closes the 78 | // connection by calling `ws.close()`. This allows a close frame to be 79 | // sent to the other peer. If `ws.terminate()` is called right after this, 80 | // then the close frame might not be sent. 81 | terminateOnDestroy = false; 82 | duplex.destroy(err); 83 | }); 84 | 85 | ws.once('close', function close() { 86 | if (duplex.destroyed) return; 87 | 88 | duplex.push(null); 89 | }); 90 | 91 | duplex._destroy = function (err, callback) { 92 | if (ws.readyState === ws.CLOSED) { 93 | callback(err); 94 | process.nextTick(emitClose, duplex); 95 | return; 96 | } 97 | 98 | let called = false; 99 | 100 | ws.once('error', function error(err) { 101 | called = true; 102 | callback(err); 103 | }); 104 | 105 | ws.once('close', function close() { 106 | if (!called) callback(err); 107 | process.nextTick(emitClose, duplex); 108 | }); 109 | 110 | if (terminateOnDestroy) ws.terminate(); 111 | }; 112 | 113 | duplex._final = function (callback) { 114 | if (ws.readyState === ws.CONNECTING) { 115 | ws.once('open', function open() { 116 | duplex._final(callback); 117 | }); 118 | return; 119 | } 120 | 121 | // If the value of the `_socket` property is `null` it means that `ws` is a 122 | // client websocket and the handshake failed. In fact, when this happens, a 123 | // socket is never assigned to the websocket. Wait for the `'error'` event 124 | // that will be emitted by the websocket. 125 | if (ws._socket === null) return; 126 | 127 | if (ws._socket._writableState.finished) { 128 | callback(); 129 | if (duplex._readableState.endEmitted) duplex.destroy(); 130 | } else { 131 | ws._socket.once('finish', function finish() { 132 | // `duplex` is not destroyed here because the `'end'` event will be 133 | // emitted on `duplex` after this `'finish'` event. The EOF signaling 134 | // `null` chunk is, in fact, pushed when the websocket emits `'close'`. 135 | callback(); 136 | }); 137 | ws.close(); 138 | } 139 | }; 140 | 141 | duplex._read = function () { 142 | if (ws.isPaused) ws.resume(); 143 | }; 144 | 145 | duplex._write = function (chunk, encoding, callback) { 146 | if (ws.readyState === ws.CONNECTING) { 147 | ws.once('open', function open() { 148 | duplex._write(chunk, encoding, callback); 149 | }); 150 | return; 151 | } 152 | 153 | ws.send(chunk, callback); 154 | }; 155 | 156 | duplex.on('end', duplexOnEnd); 157 | duplex.on('error', duplexOnError); 158 | return duplex; 159 | } 160 | 161 | module.exports = createWebSocketStream; 162 | -------------------------------------------------------------------------------- /tradingview_vendor/classes/PinePermManager.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const { genAuthCookies } = require('../utils'); 3 | 4 | /** 5 | * @typedef {Object} AuthorizationUser 6 | * @prop {id} id User id 7 | * @prop {string} username User's username 8 | * @prop {string} userpic User's profile picture URL 9 | * @prop {string} expiration Authorization expiration date 10 | * @prop {string} created Authorization creation date 11 | */ 12 | 13 | /** @class */ 14 | class PinePermManager { 15 | sessionId; 16 | 17 | pineId; 18 | 19 | /** 20 | * Creates a PinePermManager instance 21 | * @param {string} sessionId Token from `sessionid` cookie 22 | * @param {string} signature Signature cookie 23 | * @param {string} pineId Indicator ID (Like: PUB;XXXXXXXXXXXXXXXXXXXXX) 24 | */ 25 | constructor(sessionId, signature, pineId) { 26 | if (!sessionId) throw new Error('Please provide a SessionID'); 27 | if (!signature) throw new Error('Please provide a Signature'); 28 | if (!pineId) throw new Error('Please provide a PineID'); 29 | this.sessionId = sessionId; 30 | this.signature = signature; 31 | this.pineId = pineId; 32 | } 33 | 34 | /** 35 | * Get list of authorized users 36 | * @param {number} limit Fetching limit 37 | * @param {'user__username' 38 | * | '-user__username' 39 | * | 'created' | 'created' 40 | * | 'expiration,user__username' 41 | * | '-expiration,user__username' 42 | * } order Fetching order 43 | * @returns {Promise} 44 | */ 45 | async getUsers(limit = 10, order = '-created') { 46 | try { 47 | const { data } = await axios.post( 48 | `https://www.tradingview.com/pine_perm/list_users/?limit=${limit}&order_by=${order}`, 49 | `pine_id=${this.pineId.replace(/;/g, '%3B')}`, 50 | { 51 | headers: { 52 | origin: 'https://www.tradingview.com', 53 | 'Content-Type': 'application/x-www-form-urlencoded', 54 | cookie: genAuthCookies(this.sessionId, this.signature), 55 | }, 56 | }, 57 | ); 58 | 59 | return data.results; 60 | } catch (e) { 61 | throw new Error(e.response.data.detail || 'Wrong credentials or pineId'); 62 | } 63 | } 64 | 65 | /** 66 | * Adds an user to the authorized list 67 | * @param {string} username User's username 68 | * @param {Date} [expiration] Expiration date 69 | * @returns {Promise<'ok' | 'exists' | null>} 70 | */ 71 | async addUser(username, expiration = null) { 72 | try { 73 | const { data } = await axios.post( 74 | 'https://www.tradingview.com/pine_perm/add/', 75 | `pine_id=${ 76 | this.pineId.replace(/;/g, '%3B') 77 | }&username_recip=${ 78 | username 79 | }${ 80 | expiration && expiration instanceof Date 81 | ? `&expiration=${expiration.toISOString()}` 82 | : '' 83 | }`, 84 | { 85 | headers: { 86 | origin: 'https://www.tradingview.com', 87 | 'Content-Type': 'application/x-www-form-urlencoded', 88 | cookie: genAuthCookies(this.sessionId, this.signature), 89 | }, 90 | }, 91 | ); 92 | 93 | return data.status; 94 | } catch (e) { 95 | throw new Error(e.response.data.detail || 'Wrong credentials or pineId'); 96 | } 97 | } 98 | 99 | /** 100 | * Modify an authorization expiration date 101 | * @param {string} username User's username 102 | * @param {Date} [expiration] New expiration date 103 | * @returns {Promise<'ok' | null>} 104 | */ 105 | async modifyExpiration(username, expiration = null) { 106 | try { 107 | const { data } = await axios.post( 108 | 'https://www.tradingview.com/pine_perm/modify_user_expiration/', 109 | `pine_id=${ 110 | this.pineId.replace(/;/g, '%3B') 111 | }&username_recip=${ 112 | username 113 | }${ 114 | expiration && expiration instanceof Date 115 | ? `&expiration=${expiration.toISOString()}` 116 | : '' 117 | }`, 118 | { 119 | headers: { 120 | origin: 'https://www.tradingview.com', 121 | 'Content-Type': 'application/x-www-form-urlencoded', 122 | cookie: genAuthCookies(this.sessionId, this.signature), 123 | }, 124 | }, 125 | ); 126 | 127 | return data.status; 128 | } catch (e) { 129 | throw new Error(e.response.data.detail || 'Wrong credentials or pineId'); 130 | } 131 | } 132 | 133 | /** 134 | * Removes an user to the authorized list 135 | * @param {string} username User's username 136 | * @returns {Promise<'ok' | null>} 137 | */ 138 | async removeUser(username) { 139 | try { 140 | const { data } = await axios.post( 141 | 'https://www.tradingview.com/pine_perm/remove/', 142 | `pine_id=${this.pineId.replace(/;/g, '%3B')}&username_recip=${username}`, 143 | { 144 | headers: { 145 | origin: 'https://www.tradingview.com', 146 | 'Content-Type': 'application/x-www-form-urlencoded', 147 | cookie: genAuthCookies(this.sessionId, this.signature), 148 | }, 149 | }, 150 | ); 151 | 152 | return data.status; 153 | } catch (e) { 154 | throw new Error(e.response.data.detail || 'Wrong credentials or pineId'); 155 | } 156 | } 157 | } 158 | 159 | module.exports = PinePermManager; 160 | -------------------------------------------------------------------------------- /src/health-api.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { logger } from './logger'; 3 | import { config } from './config'; 4 | import { TradingViewClient } from './tradingview'; 5 | import { TradingViewHealthMonitor } from './health'; 6 | import { staleSubscriptionsGauge } from './metrics'; 7 | 8 | // Health API server 9 | export class HealthApiServer { 10 | private app: express.Express; 11 | private server: any; 12 | private tvClient: TradingViewClient | null = null; 13 | private healthMonitor: TradingViewHealthMonitor | null = null; 14 | 15 | constructor(port: number = config.health?.apiPort || 8082) { 16 | this.app = express(); 17 | 18 | // Health check endpoint 19 | this.app.get('/health', (req, res) => { 20 | // Basic health check 21 | const healthy = this.isHealthy(); 22 | 23 | const healthStatus = { 24 | status: healthy ? 'healthy' : 'unhealthy', 25 | version: process.env.npm_package_version || 'unknown', 26 | uptime: process.uptime(), 27 | tradingview: { 28 | connected: this.tvClient?.isConnected() || false, 29 | subscriptions: this.tvClient?.getSubscriptions().length || 0, 30 | }, 31 | health_monitor: { 32 | active: !!this.healthMonitor, 33 | stale_subscriptions: staleSubscriptionsGauge.get() || 0, 34 | }, 35 | timestamp: new Date().toISOString() 36 | }; 37 | 38 | res.status(healthy ? 200 : 503).json(healthStatus); 39 | }); 40 | 41 | // Detailed status endpoint 42 | this.app.get('/status', (req, res) => { 43 | // Detailed status with subscriptions 44 | const subscriptions = this.tvClient?.getSubscriptions() || []; 45 | 46 | const statusInfo = { 47 | status: this.isHealthy() ? 'healthy' : 'unhealthy', 48 | version: process.env.npm_package_version || 'unknown', 49 | uptime: process.uptime(), 50 | tradingview: { 51 | connected: this.tvClient?.isConnected() || false, 52 | subscriptions_count: subscriptions.length, 53 | subscriptions: subscriptions, 54 | }, 55 | health_monitor: { 56 | active: !!this.healthMonitor, 57 | stale_subscriptions: staleSubscriptionsGauge.get() || 0, 58 | check_interval_ms: config.health.checkIntervalMs, 59 | auto_recovery: config.health.autoRecoveryEnabled, 60 | }, 61 | timestamp: new Date().toISOString() 62 | }; 63 | 64 | res.json(statusInfo); 65 | }); 66 | 67 | // Recovery trigger endpoint - for manual recovery 68 | this.app.post('/recovery/subscription', express.json(), (req, res) => { 69 | const { symbol, timeframe } = req.body; 70 | 71 | if (!symbol || !timeframe) { 72 | return res.status(400).json({ 73 | status: 'error', 74 | message: 'Symbol and timeframe are required' 75 | }); 76 | } 77 | 78 | if (!this.tvClient) { 79 | return res.status(503).json({ 80 | status: 'error', 81 | message: 'TradingView client not available' 82 | }); 83 | } 84 | 85 | // Trigger manual recovery 86 | const subscription = { symbol, timeframe }; 87 | logger.info('[HEALTH-API] Manual recovery request for %s/%s', symbol, timeframe); 88 | 89 | // Unsubscribe and resubscribe 90 | this.tvClient.unsubscribe(symbol, timeframe) 91 | .then(() => new Promise(resolve => setTimeout(resolve, 1000))) 92 | .then(() => this.tvClient?.subscribe(subscription, 'manual_recovery')) 93 | .then(success => { 94 | if (success) { 95 | res.json({ 96 | status: 'success', 97 | message: `Successfully resubscribed to ${symbol}/${timeframe}` 98 | }); 99 | } else { 100 | res.status(500).json({ 101 | status: 'error', 102 | message: `Failed to resubscribe to ${symbol}/${timeframe}` 103 | }); 104 | } 105 | }) 106 | .catch(err => { 107 | res.status(500).json({ 108 | status: 'error', 109 | message: `Error during recovery: ${err.message}` 110 | }); 111 | }); 112 | }); 113 | 114 | // Full reconnect endpoint - for manual reconnect 115 | this.app.post('/recovery/full-reconnect', (req, res) => { 116 | if (!this.tvClient || typeof this.tvClient.fullReconnect !== 'function') { 117 | return res.status(503).json({ 118 | status: 'error', 119 | message: 'TradingView client not available or fullReconnect not supported' 120 | }); 121 | } 122 | 123 | logger.info('[HEALTH-API] Manual full reconnect request'); 124 | 125 | // Trigger full reconnect 126 | this.tvClient.fullReconnect() 127 | .then(success => { 128 | if (success) { 129 | res.json({ 130 | status: 'success', 131 | message: 'Full reconnect successful' 132 | }); 133 | } else { 134 | res.status(500).json({ 135 | status: 'error', 136 | message: 'Full reconnect failed' 137 | }); 138 | } 139 | }) 140 | .catch(err => { 141 | res.status(500).json({ 142 | status: 'error', 143 | message: `Error during full reconnect: ${err.message}` 144 | }); 145 | }); 146 | }); 147 | 148 | // Start server 149 | this.server = this.app.listen(port, () => { 150 | logger.info(`Health API server started on port ${port}`); 151 | }); 152 | } 153 | 154 | /** 155 | * Set the TradingView client to monitor 156 | */ 157 | public setTradingViewClient(client: TradingViewClient): void { 158 | this.tvClient = client; 159 | } 160 | 161 | /** 162 | * Set the health monitor to expose stats from 163 | */ 164 | public setHealthMonitor(monitor: TradingViewHealthMonitor): void { 165 | this.healthMonitor = monitor; 166 | } 167 | 168 | /** 169 | * Check if the service is healthy 170 | */ 171 | private isHealthy(): boolean { 172 | // Check basic health: TradingView client is connected 173 | const connected = this.tvClient?.isConnected() || false; 174 | 175 | // Could add more checks here if needed 176 | return connected; 177 | } 178 | 179 | /** 180 | * Stop the health API server 181 | */ 182 | public stop(): void { 183 | if (this.server) { 184 | this.server.close(() => { 185 | logger.info('Health API server stopped'); 186 | }); 187 | } 188 | } 189 | } -------------------------------------------------------------------------------- /examples/node_modules/ws/lib/extension.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { tokenChars } = require('./validation'); 4 | 5 | /** 6 | * Adds an offer to the map of extension offers or a parameter to the map of 7 | * parameters. 8 | * 9 | * @param {Object} dest The map of extension offers or parameters 10 | * @param {String} name The extension or parameter name 11 | * @param {(Object|Boolean|String)} elem The extension parameters or the 12 | * parameter value 13 | * @private 14 | */ 15 | function push(dest, name, elem) { 16 | if (dest[name] === undefined) dest[name] = [elem]; 17 | else dest[name].push(elem); 18 | } 19 | 20 | /** 21 | * Parses the `Sec-WebSocket-Extensions` header into an object. 22 | * 23 | * @param {String} header The field value of the header 24 | * @return {Object} The parsed object 25 | * @public 26 | */ 27 | function parse(header) { 28 | const offers = Object.create(null); 29 | let params = Object.create(null); 30 | let mustUnescape = false; 31 | let isEscaping = false; 32 | let inQuotes = false; 33 | let extensionName; 34 | let paramName; 35 | let start = -1; 36 | let code = -1; 37 | let end = -1; 38 | let i = 0; 39 | 40 | for (; i < header.length; i++) { 41 | code = header.charCodeAt(i); 42 | 43 | if (extensionName === undefined) { 44 | if (end === -1 && tokenChars[code] === 1) { 45 | if (start === -1) start = i; 46 | } else if ( 47 | i !== 0 && 48 | (code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */ 49 | ) { 50 | if (end === -1 && start !== -1) end = i; 51 | } else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) { 52 | if (start === -1) { 53 | throw new SyntaxError(`Unexpected character at index ${i}`); 54 | } 55 | 56 | if (end === -1) end = i; 57 | const name = header.slice(start, end); 58 | if (code === 0x2c) { 59 | push(offers, name, params); 60 | params = Object.create(null); 61 | } else { 62 | extensionName = name; 63 | } 64 | 65 | start = end = -1; 66 | } else { 67 | throw new SyntaxError(`Unexpected character at index ${i}`); 68 | } 69 | } else if (paramName === undefined) { 70 | if (end === -1 && tokenChars[code] === 1) { 71 | if (start === -1) start = i; 72 | } else if (code === 0x20 || code === 0x09) { 73 | if (end === -1 && start !== -1) end = i; 74 | } else if (code === 0x3b || code === 0x2c) { 75 | if (start === -1) { 76 | throw new SyntaxError(`Unexpected character at index ${i}`); 77 | } 78 | 79 | if (end === -1) end = i; 80 | push(params, header.slice(start, end), true); 81 | if (code === 0x2c) { 82 | push(offers, extensionName, params); 83 | params = Object.create(null); 84 | extensionName = undefined; 85 | } 86 | 87 | start = end = -1; 88 | } else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) { 89 | paramName = header.slice(start, i); 90 | start = end = -1; 91 | } else { 92 | throw new SyntaxError(`Unexpected character at index ${i}`); 93 | } 94 | } else { 95 | // 96 | // The value of a quoted-string after unescaping must conform to the 97 | // token ABNF, so only token characters are valid. 98 | // Ref: https://tools.ietf.org/html/rfc6455#section-9.1 99 | // 100 | if (isEscaping) { 101 | if (tokenChars[code] !== 1) { 102 | throw new SyntaxError(`Unexpected character at index ${i}`); 103 | } 104 | if (start === -1) start = i; 105 | else if (!mustUnescape) mustUnescape = true; 106 | isEscaping = false; 107 | } else if (inQuotes) { 108 | if (tokenChars[code] === 1) { 109 | if (start === -1) start = i; 110 | } else if (code === 0x22 /* '"' */ && start !== -1) { 111 | inQuotes = false; 112 | end = i; 113 | } else if (code === 0x5c /* '\' */) { 114 | isEscaping = true; 115 | } else { 116 | throw new SyntaxError(`Unexpected character at index ${i}`); 117 | } 118 | } else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) { 119 | inQuotes = true; 120 | } else if (end === -1 && tokenChars[code] === 1) { 121 | if (start === -1) start = i; 122 | } else if (start !== -1 && (code === 0x20 || code === 0x09)) { 123 | if (end === -1) end = i; 124 | } else if (code === 0x3b || code === 0x2c) { 125 | if (start === -1) { 126 | throw new SyntaxError(`Unexpected character at index ${i}`); 127 | } 128 | 129 | if (end === -1) end = i; 130 | let value = header.slice(start, end); 131 | if (mustUnescape) { 132 | value = value.replace(/\\/g, ''); 133 | mustUnescape = false; 134 | } 135 | push(params, paramName, value); 136 | if (code === 0x2c) { 137 | push(offers, extensionName, params); 138 | params = Object.create(null); 139 | extensionName = undefined; 140 | } 141 | 142 | paramName = undefined; 143 | start = end = -1; 144 | } else { 145 | throw new SyntaxError(`Unexpected character at index ${i}`); 146 | } 147 | } 148 | } 149 | 150 | if (start === -1 || inQuotes || code === 0x20 || code === 0x09) { 151 | throw new SyntaxError('Unexpected end of input'); 152 | } 153 | 154 | if (end === -1) end = i; 155 | const token = header.slice(start, end); 156 | if (extensionName === undefined) { 157 | push(offers, token, params); 158 | } else { 159 | if (paramName === undefined) { 160 | push(params, token, true); 161 | } else if (mustUnescape) { 162 | push(params, paramName, token.replace(/\\/g, '')); 163 | } else { 164 | push(params, paramName, token); 165 | } 166 | push(offers, extensionName, params); 167 | } 168 | 169 | return offers; 170 | } 171 | 172 | /** 173 | * Builds the `Sec-WebSocket-Extensions` header field value. 174 | * 175 | * @param {Object} extensions The map of extensions and parameters to format 176 | * @return {String} A string representing the given object 177 | * @public 178 | */ 179 | function format(extensions) { 180 | return Object.keys(extensions) 181 | .map((extension) => { 182 | let configurations = extensions[extension]; 183 | if (!Array.isArray(configurations)) configurations = [configurations]; 184 | return configurations 185 | .map((params) => { 186 | return [extension] 187 | .concat( 188 | Object.keys(params).map((k) => { 189 | let values = params[k]; 190 | if (!Array.isArray(values)) values = [values]; 191 | return values 192 | .map((v) => (v === true ? k : `${k}=${v}`)) 193 | .join('; '); 194 | }) 195 | ) 196 | .join('; '); 197 | }) 198 | .join(', '); 199 | }) 200 | .join(', '); 201 | } 202 | 203 | module.exports = { format, parse }; 204 | -------------------------------------------------------------------------------- /examples/node_modules/ws/lib/event-target.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { kForOnEventAttribute, kListener } = require('./constants'); 4 | 5 | const kCode = Symbol('kCode'); 6 | const kData = Symbol('kData'); 7 | const kError = Symbol('kError'); 8 | const kMessage = Symbol('kMessage'); 9 | const kReason = Symbol('kReason'); 10 | const kTarget = Symbol('kTarget'); 11 | const kType = Symbol('kType'); 12 | const kWasClean = Symbol('kWasClean'); 13 | 14 | /** 15 | * Class representing an event. 16 | */ 17 | class Event { 18 | /** 19 | * Create a new `Event`. 20 | * 21 | * @param {String} type The name of the event 22 | * @throws {TypeError} If the `type` argument is not specified 23 | */ 24 | constructor(type) { 25 | this[kTarget] = null; 26 | this[kType] = type; 27 | } 28 | 29 | /** 30 | * @type {*} 31 | */ 32 | get target() { 33 | return this[kTarget]; 34 | } 35 | 36 | /** 37 | * @type {String} 38 | */ 39 | get type() { 40 | return this[kType]; 41 | } 42 | } 43 | 44 | Object.defineProperty(Event.prototype, 'target', { enumerable: true }); 45 | Object.defineProperty(Event.prototype, 'type', { enumerable: true }); 46 | 47 | /** 48 | * Class representing a close event. 49 | * 50 | * @extends Event 51 | */ 52 | class CloseEvent extends Event { 53 | /** 54 | * Create a new `CloseEvent`. 55 | * 56 | * @param {String} type The name of the event 57 | * @param {Object} [options] A dictionary object that allows for setting 58 | * attributes via object members of the same name 59 | * @param {Number} [options.code=0] The status code explaining why the 60 | * connection was closed 61 | * @param {String} [options.reason=''] A human-readable string explaining why 62 | * the connection was closed 63 | * @param {Boolean} [options.wasClean=false] Indicates whether or not the 64 | * connection was cleanly closed 65 | */ 66 | constructor(type, options = {}) { 67 | super(type); 68 | 69 | this[kCode] = options.code === undefined ? 0 : options.code; 70 | this[kReason] = options.reason === undefined ? '' : options.reason; 71 | this[kWasClean] = options.wasClean === undefined ? false : options.wasClean; 72 | } 73 | 74 | /** 75 | * @type {Number} 76 | */ 77 | get code() { 78 | return this[kCode]; 79 | } 80 | 81 | /** 82 | * @type {String} 83 | */ 84 | get reason() { 85 | return this[kReason]; 86 | } 87 | 88 | /** 89 | * @type {Boolean} 90 | */ 91 | get wasClean() { 92 | return this[kWasClean]; 93 | } 94 | } 95 | 96 | Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true }); 97 | Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true }); 98 | Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true }); 99 | 100 | /** 101 | * Class representing an error event. 102 | * 103 | * @extends Event 104 | */ 105 | class ErrorEvent extends Event { 106 | /** 107 | * Create a new `ErrorEvent`. 108 | * 109 | * @param {String} type The name of the event 110 | * @param {Object} [options] A dictionary object that allows for setting 111 | * attributes via object members of the same name 112 | * @param {*} [options.error=null] The error that generated this event 113 | * @param {String} [options.message=''] The error message 114 | */ 115 | constructor(type, options = {}) { 116 | super(type); 117 | 118 | this[kError] = options.error === undefined ? null : options.error; 119 | this[kMessage] = options.message === undefined ? '' : options.message; 120 | } 121 | 122 | /** 123 | * @type {*} 124 | */ 125 | get error() { 126 | return this[kError]; 127 | } 128 | 129 | /** 130 | * @type {String} 131 | */ 132 | get message() { 133 | return this[kMessage]; 134 | } 135 | } 136 | 137 | Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true }); 138 | Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true }); 139 | 140 | /** 141 | * Class representing a message event. 142 | * 143 | * @extends Event 144 | */ 145 | class MessageEvent extends Event { 146 | /** 147 | * Create a new `MessageEvent`. 148 | * 149 | * @param {String} type The name of the event 150 | * @param {Object} [options] A dictionary object that allows for setting 151 | * attributes via object members of the same name 152 | * @param {*} [options.data=null] The message content 153 | */ 154 | constructor(type, options = {}) { 155 | super(type); 156 | 157 | this[kData] = options.data === undefined ? null : options.data; 158 | } 159 | 160 | /** 161 | * @type {*} 162 | */ 163 | get data() { 164 | return this[kData]; 165 | } 166 | } 167 | 168 | Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true }); 169 | 170 | /** 171 | * This provides methods for emulating the `EventTarget` interface. It's not 172 | * meant to be used directly. 173 | * 174 | * @mixin 175 | */ 176 | const EventTarget = { 177 | /** 178 | * Register an event listener. 179 | * 180 | * @param {String} type A string representing the event type to listen for 181 | * @param {(Function|Object)} handler The listener to add 182 | * @param {Object} [options] An options object specifies characteristics about 183 | * the event listener 184 | * @param {Boolean} [options.once=false] A `Boolean` indicating that the 185 | * listener should be invoked at most once after being added. If `true`, 186 | * the listener would be automatically removed when invoked. 187 | * @public 188 | */ 189 | addEventListener(type, handler, options = {}) { 190 | for (const listener of this.listeners(type)) { 191 | if ( 192 | !options[kForOnEventAttribute] && 193 | listener[kListener] === handler && 194 | !listener[kForOnEventAttribute] 195 | ) { 196 | return; 197 | } 198 | } 199 | 200 | let wrapper; 201 | 202 | if (type === 'message') { 203 | wrapper = function onMessage(data, isBinary) { 204 | const event = new MessageEvent('message', { 205 | data: isBinary ? data : data.toString() 206 | }); 207 | 208 | event[kTarget] = this; 209 | callListener(handler, this, event); 210 | }; 211 | } else if (type === 'close') { 212 | wrapper = function onClose(code, message) { 213 | const event = new CloseEvent('close', { 214 | code, 215 | reason: message.toString(), 216 | wasClean: this._closeFrameReceived && this._closeFrameSent 217 | }); 218 | 219 | event[kTarget] = this; 220 | callListener(handler, this, event); 221 | }; 222 | } else if (type === 'error') { 223 | wrapper = function onError(error) { 224 | const event = new ErrorEvent('error', { 225 | error, 226 | message: error.message 227 | }); 228 | 229 | event[kTarget] = this; 230 | callListener(handler, this, event); 231 | }; 232 | } else if (type === 'open') { 233 | wrapper = function onOpen() { 234 | const event = new Event('open'); 235 | 236 | event[kTarget] = this; 237 | callListener(handler, this, event); 238 | }; 239 | } else { 240 | return; 241 | } 242 | 243 | wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute]; 244 | wrapper[kListener] = handler; 245 | 246 | if (options.once) { 247 | this.once(type, wrapper); 248 | } else { 249 | this.on(type, wrapper); 250 | } 251 | }, 252 | 253 | /** 254 | * Remove an event listener. 255 | * 256 | * @param {String} type A string representing the event type to remove 257 | * @param {(Function|Object)} handler The listener to remove 258 | * @public 259 | */ 260 | removeEventListener(type, handler) { 261 | for (const listener of this.listeners(type)) { 262 | if (listener[kListener] === handler && !listener[kForOnEventAttribute]) { 263 | this.removeListener(type, listener); 264 | break; 265 | } 266 | } 267 | } 268 | }; 269 | 270 | module.exports = { 271 | CloseEvent, 272 | ErrorEvent, 273 | Event, 274 | EventTarget, 275 | MessageEvent 276 | }; 277 | 278 | /** 279 | * Call an event listener 280 | * 281 | * @param {(Function|Object)} listener The listener to call 282 | * @param {*} thisArg The value to use as `this`` when calling the listener 283 | * @param {Event} event The event to pass to the listener 284 | * @private 285 | */ 286 | function callListener(listener, thisArg, event) { 287 | if (typeof listener === 'object' && listener.handleEvent) { 288 | listener.handleEvent.call(listener, event); 289 | } else { 290 | listener.call(thisArg, event); 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /tradingview_vendor/client.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws'); 2 | const HttpsProxyAgent = require('https-proxy-agent'); 3 | const SocksProxyAgent = require('socks-proxy-agent'); 4 | 5 | const misc = require('./miscRequests'); 6 | const protocol = require('./protocol'); 7 | 8 | const quoteSessionGenerator = require('./quote/session'); 9 | const chartSessionGenerator = require('./chart/session'); 10 | 11 | /** 12 | * @typedef {Object} Session 13 | * @prop {'quote' | 'chart' | 'replay'} type Session type 14 | * @prop {(data: {}) => null} onData When there is a data 15 | */ 16 | 17 | /** @typedef {Object} SessionList Session list */ 18 | 19 | /** 20 | * @callback SendPacket Send a custom packet 21 | * @param {string} t Packet type 22 | * @param {string[]} p Packet data 23 | * @returns {void} 24 | */ 25 | 26 | /** 27 | * @typedef {Object} ClientBridge 28 | * @prop {SessionList} sessions 29 | * @prop {SendPacket} send 30 | */ 31 | 32 | /** 33 | * @typedef { 'connected' | 'disconnected' 34 | * | 'logged' | 'ping' | 'data' 35 | * | 'error' | 'event' 36 | * } ClientEvent 37 | */ 38 | 39 | /** @class */ 40 | module.exports = class Client { 41 | #ws; 42 | 43 | #logged = false; 44 | 45 | /** If the client is logged in */ 46 | get isLogged() { 47 | return this.#logged; 48 | } 49 | 50 | /** If the cient was closed */ 51 | get isOpen() { 52 | return this.#ws.readyState === this.#ws.OPEN; 53 | } 54 | 55 | /** @type {SessionList} */ 56 | #sessions = {}; 57 | 58 | #callbacks = { 59 | connected: [], 60 | disconnected: [], 61 | logged: [], 62 | ping: [], 63 | data: [], 64 | 65 | error: [], 66 | event: [], 67 | }; 68 | 69 | /** 70 | * @param {ClientEvent} ev Client event 71 | * @param {...{}} data Packet data 72 | */ 73 | #handleEvent(ev, ...data) { 74 | this.#callbacks[ev].forEach((e) => e(...data)); 75 | this.#callbacks.event.forEach((e) => e(ev, ...data)); 76 | } 77 | 78 | #handleError(...msgs) { 79 | if (this.#callbacks.error.length === 0) console.error(...msgs); 80 | else this.#handleEvent('error', ...msgs); 81 | } 82 | 83 | /** 84 | * When client is connected 85 | * @param {() => void} cb Callback 86 | * @event onConnected 87 | */ 88 | onConnected(cb) { 89 | this.#callbacks.connected.push(cb); 90 | } 91 | 92 | /** 93 | * When client is disconnected 94 | * @param {() => void} cb Callback 95 | * @event onDisconnected 96 | */ 97 | onDisconnected(cb) { 98 | this.#callbacks.disconnected.push(cb); 99 | } 100 | 101 | /** 102 | * @typedef {Object} SocketSession 103 | * @prop {string} session_id Socket session ID 104 | * @prop {number} timestamp Session start timestamp 105 | * @prop {number} timestampMs Session start milliseconds timestamp 106 | * @prop {string} release Release 107 | * @prop {string} studies_metadata_hash Studies metadata hash 108 | * @prop {'json' | string} protocol Used protocol 109 | * @prop {string} javastudies Javastudies 110 | * @prop {number} auth_scheme_vsn Auth scheme type 111 | * @prop {string} via Socket IP 112 | */ 113 | 114 | /** 115 | * When client is logged 116 | * @param {(SocketSession: SocketSession) => void} cb Callback 117 | * @event onLogged 118 | */ 119 | onLogged(cb) { 120 | this.#callbacks.logged.push(cb); 121 | } 122 | 123 | /** 124 | * When server is pinging the client 125 | * @param {(i: number) => void} cb Callback 126 | * @event onPing 127 | */ 128 | onPing(cb) { 129 | this.#callbacks.ping.push(cb); 130 | } 131 | 132 | /** 133 | * When unparsed data is received 134 | * @param {(...{}) => void} cb Callback 135 | * @event onData 136 | */ 137 | onData(cb) { 138 | this.#callbacks.data.push(cb); 139 | } 140 | 141 | /** 142 | * When a client error happens 143 | * @param {(...{}) => void} cb Callback 144 | * @event onError 145 | */ 146 | onError(cb) { 147 | this.#callbacks.error.push(cb); 148 | } 149 | 150 | /** 151 | * When a client event happens 152 | * @param {(...{}) => void} cb Callback 153 | * @event onEvent 154 | */ 155 | onEvent(cb) { 156 | this.#callbacks.event.push(cb); 157 | } 158 | 159 | #parsePacket(str) { 160 | if (!this.isOpen) return; 161 | 162 | protocol.parseWSPacket(str).forEach((packet) => { 163 | if (global.TW_DEBUG) console.log('§90§30§107 CLIENT §0 PACKET', packet); 164 | if (typeof packet === 'number') { // Ping 165 | this.#ws.send(protocol.formatWSPacket(`~h~${packet}`)); 166 | this.#handleEvent('ping', packet); 167 | return; 168 | } 169 | 170 | if (packet.m === 'protocol_error') { // Error 171 | this.#handleError('Client critical error:', packet.p); 172 | this.#ws.close(); 173 | return; 174 | } 175 | 176 | if (packet.m && packet.p) { // Normal packet 177 | const parsed = { 178 | type: packet.m, 179 | data: packet.p, 180 | }; 181 | 182 | const session = packet.p[0]; 183 | 184 | if (session && this.#sessions[session]) { 185 | this.#sessions[session].onData(parsed); 186 | return; 187 | } 188 | } 189 | 190 | if (!this.#logged) { 191 | this.#handleEvent('logged', packet); 192 | return; 193 | } 194 | 195 | this.#handleEvent('data', packet); 196 | }); 197 | } 198 | 199 | #sendQueue = []; 200 | 201 | /** @type {SendPacket} Send a custom packet */ 202 | send(t, p = []) { 203 | this.#sendQueue.push(protocol.formatWSPacket({ m: t, p })); 204 | this.sendQueue(); 205 | } 206 | 207 | /** Send all waiting packets */ 208 | sendQueue() { 209 | while (this.isOpen && this.#logged && this.#sendQueue.length > 0) { 210 | const packet = this.#sendQueue.shift(); 211 | this.#ws.send(packet); 212 | if (global.TW_DEBUG) console.log('§90§30§107 > §0', packet); 213 | } 214 | } 215 | 216 | /** 217 | * @typedef {Object} ClientOptions 218 | * @prop {string} [token] User auth token (in 'sessionid' cookie) 219 | * @prop {string} [signature] User auth token signature (in 'sessionid_sign' cookie) 220 | * @prop {boolean} [DEBUG] Enable debug mode 221 | * @prop {'data' | 'prodata' | 'widgetdata'} [server] Server type 222 | * @prop {string} [location] Auth page location (For france: https://fr.tradingview.com/) 223 | * @prop {string} [proxy] Proxy URL 224 | */ 225 | 226 | /** 227 | * Client object 228 | * @param {ClientOptions} clientOptions TradingView client options 229 | */ 230 | constructor(clientOptions = {}) { 231 | if (clientOptions.DEBUG) global.TW_DEBUG = clientOptions.DEBUG; 232 | 233 | const server = clientOptions.server || 'data'; 234 | let agent = undefined; 235 | const proxyUrl = clientOptions.proxy || process.env.TV_API_PROXY; 236 | if (proxyUrl) { 237 | if (proxyUrl.startsWith('socks')) { 238 | agent = new SocksProxyAgent.SocksProxyAgent(proxyUrl); 239 | } else { 240 | agent = new HttpsProxyAgent.HttpsProxyAgent(proxyUrl); 241 | } 242 | if (global.TW_DEBUG) console.log('Using proxy agent for TradingView WS:', proxyUrl); 243 | } 244 | this.#ws = new WebSocket(`wss://${server}.tradingview.com/socket.io/websocket?type=chart`, { 245 | origin: 'https://www.tradingview.com', 246 | agent 247 | }); 248 | 249 | if (clientOptions.token) { 250 | misc.getUser( 251 | clientOptions.token, 252 | clientOptions.signature ? clientOptions.signature : '', 253 | clientOptions.location ? clientOptions.location : 'https://tradingview.com', 254 | ).then((user) => { 255 | this.#sendQueue.unshift(protocol.formatWSPacket({ 256 | m: 'set_auth_token', 257 | p: [user.authToken], 258 | })); 259 | this.#logged = true; 260 | this.sendQueue(); 261 | }).catch((err) => { 262 | this.#handleError('Credentials error:', err.message); 263 | }); 264 | } else { 265 | this.#sendQueue.unshift(protocol.formatWSPacket({ 266 | m: 'set_auth_token', 267 | p: ['unauthorized_user_token'], 268 | })); 269 | this.#logged = true; 270 | this.sendQueue(); 271 | } 272 | 273 | this.#ws.on('open', () => { 274 | this.#handleEvent('connected'); 275 | this.sendQueue(); 276 | }); 277 | 278 | this.#ws.on('close', () => { 279 | this.#logged = false; 280 | this.#handleEvent('disconnected'); 281 | }); 282 | 283 | this.#ws.on('message', (data) => this.#parsePacket(data)); 284 | } 285 | 286 | /** @type {ClientBridge} */ 287 | #clientBridge = { 288 | sessions: this.#sessions, 289 | send: (t, p) => this.send(t, p), 290 | }; 291 | 292 | /** @namespace Session */ 293 | Session = { 294 | Quote: quoteSessionGenerator(this.#clientBridge), 295 | Chart: chartSessionGenerator(this.#clientBridge), 296 | }; 297 | 298 | /** 299 | * Close the websocket connection 300 | * @return {Promise} When websocket is closed 301 | */ 302 | end() { 303 | return new Promise((cb) => { 304 | if (this.#ws.readyState) this.#ws.close(); 305 | cb(); 306 | }); 307 | } 308 | }; 309 | -------------------------------------------------------------------------------- /tradingview_vendor/chart/graphicParser.js: -------------------------------------------------------------------------------- 1 | const TRANSLATOR = { 2 | /** @typedef {'right' | 'left' | 'both' | 'none'} ExtendValue */ 3 | extend: { 4 | r: 'right', 5 | l: 'left', 6 | b: 'both', 7 | n: 'none', 8 | }, 9 | 10 | /** @typedef {'price' | 'abovebar' | 'belowbar'} yLocValue */ 11 | yLoc: { 12 | pr: 'price', 13 | ab: 'abovebar', 14 | bl: 'belowbar', 15 | }, 16 | 17 | /** 18 | * @typedef {'none' | 'xcross' | 'cross' | 'triangleup' 19 | * | 'triangledown' | 'flag' | 'circle' | 'arrowup' 20 | * | 'arrowdown' | 'label_up' | 'label_down' | 'label_left' 21 | * | 'label_right' | 'label_lower_left' | 'label_lower_right' 22 | * | 'label_upper_left' | 'label_upper_right' | 'label_center' 23 | * | 'square' | 'diamond' 24 | * } LabelStyleValue 25 | * */ 26 | labelStyle: { 27 | n: 'none', 28 | xcr: 'xcross', 29 | cr: 'cross', 30 | tup: 'triangleup', 31 | tdn: 'triangledown', 32 | flg: 'flag', 33 | cir: 'circle', 34 | aup: 'arrowup', 35 | adn: 'arrowdown', 36 | lup: 'label_up', 37 | ldn: 'label_down', 38 | llf: 'label_left', 39 | lrg: 'label_right', 40 | llwlf: 'label_lower_left', 41 | llwrg: 'label_lower_right', 42 | luplf: 'label_upper_left', 43 | luprg: 'label_upper_right', 44 | lcn: 'label_center', 45 | sq: 'square', 46 | dia: 'diamond', 47 | }, 48 | 49 | /** 50 | * @typedef {'solid' | 'dotted' | 'dashed'| 'arrow_left' 51 | * | 'arrow_right' | 'arrow_both'} LineStyleValue 52 | */ 53 | lineStyle: { 54 | sol: 'solid', 55 | dot: 'dotted', 56 | dsh: 'dashed', 57 | al: 'arrow_left', 58 | ar: 'arrow_right', 59 | ab: 'arrow_both', 60 | }, 61 | 62 | /** @typedef {'solid' | 'dotted' | 'dashed'} BoxStyleValue */ 63 | boxStyle: { 64 | sol: 'solid', 65 | dot: 'dotted', 66 | dsh: 'dashed', 67 | }, 68 | }; 69 | 70 | /** 71 | * @typedef {'auto' | 'huge' | 'large' 72 | * | 'normal' | 'small' | 'tiny'} SizeValue 73 | */ 74 | /** @typedef {'top' | 'center' | 'bottom'} VAlignValue */ 75 | /** @typedef {'left' | 'center' | 'right'} HAlignValue */ 76 | /** @typedef {'none' | 'auto'} TextWrapValue */ 77 | /** 78 | * @typedef {'top_left' | 'top_center' | 'top_right' 79 | * | 'middle_left' | 'middle_center' | 'middle_right' 80 | * | 'bottom_left' | 'bottom_center' | 'bottom_right' 81 | * } TablePositionValue 82 | */ 83 | 84 | /** 85 | * @typedef {Object} GraphicLabel 86 | * @prop {number} id Drawing ID 87 | * @prop {number} x Label x position 88 | * @prop {number} y Label y position 89 | * @prop {yLocValue} yLoc yLoc mode 90 | * @prop {string} text Label text 91 | * @prop {LabelStyleValue} style Label style 92 | * @prop {number} color 93 | * @prop {number} textColor 94 | * @prop {SizeValue} size Label size 95 | * @prop {HAlignValue} textAlign Text horizontal align 96 | * @prop {string} toolTip Tooltip text 97 | */ 98 | 99 | /** 100 | * @typedef {Object} GraphicLine 101 | * @prop {number} id Drawing ID 102 | * @prop {number} x1 First x position 103 | * @prop {number} y1 First y position 104 | * @prop {number} x2 Second x position 105 | * @prop {number} y2 Second y position 106 | * @prop {ExtendValue} extend Horizontal extend 107 | * @prop {LineStyleValue} style Line style 108 | * @prop {number} color Line color 109 | * @prop {number} width Line width 110 | */ 111 | 112 | /** 113 | * @typedef {Object} GraphicBox 114 | * @prop {number} id Drawing ID 115 | * @prop {number} x1 First x position 116 | * @prop {number} y1 First y position 117 | * @prop {number} x2 Second x position 118 | * @prop {number} y2 Second y position 119 | * @prop {number} color Box color 120 | * @prop {number} bgColor Background color 121 | * @prop {ExtendValue} extend Horizontal extend 122 | * @prop {BoxStyleValue} style Box style 123 | * @prop {number} width Box width 124 | * @prop {string} text Text 125 | * @prop {SizeValue} textSize Text size 126 | * @prop {number} textColor Text color 127 | * @prop {VAlignValue} textVAlign Text vertical align 128 | * @prop {HAlignValue} textHAlign Text horizontal align 129 | * @prop {TextWrapValue} textWrap Text wrap 130 | */ 131 | 132 | /** 133 | * @typedef {Object} TableCell 134 | * @prop {number} id Drawing ID 135 | * @prop {string} text Cell text 136 | * @prop {number} width Cell width 137 | * @prop {number} height Cell height 138 | * @prop {number} textColor Text color 139 | * @prop {HAlignValue} textHAlign Text horizontal align 140 | * @prop {VAlignValue} textVAlign Text Vertical align 141 | * @prop {SizeValue} textSize Text size 142 | * @prop {number} bgColor Background color 143 | */ 144 | 145 | /** 146 | * @typedef {Object} GraphicTable 147 | * @prop {number} id Drawing ID 148 | * @prop {TablePositionValue} position Table position 149 | * @prop {number} rows Number of rows 150 | * @prop {number} columns Number of columns 151 | * @prop {number} bgColor Background color 152 | * @prop {number} frameColor Frame color 153 | * @prop {number} frameWidth Frame width 154 | * @prop {number} borderColor Border color 155 | * @prop {number} borderWidth Border width 156 | * @prop {() => TableCell[][]} cells Table cells matrix 157 | */ 158 | 159 | /** 160 | * @typedef {Object} GraphicHorizline 161 | * @prop {number} id Drawing ID 162 | * @prop {number} level Y position of the line 163 | * @prop {number} startIndex Start index of the line (`chart.periods[line.startIndex]`) 164 | * @prop {number} endIndex End index of the line (`chart.periods[line.endIndex]`) 165 | * @prop {boolean} extendRight Is the line extended to the right 166 | * @prop {boolean} extendLeft Is the line extended to the left 167 | */ 168 | 169 | /** 170 | * @typedef {Object} GraphicPoint 171 | * @prop {number} index X position of the point 172 | * @prop {number} level Y position of the point 173 | */ 174 | 175 | /** 176 | * @typedef {Object} GraphicPolygon 177 | * @prop {number} id Drawing ID 178 | * @prop {GraphicPoint[]} points List of polygon points 179 | */ 180 | 181 | /** 182 | * @typedef {Object} GraphicHorizHist 183 | * @prop {number} id Drawing ID 184 | * @prop {number} priceLow Low Y position 185 | * @prop {number} priceHigh High Y position 186 | * @prop {number} firstBarTime First X position 187 | * @prop {number} lastBarTime Last X position 188 | * @prop {number[]} rate List of values 189 | */ 190 | 191 | /** 192 | * @typedef {Object} GraphicData List of drawings indexed by type 193 | * @prop {GraphicLabel[]} labels List of labels drawings 194 | * @prop {GraphicLine[]} lines List of lines drawings 195 | * @prop {GraphicBox[]} boxes List of boxes drawings 196 | * @prop {GraphicTable[]} tables List of tables drawings 197 | * @prop {GraphicPolygon[]} polygons List of polygons drawings 198 | * @prop {GraphicHorizHist[]} horizHists List of horizontal histograms drawings 199 | * @prop {GraphicHorizline[]} horizLines List of horizontal lines drawings 200 | */ 201 | 202 | /** 203 | * @param {Object} rawGraphic Raw graphic data 204 | * @param {Object} indexes Drawings xPos indexes 205 | * @returns {GraphicData} 206 | */ 207 | module.exports = function graphicParse(rawGraphic = {}, indexes = []) { 208 | // console.log('indexes', indexes); 209 | return { 210 | labels: Object.values(rawGraphic.dwglabels ?? {}).map((l) => ({ 211 | id: l.id, 212 | x: indexes[l.x], 213 | y: l.y, 214 | yLoc: TRANSLATOR.yLoc[l.yl] ?? l.yl, 215 | text: l.t, 216 | style: TRANSLATOR.labelStyle[l.st] ?? l.st, 217 | color: l.ci, 218 | textColor: l.tci, 219 | size: l.sz, 220 | textAlign: l.ta, 221 | toolTip: l.tt, 222 | })), 223 | 224 | lines: Object.values(rawGraphic.dwglines ?? {}).map((l) => ({ 225 | id: l.id, 226 | x1: indexes[l.x1], 227 | y1: l.y1, 228 | x2: indexes[l.x2], 229 | y2: l.y2, 230 | extend: TRANSLATOR.extend[l.ex] ?? l.ex, 231 | style: TRANSLATOR.lineStyle[l.st] ?? l.st, 232 | color: l.ci, 233 | width: l.w, 234 | })), 235 | 236 | boxes: Object.values(rawGraphic.dwgboxes ?? {}).map((b) => ({ 237 | id: b.id, 238 | x1: indexes[b.x1], 239 | y1: b.y1, 240 | x2: indexes[b.x2], 241 | y2: b.y2, 242 | color: b.c, 243 | bgColor: b.bc, 244 | extend: TRANSLATOR.extend[b.ex] ?? b.ex, 245 | style: TRANSLATOR.boxStyle[b.st] ?? b.st, 246 | width: b.w, 247 | text: b.t, 248 | textSize: b.ts, 249 | textColor: b.tc, 250 | textVAlign: b.tva, 251 | textHAlign: b.tha, 252 | textWrap: b.tw, 253 | })), 254 | 255 | tables: Object.values(rawGraphic.dwgtables ?? {}).map((t) => ({ 256 | id: t.id, 257 | position: t.pos, 258 | rows: t.rows, 259 | columns: t.cols, 260 | bgColor: t.bgc, 261 | frameColor: t.frmc, 262 | frameWidth: t.frmw, 263 | borderColor: t.brdc, 264 | borderWidth: t.brdw, 265 | cells: () => { 266 | const matrix = []; 267 | Object.values(rawGraphic.dwgtablecells ?? {}).forEach((cell) => { 268 | if (cell.tid !== t.id) return; 269 | if (!matrix[cell.row]) matrix[cell.row] = []; 270 | matrix[cell.row][cell.col] = { 271 | id: cell.id, 272 | text: cell.t, 273 | width: cell.w, 274 | height: cell.h, 275 | textColor: cell.tc, 276 | textHAlign: cell.tha, 277 | textVAlign: cell.tva, 278 | textSize: cell.ts, 279 | bgColor: cell.bgc, 280 | }; 281 | }); 282 | return matrix; 283 | }, 284 | })), 285 | 286 | horizLines: Object.values(rawGraphic.horizlines ?? {}).map((h) => ({ 287 | ...h, 288 | startIndex: indexes[h.startIndex], 289 | endIndex: indexes[h.endIndex], 290 | })), 291 | 292 | polygons: Object.values(rawGraphic.polygons ?? {}).map((p) => ({ 293 | ...p, 294 | points: p.points.map((pt) => ({ 295 | ...pt, 296 | index: indexes[pt.index], 297 | })), 298 | })), 299 | 300 | horizHists: Object.values(rawGraphic.hhists ?? {}).map((h) => ({ 301 | ...h, 302 | firstBarTime: indexes[h.firstBarTime], 303 | lastBarTime: indexes[h.lastBarTime], 304 | })), 305 | 306 | raw: () => rawGraphic, 307 | }; 308 | }; 309 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TradingView Fetcher 2 | 3 | A microservice for fetching real-time OHLCV data from TradingView and broadcasting it via a WebSocket API. Supports dynamic subscription management, resilience, and Prometheus metrics. 4 | 5 | **All code comments and documentation are in English.** 6 | 7 | ## Features 8 | 9 | - Real-time data from TradingView 10 | - WebSocket API for dynamic symbol subscription management 11 | - Broadcasts price updates via WebSocket 12 | - HTTP API integration for pushing data to external systems 13 | - Prometheus metrics monitoring 14 | - Configurable logging 15 | - Automatic reconnection on failure 16 | - Proxy support (optional) 17 | - Docker-ready for easy deployment 18 | - **Detailed price logging for diagnostics (see below)** 19 | 20 | ## Installation 21 | 22 | ### From Source 23 | 24 | ```bash 25 | # Clone the repository 26 | git clone 27 | cd tv-fetcher 28 | 29 | # Install dependencies 30 | npm install 31 | 32 | # Build the project 33 | npm run build 34 | 35 | # Start the service 36 | npm start 37 | ``` 38 | 39 | ### Using Docker 40 | 41 | ```bash 42 | # Build the image 43 | docker build -t tv-fetcher . 44 | 45 | # Run the container 46 | docker run -p 8081:8081 -p 9100:9100 --env-file .env tv-fetcher 47 | ``` 48 | 49 | ### Using Docker Compose 50 | 51 | ```bash 52 | docker-compose up -d 53 | ``` 54 | 55 | ## Configuration 56 | 57 | Copy `.env.example` to `.env` and set your parameters: 58 | 59 | ```bash 60 | cp .env.example .env 61 | ``` 62 | 63 | ### Configuration Parameters 64 | 65 | | Variable | Description | Default | 66 | |----------------------|------------------------------------------------------------------|------------------------| 67 | | `TV_API_PROXY` | Proxy for TradingView API (optional) | (empty) | 68 | | `TV_API_TIMEOUT_MS` | Timeout for TradingView API requests (ms) | 10000 | 69 | | `SUBSCRIPTIONS` | JSON array of initial subscriptions | `[{"symbol":"BINANCE:BTCUSDT","timeframe":"1"}]` | 70 | | `BACKEND_ENDPOINT` | HTTP endpoint for pushing data | (empty) | 71 | | `BACKEND_API_KEY` | API key for pushing data | (empty) | 72 | | `WEBSOCKET_PORT` | WebSocket server port | 8081 | 73 | | `WEBSOCKET_ENABLED` | Enable WebSocket API | true | 74 | | `METRICS_PORT` | Prometheus metrics port | 9100 | 75 | | `LOG_LEVEL` | Logging level (debug, info, warn, error) | info | 76 | | `LOG_FILE` | Log file path | ./logs/tv-fetcher.log | 77 | | `DEBUG_PRICES` | Enable detailed price logging (true/false) | false | 78 | | `PRICES_LOG_FILE` | File to log all received price bars if DEBUG_PRICES is true | ./logs/prices.log | 79 | | `HEALTH_CHECK_INTERVAL_MS` | How often to check for stale subscriptions (ms) | 60000 | 80 | | `HEALTH_STALE_THRESHOLD_MULTIPLIER` | Multiplier for stale detection (timeframe × multiplier) | 3 | 81 | | `HEALTH_AUTO_RECOVERY_ENABLED` | Enable automatic recovery of stale subscriptions | true | 82 | | `HEALTH_MAX_RECOVERY_ATTEMPTS` | Maximum recovery attempts per subscription | 3 | 83 | | `HEALTH_FULL_RECONNECT_THRESHOLD` | Stale subscriptions count to trigger full reconnect | 3 | 84 | | `HEALTH_FULL_RECONNECT_COOLDOWN_MS` | Cooldown between full reconnects (ms) | 600000 | 85 | 86 | #### Detailed Price Logging 87 | 88 | If you set `DEBUG_PRICES=true` in your `.env`, every price bar received from TradingView will be logged in detail to the file specified by `PRICES_LOG_FILE` (default: `./logs/prices.log`). 89 | 90 | Each log entry will include the symbol, timeframe, timestamp, open, high, low, close, and volume, e.g.: 91 | 92 | ``` 93 | 2024-05-16T14:00:00.000Z [PRICE] BINANCE:BTCUSDT/1 2024-05-16T14:00:00.000Z O:65000.00 H:65100.00 L:64900.00 C:65050.00 V:12.34 94 | ``` 95 | 96 | This is useful for diagnostics and for verifying exactly what data is being received from TradingView, especially if you suspect issues with data delivery or backend integration. 97 | 98 | ### Timeframes 99 | 100 | TradingView API uses the following timeframe formats: 101 | 102 | | Human format | TradingView API format | 103 | |--------------|-------------------------| 104 | | 1 minute | "1" | 105 | | 5 minutes | "5" | 106 | | 15 minutes | "15" | 107 | | 30 minutes | "30" | 108 | | 1 hour | "60" | 109 | | 4 hours | "240" | 110 | | 1 day | "D" | 111 | | 1 week | "W" | 112 | | 1 month | "M" | 113 | 114 | ## Health Monitoring System 115 | 116 | This service includes a comprehensive health monitoring system for TradingView data flow, ensuring reliable data delivery under all conditions. 117 | 118 | ### How It Works 119 | 120 | 1. **Data Flow Monitoring**: The system tracks the timestamp of the last bar received for each subscription. 121 | 2. **Stale Detection**: A subscription is considered "stale" if no data has been received for longer than expected (timeframe duration × multiplier). 122 | 3. **Auto-Recovery**: When stale subscriptions are detected, the system automatically attempts recovery through targeted resubscription. 123 | 4. **Progressive Recovery**: Multiple recovery strategies are employed based on the severity of the issue: 124 | - **Individual Recovery**: First attempts to unsubscribe and resubscribe to the affected symbol/timeframe. 125 | - **Full Reconnection**: If multiple subscriptions become stale, performs a complete TradingView reconnection. 126 | 127 | ### Health Monitoring Configuration 128 | 129 | The health monitoring system is configurable through the following environment variables: 130 | 131 | - `HEALTH_CHECK_INTERVAL_MS`: How often to check for stale subscriptions (default: 60000 ms) 132 | - `HEALTH_STALE_THRESHOLD_MULTIPLIER`: How many timeframe intervals to wait before considering a subscription stale (default: 3) 133 | - `HEALTH_AUTO_RECOVERY_ENABLED`: Enable/disable automatic recovery attempts (default: true) 134 | - `HEALTH_MAX_RECOVERY_ATTEMPTS`: Maximum recovery attempts per subscription before giving up (default: 3) 135 | - `HEALTH_FULL_RECONNECT_THRESHOLD`: Number of stale subscriptions that triggers a full reconnect (default: 3) 136 | - `HEALTH_FULL_RECONNECT_COOLDOWN_MS`: Minimum time between full reconnects (default: 600000 ms) 137 | 138 | ### Health Monitoring Logs 139 | 140 | The health monitoring system logs detailed information about its operation with the `[HEALTH]` prefix: 141 | 142 | ``` 143 | [HEALTH] Stale subscription detected for BINANCE:BTCUSDT/1 - no data for 3m 45s (threshold: 180s) 144 | [HEALTH] Attempting recovery for BINANCE:BTCUSDT/1 (attempt 1/3) 145 | [HEALTH] Successfully resubscribed to BINANCE:BTCUSDT/1 146 | ``` 147 | 148 | For severe issues, more aggressive recovery actions are logged: 149 | 150 | ``` 151 | [HEALTH] 4 stale subscriptions exceeds threshold (3), performing full reconnect 152 | [HEALTH] Performing full TradingView reconnect due to multiple stale subscriptions 153 | [HEALTH] Full TradingView reconnect successful 154 | ``` 155 | 156 | ### Health Monitoring Metrics 157 | 158 | The following Prometheus metrics are exposed for monitoring the health of TradingView data flow: 159 | 160 | - `stale_subscriptions`: Gauge of currently stale subscriptions 161 | - `recovery_attempts_total`: Counter of recovery attempts 162 | - `successful_recoveries_total`: Counter of successful recovery attempts 163 | - `failed_recoveries_total`: Counter of failed recovery attempts 164 | - `full_reconnects_total`: Counter of full TradingView reconnections 165 | - `last_data_received_seconds`: Gauge of seconds since last data per subscription (labeled by symbol and timeframe) 166 | 167 | These metrics can be used to set up alerts for persistent data flow issues. 168 | 169 | ## WebSocket API 170 | 171 | The service provides a WebSocket API for managing subscriptions and receiving real-time data. 172 | 173 | ### Message Format 174 | 175 | #### Requests (client → server) 176 | 177 | ```json 178 | { 179 | "action": "subscribe", // or unsubscribe, list, subscribe_many, unsubscribe_many 180 | "symbol": "BINANCE:BTCUSDT", // for subscribe/unsubscribe 181 | "timeframe": "1", // for subscribe/unsubscribe 182 | "pairs": [ // for subscribe_many/unsubscribe_many 183 | { "symbol": "BINANCE:BTCUSDT", "timeframe": "1" }, 184 | { "symbol": "BINANCE:ETHUSDT", "timeframe": "5" } 185 | ], 186 | "requestId": "optional-string-id" 187 | } 188 | ``` 189 | 190 | #### Responses (server → client) 191 | 192 | ```json 193 | { 194 | "type": "subscribe", // or unsubscribe, list, bar, error, info, subscribe_many, unsubscribe_many 195 | "success": true, 196 | "message": "Subscription created", 197 | "requestId": "optional-string-id", 198 | "symbol": "BINANCE:BTCUSDT", 199 | "timeframe": "1", 200 | "subscriptions": [ { "symbol": "BINANCE:BTCUSDT", "timeframe": "1" } ], // for list and bulk 201 | "bar": { /* ... */ }, // for type: bar 202 | "results": [ /* ... */ ] // for bulk operations 203 | } 204 | ``` 205 | 206 | #### Example Requests 207 | 208 | - **Subscribe to a single instrument:** 209 | ```json 210 | { 211 | "action": "subscribe", 212 | "symbol": "BINANCE:BTCUSDT", 213 | "timeframe": "1", 214 | "requestId": "sub-1" 215 | } 216 | ``` 217 | - **Unsubscribe:** 218 | ```json 219 | { 220 | "action": "unsubscribe", 221 | "symbol": "BINANCE:BTCUSDT", 222 | "timeframe": "1", 223 | "requestId": "unsub-1" 224 | } 225 | ``` 226 | - **Get subscription list:** 227 | ```json 228 | { 229 | "action": "list", 230 | "requestId": "list-1" 231 | } 232 | ``` 233 | - **Bulk subscribe:** 234 | ```json 235 | { 236 | "action": "subscribe_many", 237 | "pairs": [ 238 | { "symbol": "BINANCE:BTCUSDT", "timeframe": "1" }, 239 | { "symbol": "BINANCE:ETHUSDT", "timeframe": "5" } 240 | ], 241 | "requestId": "bulk-sub-1" 242 | } 243 | ``` 244 | - **Bulk unsubscribe:** 245 | ```json 246 | { 247 | "action": "unsubscribe_many", 248 | "pairs": [ 249 | { "symbol": "BINANCE:BTCUSDT", "timeframe": "1" }, 250 | { "symbol": "BINANCE:ETHUSDT", "timeframe": "5" } 251 | ], 252 | "requestId": "bulk-unsub-1" 253 | } 254 | ``` 255 | 256 | #### Example Responses 257 | 258 | - **Successful subscription:** 259 | ```json 260 | { 261 | "type": "subscribe", 262 | "success": true, 263 | "message": "Subscription created", 264 | "symbol": "BINANCE:BTCUSDT", 265 | "timeframe": "1", 266 | "requestId": "sub-1" 267 | } 268 | ``` 269 | - **Bulk subscribe:** 270 | ```json 271 | { 272 | "type": "subscribe_many", 273 | "success": true, 274 | "message": "Bulk subscribe processed", 275 | "subscriptions": [ 276 | { "symbol": "BINANCE:BTCUSDT", "timeframe": "1" }, 277 | { "symbol": "BINANCE:ETHUSDT", "timeframe": "5" } 278 | ], 279 | "results": [ 280 | { "symbol": "BINANCE:BTCUSDT", "timeframe": "1", "success": true, "message": "Subscription created" }, 281 | { "symbol": "BINANCE:ETHUSDT", "timeframe": "5", "success": true, "message": "Subscription created" } 282 | ], 283 | "requestId": "bulk-sub-1" 284 | } 285 | ``` 286 | - **Error:** 287 | ```json 288 | { 289 | "type": "error", 290 | "success": false, 291 | "message": "Symbol and timeframe are required for subscription", 292 | "requestId": "sub-err-1" 293 | } 294 | ``` -------------------------------------------------------------------------------- /src/tradingview.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import { config } from './config'; 3 | import type { Subscription } from './config'; 4 | import { logger, priceLogger } from './logger'; 5 | import { wsConnectsTotal, wsErrorsTotal, subscriptionsGauge } from './metrics'; 6 | 7 | // Import TradingView API from local vendor directory 8 | // eslint-disable-next-line @typescript-eslint/no-var-requires 9 | const TradingViewAPI = require('../tradingview_vendor/main'); 10 | 11 | export interface Bar { 12 | symbol: string; 13 | timeframe: string; 14 | time: number; 15 | open: number; 16 | high: number; 17 | low: number; 18 | close: number; 19 | volume: number; 20 | } 21 | 22 | export class TradingViewClient extends EventEmitter { 23 | private client: any; 24 | private connected = false; 25 | private charts: Map = new Map(); // Track subscriptions for each symbol 26 | private reconnectTimeout: NodeJS.Timeout | null = null; 27 | private reconnectAttempts = 0; 28 | private readonly maxReconnectAttempts = 10; 29 | private readonly reconnectDelay = 5000; // 5 seconds base delay 30 | 31 | constructor() { 32 | super(); 33 | } 34 | 35 | async connect() { 36 | try { 37 | logger.info('Creating TradingView client...'); 38 | 39 | // Create TradingView API client 40 | this.client = new TradingViewAPI.Client({ 41 | // Use proxy if specified in config 42 | proxy: config.tvApi.proxy || undefined, 43 | // Set connection timeout 44 | timeout_ms: config.tvApi.timeoutMs 45 | }); 46 | 47 | logger.info('TradingView client created successfully'); 48 | 49 | if (!this.client) { 50 | throw new Error('Could not initialize TradingView client'); 51 | } 52 | 53 | this.connected = true; 54 | this.reconnectAttempts = 0; 55 | wsConnectsTotal.inc(); 56 | this.emit('connect'); 57 | logger.info('[DIAG] TradingView connected. Will restore subscriptions if needed.'); 58 | logger.info('[DIAG] Subscriptions to restore: %o', config.subscriptions); 59 | } catch (err) { 60 | logger.error('Failed to connect TradingView WS: %s', (err as Error).message); 61 | wsErrorsTotal.inc(); 62 | this.emit('error', err); 63 | this.scheduleReconnect(); 64 | } 65 | } 66 | 67 | private scheduleReconnect() { 68 | // Prevent multiple reconnect attempts 69 | if (this.reconnectTimeout) { 70 | clearTimeout(this.reconnectTimeout); 71 | } 72 | 73 | this.reconnectAttempts++; 74 | if (this.reconnectAttempts > this.maxReconnectAttempts) { 75 | logger.error(`Max reconnect attempts (${this.maxReconnectAttempts}) reached`); 76 | this.emit('max_reconnect_attempts'); 77 | return; 78 | } 79 | 80 | // Exponential backoff with jitter to prevent thundering herd 81 | const delay = Math.min( 82 | this.reconnectDelay * Math.pow(1.5, this.reconnectAttempts - 1) * (1 + Math.random() * 0.2), 83 | 60000 // max 1 minute 84 | ); 85 | 86 | logger.info(`[DIAG] Scheduling reconnect in ${Math.round(delay / 1000)}s (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); 87 | 88 | this.reconnectTimeout = setTimeout(() => { 89 | logger.info('[DIAG] Reconnecting TradingView...'); 90 | this.connect().catch(err => { 91 | logger.error('Reconnect failed:', err); 92 | this.scheduleReconnect(); 93 | }); 94 | }, delay); 95 | } 96 | 97 | // Subscribe to symbol/timeframe 98 | async subscribe(subscription: Subscription, reason: string = 'explicit'): Promise { 99 | logger.info(`[DIAG] subscribe() called for %s/%s (%s). Current charts: %o`, subscription.symbol, subscription.timeframe, reason, Array.from(this.charts.keys())); 100 | if (!this.connected || !this.client) { 101 | logger.error('Cannot subscribe, client not connected'); 102 | throw new Error('TradingView client not connected'); 103 | } 104 | 105 | const { symbol, timeframe } = subscription; 106 | const key = `${symbol}_${timeframe}`; 107 | 108 | // If already subscribed, do nothing 109 | if (this.charts.has(key)) { 110 | logger.info(`[DIAG] Already subscribed to %s/%s (%s). Charts: %o`, symbol, timeframe, reason, Array.from(this.charts.keys())); 111 | return true; 112 | } 113 | 114 | try { 115 | logger.info(`[DIAG] Subscribing to %s/%s (%s)`, symbol, timeframe, reason); 116 | 117 | // Create separate chart for symbol/timeframe 118 | const chart = new this.client.Session.Chart(); 119 | 120 | // Handle errors 121 | chart.onError((...err: any[]) => { 122 | logger.error('Chart error for %s/%s: %o', symbol, timeframe, err); 123 | this.emit('chart_error', { symbol, timeframe, error: err }); 124 | logger.error(`[DIAG] Chart error for %s/%s after %s: %o`, symbol, timeframe, reason, err); 125 | }); 126 | 127 | // When symbol is loaded 128 | chart.onSymbolLoaded(() => { 129 | logger.info('Symbol loaded for %s/%s: %s', symbol, timeframe, chart.infos?.description || 'Unknown'); 130 | this.emit('symbol_loaded', { symbol, timeframe, description: chart.infos?.description }); 131 | }); 132 | 133 | // Handle data updates 134 | chart.onUpdate(() => { 135 | if (!chart.periods || !chart.periods[0]) return; 136 | 137 | // Get last bar 138 | const lastBar = chart.periods[0]; 139 | 140 | if (lastBar) { 141 | // Prepare bar for push 142 | const bar: Bar = { 143 | symbol, 144 | timeframe, 145 | time: lastBar.time, 146 | open: lastBar.open, 147 | high: lastBar.max || lastBar.high, // Support for different data formats 148 | low: lastBar.min || lastBar.low, // Support for different data formats 149 | close: lastBar.close, 150 | volume: lastBar.volume || 0, 151 | }; 152 | 153 | logger.debug('Got bar: %o', bar); 154 | 155 | if (config.debugPrices) { 156 | priceLogger.info( 157 | '[PRICE] %s/%s %s O:%.5f H:%.5f L:%.5f C:%.5f V:%.2f', 158 | bar.symbol, bar.timeframe, new Date(bar.time * 1000).toISOString(), 159 | bar.open, bar.high, bar.low, bar.close, bar.volume 160 | ); 161 | } 162 | 163 | // Emit bar to listeners 164 | this.emit('bar', bar); 165 | } 166 | }); 167 | 168 | // Set market 169 | chart.setMarket(symbol, { 170 | timeframe 171 | }); 172 | 173 | // Save chart for this subscription 174 | this.charts.set(key, chart); 175 | logger.info('[DIAG] charts after subscribe: %o', Array.from(this.charts.keys())); 176 | subscriptionsGauge.set(this.charts.size); 177 | logger.info(`[DIAG] Subscribed to %s/%s (%s). Charts now: %o`, symbol, timeframe, reason, Array.from(this.charts.keys())); 178 | this.emit('subscribed', subscription); 179 | 180 | return true; 181 | } catch (err) { 182 | logger.error('Failed to subscribe to %s/%s: %s', symbol, timeframe, (err as Error).message); 183 | this.emit('subscription_error', { subscription, error: err }); 184 | logger.error(`[DIAG] Failed to subscribe to %s/%s after %s: %s`, symbol, timeframe, reason, (err as Error).message); 185 | return false; 186 | } 187 | } 188 | 189 | // Unsubscribe from symbol/timeframe 190 | async unsubscribe(symbol: string, timeframe: string): Promise { 191 | const key = `${symbol}_${timeframe}`; 192 | logger.info(`[DIAG] unsubscribe() called for %s/%s. Current charts: %o`, symbol, timeframe, Array.from(this.charts.keys())); 193 | const chart = this.charts.get(key); 194 | 195 | if (!chart) { 196 | logger.warn('Cannot unsubscribe, subscription not found: %s/%s', symbol, timeframe); 197 | return false; 198 | } 199 | 200 | try { 201 | logger.info('Unsubscribing from TradingView: %s/%s', symbol, timeframe); 202 | if (typeof chart.delete === 'function') { 203 | chart.delete(); 204 | logger.info('Chart.delete() called for %s/%s', symbol, timeframe); 205 | } else { 206 | logger.warn('Chart.delete() not a function for %s/%s', symbol, timeframe); 207 | } 208 | this.charts.delete(key); 209 | logger.info('[DIAG] charts after unsubscribe: %o', Array.from(this.charts.keys())); 210 | subscriptionsGauge.set(this.charts.size); 211 | logger.info('Unsubscribed from %s/%s, %d subscriptions remain. Charts now: %o', symbol, timeframe, this.charts.size, Array.from(this.charts.keys())); 212 | if (this.charts.size === 0) { 213 | logger.info('All TradingView subscriptions removed, TradingView client is now idle'); 214 | } 215 | this.emit('unsubscribed', { symbol, timeframe }); 216 | return true; 217 | } catch (err) { 218 | logger.error('Error unsubscribing from %s/%s: %s', symbol, timeframe, (err as Error).message); 219 | return false; 220 | } 221 | } 222 | 223 | // Get list of active subscriptions 224 | getSubscriptions(): Subscription[] { 225 | return Array.from(this.charts.keys()).map(key => { 226 | const [symbol, timeframe] = key.split('_'); 227 | return { symbol, timeframe }; 228 | }); 229 | } 230 | 231 | // Update subscriptions (subscribe to new and unsubscribe from removed) 232 | async updateSubscriptions(subscriptions: Subscription[], reason: string = 'explicit'): Promise { 233 | const currentSubs = this.getSubscriptions(); 234 | const currentKeys = new Set(currentSubs.map(s => `${s.symbol}_${s.timeframe}`)); 235 | const newKeys = new Set(subscriptions.map(s => `${s.symbol}_${s.timeframe}`)); 236 | 237 | // Unsubscribe from those not in the new list 238 | const toRemove = currentSubs.filter(s => !newKeys.has(`${s.symbol}_${s.timeframe}`)); 239 | for (const sub of toRemove) { 240 | await this.unsubscribe(sub.symbol, sub.timeframe); 241 | } 242 | 243 | // Subscribe to new ones 244 | const toAdd = subscriptions.filter(s => !currentKeys.has(`${s.symbol}_${s.timeframe}`)); 245 | let restored = 0; 246 | for (const sub of toAdd) { 247 | const ok = await this.subscribe(sub, reason); 248 | if (ok) restored++; 249 | } 250 | 251 | logger.info(`[DIAG] updateSubscriptions(%s): removed %d, added %d, restored %d`, reason, toRemove.length, toAdd.length, restored); 252 | if (reason === 'reconnect' && restored === 0) { 253 | logger.error('[DIAG] No subscriptions restored after reconnect!'); 254 | } 255 | } 256 | 257 | close() { 258 | // Cancel reconnect attempts 259 | if (this.reconnectTimeout) { 260 | clearTimeout(this.reconnectTimeout); 261 | this.reconnectTimeout = null; 262 | } 263 | 264 | // Close all subscriptions 265 | for (const [key, chart] of this.charts.entries()) { 266 | try { 267 | if (typeof chart.delete === 'function') { 268 | chart.delete(); 269 | } 270 | } catch (err) { 271 | logger.error('Error closing chart %s: %s', key, (err as Error).message); 272 | } 273 | } 274 | 275 | this.charts.clear(); 276 | subscriptionsGauge.set(0); 277 | 278 | // Close connection 279 | if (this.client && this.connected && typeof this.client.end === 'function') { 280 | try { 281 | this.client.end(); 282 | } catch (err) { 283 | logger.error('Error disconnecting from TradingView: %s', (err as Error).message); 284 | } 285 | } 286 | 287 | this.connected = false; 288 | this.emit('disconnect'); 289 | logger.info('TradingView client closed'); 290 | } 291 | 292 | /** 293 | * Полная очистка всех подписок TradingView. Удаляет все charts и завершает все сессии. 294 | */ 295 | public async resetAllSubscriptions(): Promise { 296 | logger.info('[DIAG] resetAllSubscriptions() called. Current charts: %o', Array.from(this.charts.keys())); 297 | for (const [key, chart] of this.charts.entries()) { 298 | try { 299 | if (typeof chart.delete === 'function') { 300 | chart.delete(); 301 | logger.info('[DIAG] Chart.delete() called for %s', key); 302 | } else { 303 | logger.warn('[DIAG] Chart.delete() not a function for %s', key); 304 | } 305 | } catch (err) { 306 | logger.error('[DIAG] Error deleting chart %s: %s', key, (err as Error).message); 307 | } 308 | } 309 | logger.info('[DIAG] charts before clear: %o', Array.from(this.charts.keys())); 310 | this.charts.clear(); 311 | subscriptionsGauge.set(0); 312 | logger.info('[DIAG] All TradingView subscriptions fully reset. Charts now: %o', Array.from(this.charts.keys())); 313 | } 314 | 315 | /** 316 | * Full reconnection with TradingView - disconnects, reconnects, and restores all subscriptions 317 | * This is a more aggressive reset than just resetAllSubscriptions() and is used for severe issues 318 | */ 319 | public async fullReconnect(): Promise { 320 | logger.warn('[DIAG] fullReconnect() called - performing complete TradingView reconnection'); 321 | 322 | try { 323 | // Save current subscriptions 324 | const currentSubscriptions = this.getSubscriptions(); 325 | const subscriptionCount = currentSubscriptions.length; 326 | 327 | if (subscriptionCount > 0) { 328 | logger.info('[DIAG] fullReconnect() - saving %d current subscriptions before reconnect', subscriptionCount); 329 | } 330 | 331 | // Close current connection completely 332 | this.close(); 333 | 334 | // Wait a moment 335 | await new Promise(resolve => setTimeout(resolve, 2000)); 336 | 337 | // Connect again 338 | await this.connect(); 339 | 340 | // If there were subscriptions, restore them 341 | if (subscriptionCount > 0) { 342 | logger.info('[DIAG] fullReconnect() - restoring %d subscriptions after reconnect', subscriptionCount); 343 | await this.updateSubscriptions(currentSubscriptions, 'full_reconnect'); 344 | } 345 | 346 | logger.info('[DIAG] fullReconnect() completed successfully'); 347 | return true; 348 | } catch (err) { 349 | logger.error('[DIAG] fullReconnect() failed: %s', (err as Error).message); 350 | return false; 351 | } 352 | } 353 | 354 | /** 355 | * Check if the client is connected to TradingView 356 | */ 357 | public isConnected(): boolean { 358 | return this.connected; 359 | } 360 | } -------------------------------------------------------------------------------- /tradingview_vendor/chart/study.js: -------------------------------------------------------------------------------- 1 | const { genSessionID } = require('../utils'); 2 | const { parseCompressed } = require('../protocol'); 3 | const graphicParser = require('./graphicParser'); 4 | 5 | const PineIndicator = require('../classes/PineIndicator'); 6 | const BuiltInIndicator = require('../classes/BuiltInIndicator'); 7 | 8 | /** 9 | * Get pine inputs 10 | * @param {PineIndicator | BuiltInIndicator} options 11 | */ 12 | function getInputs(options) { 13 | if (options instanceof PineIndicator) { 14 | const pineInputs = { text: options.script }; 15 | 16 | if (options.pineId) pineInputs.pineId = options.pineId; 17 | if (options.pineVersion) pineInputs.pineVersion = options.pineVersion; 18 | 19 | Object.keys(options.inputs).forEach((inputID, n) => { 20 | const input = options.inputs[inputID]; 21 | 22 | pineInputs[inputID] = { 23 | v: (input.type !== 'color') ? input.value : n, 24 | f: input.isFake, 25 | t: input.type, 26 | }; 27 | }); 28 | 29 | return pineInputs; 30 | } 31 | 32 | return options.options; 33 | } 34 | 35 | const parseTrades = (trades) => trades.reverse().map((t) => ({ 36 | entry: { 37 | name: t.e.c, 38 | type: (t.e.tp[0] === 's' ? 'short' : 'long'), 39 | value: t.e.p, 40 | time: t.e.tm, 41 | }, 42 | exit: { 43 | name: t.x.c, 44 | value: t.x.p, 45 | time: t.x.tm, 46 | }, 47 | quantity: t.q, 48 | profit: t.tp, 49 | cumulative: t.cp, 50 | runup: t.rn, 51 | drawdown: t.dd, 52 | })); 53 | 54 | // const historyParser = (history) => history.reverse().map((h) => ({ 55 | 56 | /** 57 | * @typedef {Object} TradeReport Trade report 58 | 59 | * @prop {Object} entry Trade entry 60 | * @prop {string} entry.name Trade name 61 | * @prop {'long' | 'short'} entry.type Entry type (long/short) 62 | * @prop {number} entry.value Entry price value 63 | * @prop {number} entry.time Entry timestamp 64 | 65 | * @prop {Object} exit Trade exit 66 | * @prop {'' | string} exit.name Trade name ('' if false exit) 67 | * @prop {number} exit.value Exit price value 68 | * @prop {number} exit.time Exit timestamp 69 | 70 | * @prop {number} quantity Trade quantity 71 | * @prop {RelAbsValue} profit Trade profit 72 | * @prop {RelAbsValue} cumulative Trade cummulative profit 73 | * @prop {RelAbsValue} runup Trade run-up 74 | * @prop {RelAbsValue} drawdown Trade drawdown 75 | */ 76 | 77 | /** 78 | * @typedef {Object} PerfReport 79 | * @prop {number} avgBarsInTrade Average bars in trade 80 | * @prop {number} avgBarsInWinTrade Average bars in winning trade 81 | * @prop {number} avgBarsInLossTrade Average bars in losing trade 82 | * @prop {number} avgTrade Average trade gain 83 | * @prop {number} avgTradePercent Average trade performace 84 | * @prop {number} avgLosTrade Average losing trade gain 85 | * @prop {number} avgLosTradePercent Average losing trade performace 86 | * @prop {number} avgWinTrade Average winning trade gain 87 | * @prop {number} avgWinTradePercent Average winning trade performace 88 | * @prop {number} commissionPaid Commission paid 89 | * @prop {number} grossLoss Gross loss value 90 | * @prop {number} grossLossPercent Gross loss percent 91 | * @prop {number} grossProfit Gross profit 92 | * @prop {number} grossProfitPercent Gross profit percent 93 | * @prop {number} largestLosTrade Largest losing trade gain 94 | * @prop {number} largestLosTradePercent Largent losing trade performance (percentage) 95 | * @prop {number} largestWinTrade Largest winning trade gain 96 | * @prop {number} largestWinTradePercent Largest winning trade performance (percentage) 97 | * @prop {number} marginCalls Margin calls 98 | * @prop {number} maxContractsHeld Max Contracts Held 99 | * @prop {number} netProfit Net profit 100 | * @prop {number} netProfitPercent Net performance (percentage) 101 | * @prop {number} numberOfLosingTrades Number of losing trades 102 | * @prop {number} numberOfWiningTrades Number of winning trades 103 | * @prop {number} percentProfitable Strategy winrate 104 | * @prop {number} profitFactor Profit factor 105 | * @prop {number} ratioAvgWinAvgLoss Ratio Average Win / Average Loss 106 | * @prop {number} totalOpenTrades Total open trades 107 | * @prop {number} totalTrades Total trades 108 | */ 109 | 110 | /** 111 | * @typedef {Object} FromTo 112 | * @prop {number} from From timestamp 113 | * @prop {number} to To timestamp 114 | */ 115 | 116 | /** 117 | * @typedef {Object} StrategyReport 118 | * @prop {'EUR' | 'USD' | 'JPY' | '' | 'CHF'} [currency] Selected currency 119 | * @prop {Object} [settings] Backtester settings 120 | * @prop {Object} [settings.dateRange] Backtester date range 121 | * @prop {FromTo} [settings.dateRange.backtest] Date range for backtest 122 | * @prop {FromTo} [settings.dateRange.trade] Date range for trade 123 | * @prop {TradeReport[]} trades Trade list starting by the last 124 | * @prop {Object} history History Chart value 125 | * @prop {number[]} [history.buyHold] Buy hold values 126 | * @prop {number[]} [history.buyHoldPercent] Buy hold percent values 127 | * @prop {number[]} [history.drawDown] Drawdown values 128 | * @prop {number[]} [history.drawDownPercent] Drawdown percent values 129 | * @prop {number[]} [history.equity] Equity values 130 | * @prop {number[]} [history.equityPercent] Equity percent values 131 | * @prop {Object} performance Strategy performance 132 | * @prop {PerfReport} [performance.all] Strategy long/short performances 133 | * @prop {PerfReport} [performance.long] Strategy long performances 134 | * @prop {PerfReport} [performance.short] Strategy short performances 135 | * @prop {number} [performance.buyHoldReturn] Strategy Buy & Hold Return 136 | * @prop {number} [performance.buyHoldReturnPercent] Strategy Buy & Hold Return percent 137 | * @prop {number} [performance.maxDrawDown] Strategy max drawdown 138 | * @prop {number} [performance.maxDrawDownPercent] Strategy max drawdown percent 139 | * @prop {number} [performance.openPL] Strategy Open P&L (Profit And Loss) 140 | * @prop {number} [performance.openPLPercent] Strategy Open P&L (Profit And Loss) percent 141 | * @prop {number} [performance.sharpeRatio] Strategy Sharpe Ratio 142 | * @prop {number} [performance.sortinoRatio] Strategy Sortino Ratio 143 | */ 144 | 145 | /** 146 | * @param {import('./session').ChartSessionBridge} chartSession 147 | */ 148 | module.exports = (chartSession) => class ChartStudy { 149 | #studID = genSessionID('st'); 150 | 151 | #studyListeners = chartSession.studyListeners; 152 | 153 | /** 154 | * Table of periods values indexed by timestamp 155 | * @type {Object} 156 | */ 157 | #periods = {}; 158 | 159 | /** @return {{}[]} List of periods values */ 160 | get periods() { 161 | return Object.values(this.#periods).sort((a, b) => b.$time - a.$time); 162 | } 163 | 164 | /** 165 | * List of graphic xPos indexes 166 | * @type {number[]} 167 | */ 168 | #indexes = []; 169 | 170 | /** 171 | * Table of graphic drawings indexed by type and ID 172 | * @type {Object>} 173 | */ 174 | #graphic = {}; 175 | 176 | /** 177 | * Table of graphic drawings indexed by type 178 | * @return {import('./graphicParser').GraphicData} 179 | */ 180 | get graphic() { 181 | const translator = {}; 182 | 183 | Object.keys(chartSession.indexes) 184 | .sort((a, b) => chartSession.indexes[b] - chartSession.indexes[a]) 185 | .forEach((r, n) => { translator[r] = n; }); 186 | 187 | const indexes = this.#indexes.map((i) => translator[i]); 188 | return graphicParser(this.#graphic, indexes); 189 | } 190 | 191 | /** @type {StrategyReport} */ 192 | #strategyReport = { 193 | trades: [], 194 | history: {}, 195 | performance: {}, 196 | }; 197 | 198 | /** @return {StrategyReport} Get the strategy report if available */ 199 | get strategyReport() { 200 | return this.#strategyReport; 201 | } 202 | 203 | #callbacks = { 204 | studyCompleted: [], 205 | update: [], 206 | 207 | event: [], 208 | error: [], 209 | }; 210 | 211 | /** 212 | * @param {ChartEvent} ev Client event 213 | * @param {...{}} data Packet data 214 | */ 215 | #handleEvent(ev, ...data) { 216 | this.#callbacks[ev].forEach((e) => e(...data)); 217 | this.#callbacks.event.forEach((e) => e(ev, ...data)); 218 | } 219 | 220 | #handleError(...msgs) { 221 | if (this.#callbacks.error.length === 0) console.error(...msgs); 222 | else this.#handleEvent('error', ...msgs); 223 | } 224 | 225 | /** 226 | * @param {PineIndicator | BuiltInIndicator} indicator Indicator object instance 227 | */ 228 | constructor(indicator) { 229 | if (!(indicator instanceof PineIndicator) && !(indicator instanceof BuiltInIndicator)) { 230 | throw new Error(`Indicator argument must be an instance of PineIndicator or BuiltInIndicator. 231 | Please use 'TradingView.getIndicator(...)' function.`); 232 | } 233 | 234 | /** @type {PineIndicator | BuiltInIndicator} Indicator instance */ 235 | this.instance = indicator; 236 | 237 | this.#studyListeners[this.#studID] = async (packet) => { 238 | if (global.TW_DEBUG) console.log('§90§30§105 STUDY §0 DATA', packet); 239 | 240 | if (packet.type === 'study_completed') { 241 | this.#handleEvent('studyCompleted'); 242 | return; 243 | } 244 | 245 | if (['timescale_update', 'du'].includes(packet.type)) { 246 | const changes = []; 247 | const data = packet.data[1][this.#studID]; 248 | 249 | if (data && data.st && data.st[0]) { 250 | data.st.forEach((p) => { 251 | const period = {}; 252 | 253 | p.v.forEach((plot, i) => { 254 | if (!this.instance.plots) { 255 | period[i === 0 ? '$time' : `plot_${i - 1}`] = plot; 256 | return; 257 | } 258 | const plotName = (i === 0 ? '$time' : this.instance.plots[`plot_${i - 1}`]); 259 | if (plotName && !period[plotName]) period[plotName] = plot; 260 | else period[`plot_${i - 1}`] = plot; 261 | }); 262 | 263 | this.#periods[p.v[0]] = period; 264 | }); 265 | 266 | changes.push('plots'); 267 | } 268 | 269 | if (data.ns && data.ns.d) { 270 | const parsed = JSON.parse(data.ns.d); 271 | 272 | if (parsed.graphicsCmds) { 273 | if (parsed.graphicsCmds.erase) { 274 | parsed.graphicsCmds.erase.forEach((instruction) => { 275 | // console.log('Erase', instruction); 276 | if (instruction.action === 'all') { 277 | if (!instruction.type) { 278 | Object.keys(this.#graphic).forEach((drawType) => { 279 | this.#graphic[drawType] = {}; 280 | }); 281 | } else delete this.#graphic[instruction.type]; 282 | return; 283 | } 284 | 285 | if (instruction.action === 'one') { 286 | delete this.#graphic[instruction.type][instruction.id]; 287 | } 288 | // Can an 'instruction' contains other things ? 289 | }); 290 | } 291 | 292 | if (parsed.graphicsCmds.create) { 293 | Object.keys(parsed.graphicsCmds.create).forEach((drawType) => { 294 | if (!this.#graphic[drawType]) this.#graphic[drawType] = {}; 295 | parsed.graphicsCmds.create[drawType].forEach((group) => { 296 | group.data.forEach((item) => { 297 | this.#graphic[drawType][item.id] = item; 298 | }); 299 | }); 300 | }); 301 | } 302 | 303 | // console.log('graphicsCmds', Object.keys(parsed.graphicsCmds)); 304 | // Can 'graphicsCmds' contains other things ? 305 | 306 | changes.push('graphic'); 307 | } 308 | 309 | const updateStrategyReport = (report) => { 310 | if (report.currency) { 311 | this.#strategyReport.currency = report.currency; 312 | changes.push('report.currency'); 313 | } 314 | 315 | if (report.settings) { 316 | this.#strategyReport.settings = report.settings; 317 | changes.push('report.settings'); 318 | } 319 | 320 | if (report.performance) { 321 | this.#strategyReport.performance = report.performance; 322 | changes.push('report.perf'); 323 | } 324 | 325 | if (report.trades) { 326 | this.#strategyReport.trades = parseTrades(report.trades); 327 | changes.push('report.trades'); 328 | } 329 | 330 | if (report.equity) { 331 | this.#strategyReport.history = { 332 | buyHold: report.buyHold, 333 | buyHoldPercent: report.buyHoldPercent, 334 | drawDown: report.drawDown, 335 | drawDownPercent: report.drawDownPercent, 336 | equity: report.equity, 337 | equityPercent: report.equityPercent, 338 | }; 339 | changes.push('report.history'); 340 | } 341 | }; 342 | 343 | if (parsed.dataCompressed) { 344 | updateStrategyReport((await parseCompressed(parsed.dataCompressed)).report); 345 | } 346 | 347 | if (parsed.data && parsed.data.report) updateStrategyReport(parsed.data.report); 348 | } 349 | 350 | if (data.ns.indexes && typeof data.ns.indexes === 'object') { 351 | this.#indexes = data.ns.indexes; 352 | } 353 | 354 | this.#handleEvent('update', changes); 355 | return; 356 | } 357 | 358 | if (packet.type === 'study_error') { 359 | this.#handleError(packet.data[3], packet.data[4]); 360 | } 361 | }; 362 | 363 | chartSession.send('create_study', [ 364 | chartSession.sessionID, 365 | `${this.#studID}`, 366 | 'st1', 367 | '$prices', 368 | this.instance.type, 369 | getInputs(this.instance), 370 | ]); 371 | } 372 | 373 | /** 374 | * @param {PineIndicator | BuiltInIndicator} indicator Indicator instance 375 | */ 376 | setIndicator(indicator) { 377 | if (!(indicator instanceof PineIndicator) && !(indicator instanceof BuiltInIndicator)) { 378 | throw new Error(`Indicator argument must be an instance of PineIndicator or BuiltInIndicator. 379 | Please use 'TradingView.getIndicator(...)' function.`); 380 | } 381 | 382 | this.instance = indicator; 383 | 384 | chartSession.send('modify_study', [ 385 | chartSession.sessionID, 386 | `${this.#studID}`, 387 | 'st1', 388 | getInputs(this.instance), 389 | ]); 390 | } 391 | 392 | /** 393 | * When the indicator is ready 394 | * @param {() => void} cb 395 | * @event 396 | */ 397 | onReady(cb) { 398 | this.#callbacks.studyCompleted.push(cb); 399 | } 400 | 401 | /** 402 | * @typedef {'plots' | 'report.currency' 403 | * | 'report.settings' | 'report.perf' 404 | * | 'report.trades' | 'report.history' 405 | * | 'graphic' 406 | * } UpdateChangeType 407 | */ 408 | 409 | /** 410 | * When an indicator update happens 411 | * @param {(changes: UpdateChangeType[]) => void} cb 412 | * @event 413 | */ 414 | onUpdate(cb) { 415 | this.#callbacks.update.push(cb); 416 | } 417 | 418 | /** 419 | * When indicator error happens 420 | * @param {(...any) => void} cb Callback 421 | * @event 422 | */ 423 | onError(cb) { 424 | this.#callbacks.error.push(cb); 425 | } 426 | 427 | /** Remove the study */ 428 | remove() { 429 | chartSession.send('remove_study', [ 430 | chartSession.sessionID, 431 | this.#studID, 432 | ]); 433 | delete this.#studyListeners[this.#studID]; 434 | } 435 | }; 436 | -------------------------------------------------------------------------------- /src/websocket.ts: -------------------------------------------------------------------------------- 1 | import WebSocket from 'ws'; 2 | import http from 'http'; 3 | import { EventEmitter } from 'events'; 4 | import { config } from './config'; 5 | import { logger } from './logger'; 6 | import { type Subscription } from './config'; 7 | import { type Bar } from './tradingview'; 8 | import { getTradingViewClient } from './push'; 9 | 10 | // WebSocket message types 11 | export enum MessageType { 12 | SUBSCRIBE = 'subscribe', 13 | UNSUBSCRIBE = 'unsubscribe', 14 | LIST = 'list', 15 | BAR = 'bar', 16 | ERROR = 'error', 17 | INFO = 'info', 18 | SUBSCRIBE_MANY = 'subscribe_many', 19 | UNSUBSCRIBE_MANY = 'unsubscribe_many', 20 | } 21 | 22 | // Client request type 23 | export interface WSRequest { 24 | action: MessageType | string; 25 | symbol?: string; 26 | timeframe?: string; 27 | requestId?: string; 28 | pairs?: { symbol: string; timeframe: string }[]; 29 | } 30 | 31 | // Server response type 32 | export interface WSResponse { 33 | type: MessageType | string; 34 | requestId?: string; 35 | success?: boolean; 36 | message?: string; 37 | symbol?: string; 38 | timeframe?: string; 39 | subscriptions?: Subscription[]; 40 | bar?: Bar; 41 | error?: string; 42 | } 43 | 44 | export class WebSocketServer extends EventEmitter { 45 | private wss: WebSocket.Server; 46 | private clients: Set = new Set(); 47 | private activeSubscriptions: Map = new Map(); 48 | 49 | // New structures for per-client subscription tracking 50 | private clientSubscriptions: Map> = new Map(); 51 | private subscriptionClients: Map> = new Map(); 52 | 53 | constructor(server?: http.Server) { 54 | super(); 55 | 56 | // Create WebSocket server 57 | const port = config.websocket?.port || 8081; 58 | 59 | if (server) { 60 | // Use existing HTTP server 61 | this.wss = new WebSocket.Server({ server }); 62 | logger.info(`WebSocket server attached to existing HTTP server`); 63 | } else { 64 | // Create new WebSocket server 65 | this.wss = new WebSocket.Server({ port }); 66 | logger.info(`WebSocket server started on port ${port}`); 67 | } 68 | 69 | // Handle new connections 70 | this.wss.on('connection', (ws: WebSocket) => { 71 | this.handleConnection(ws); 72 | }); 73 | 74 | // Handle server errors 75 | this.wss.on('error', (error) => { 76 | logger.error(`WebSocket server error: ${error.message}`); 77 | }); 78 | } 79 | 80 | // Handle new connection 81 | private handleConnection(ws: WebSocket) { 82 | logger.info('New WebSocket client connected'); 83 | this.clients.add(ws); 84 | this.clientSubscriptions.set(ws, new Set()); 85 | 86 | // Send welcome message 87 | this.sendMessage(ws, { 88 | type: MessageType.INFO, 89 | success: true, 90 | message: 'Connected to TradingView WebSocket Server' 91 | }); 92 | 93 | // Handle messages from client 94 | ws.on('message', (message: string) => { 95 | try { 96 | const data = JSON.parse(message) as WSRequest; 97 | this.handleMessage(ws, data); 98 | } catch (error) { 99 | logger.error(`Failed to parse WebSocket message: ${error.message}`); 100 | this.sendMessage(ws, { 101 | type: MessageType.ERROR, 102 | success: false, 103 | message: 'Invalid JSON message' 104 | }); 105 | } 106 | }); 107 | 108 | // Handle disconnection 109 | ws.on('close', async () => { 110 | logger.info('[DIAG] ws.on(close) triggered. activeSubscriptions: %d', this.activeSubscriptions.size); 111 | this.clients.delete(ws); 112 | // Automatic unsubscription from all tickers subscribed to by this client 113 | const subs = this.clientSubscriptions.get(ws); 114 | if (subs) { 115 | for (const key of subs) { 116 | const clients = this.subscriptionClients.get(key); 117 | if (clients) { 118 | clients.delete(ws); 119 | if (clients.size === 0) { 120 | this.subscriptionClients.delete(key); 121 | const [symbol, timeframe] = key.split('_'); 122 | this.activeSubscriptions.delete(key); 123 | logger.info('[DIAG] activeSubscriptions deleted key: %s. Now: %o', key, Array.from(this.activeSubscriptions.keys())); 124 | this.emit('unsubscribe', { symbol, timeframe }); 125 | logger.info('Auto-unsubscribed from %s/%s (last client disconnected)', symbol, timeframe); 126 | } 127 | } 128 | } 129 | this.clientSubscriptions.delete(ws); 130 | logger.info('[DIAG] clients.size after delete: %d', this.clients.size); 131 | if (this.clients.size === 0) { 132 | logger.info('[DIAG] No WebSocket clients left, forcibly clearing all activeSubscriptions and resetting TradingView'); 133 | this.activeSubscriptions.clear(); 134 | const tvClient = getTradingViewClient && getTradingViewClient(); 135 | if (tvClient && typeof tvClient.resetAllSubscriptions === 'function') { 136 | await tvClient.resetAllSubscriptions(); 137 | logger.info('[DIAG] resetAllSubscriptions() forcibly called due to no clients'); 138 | } 139 | } 140 | } 141 | }); 142 | 143 | // Handle errors 144 | ws.on('error', (error) => { 145 | logger.error(`WebSocket client error: ${error.message}`); 146 | }); 147 | } 148 | 149 | // Handle incoming messages 150 | private handleMessage(ws: WebSocket, data: WSRequest) { 151 | logger.info(`Received WebSocket message: ${JSON.stringify(data)}`); 152 | 153 | switch (data.action) { 154 | case MessageType.SUBSCRIBE: 155 | this.handleSubscribe(ws, data); 156 | break; 157 | 158 | case MessageType.UNSUBSCRIBE: 159 | this.handleUnsubscribe(ws, data); 160 | break; 161 | 162 | case MessageType.LIST: 163 | this.handleList(ws, data); 164 | break; 165 | 166 | case MessageType.SUBSCRIBE_MANY: 167 | this.handleSubscribeMany(ws, data); 168 | break; 169 | 170 | case MessageType.UNSUBSCRIBE_MANY: 171 | this.handleUnsubscribeMany(ws, data); 172 | break; 173 | 174 | default: 175 | this.sendMessage(ws, { 176 | type: MessageType.ERROR, 177 | requestId: data.requestId, 178 | success: false, 179 | message: `Unknown action: ${data.action}` 180 | }); 181 | } 182 | } 183 | 184 | // Handle subscription request 185 | private handleSubscribe(ws: WebSocket, data: WSRequest) { 186 | if (!data.symbol || !data.timeframe) { 187 | return this.sendMessage(ws, { 188 | type: MessageType.ERROR, 189 | requestId: data.requestId, 190 | success: false, 191 | message: 'Symbol and timeframe are required for subscription' 192 | }); 193 | } 194 | 195 | const key = `${data.symbol}_${data.timeframe}`; 196 | // If there is already such a subscription for this client — just confirm 197 | const clientSubs = this.clientSubscriptions.get(ws) || new Set(); 198 | if (clientSubs.has(key)) { 199 | return this.sendMessage(ws, { 200 | type: MessageType.SUBSCRIBE, 201 | requestId: data.requestId, 202 | success: true, 203 | message: 'Already subscribed', 204 | symbol: data.symbol, 205 | timeframe: data.timeframe 206 | }); 207 | } 208 | // Add subscription for client 209 | clientSubs.add(key); 210 | this.clientSubscriptions.set(ws, clientSubs); 211 | // Add client to ticker listener list 212 | let clients = this.subscriptionClients.get(key); 213 | let isFirst = false; 214 | if (!clients) { 215 | clients = new Set(); 216 | this.subscriptionClients.set(key, clients); 217 | isFirst = true; 218 | } 219 | clients.add(ws); 220 | // If this is the first subscription to the ticker — create TradingView subscription 221 | if (isFirst) { 222 | const subscription: Subscription = { symbol: data.symbol, timeframe: data.timeframe }; 223 | this.activeSubscriptions.set(key, subscription); 224 | this.emit('subscribe', subscription); 225 | logger.info('First client subscribed to %s/%s, subscribing to TradingView', data.symbol, data.timeframe); 226 | } 227 | // Confirm to client 228 | this.sendMessage(ws, { 229 | type: MessageType.SUBSCRIBE, 230 | requestId: data.requestId, 231 | success: true, 232 | message: isFirst ? 'Subscription created' : 'Subscribed (shared)', 233 | symbol: data.symbol, 234 | timeframe: data.timeframe 235 | }); 236 | } 237 | 238 | // Handle unsubscription request 239 | private handleUnsubscribe(ws: WebSocket, data: WSRequest) { 240 | if (!data.symbol || !data.timeframe) { 241 | return this.sendMessage(ws, { 242 | type: MessageType.ERROR, 243 | requestId: data.requestId, 244 | success: false, 245 | message: 'Symbol and timeframe are required for unsubscription' 246 | }); 247 | } 248 | 249 | const key = `${data.symbol}_${data.timeframe}`; 250 | const clientSubs = this.clientSubscriptions.get(ws); 251 | if (!clientSubs || !clientSubs.has(key)) { 252 | return this.sendMessage(ws, { 253 | type: MessageType.UNSUBSCRIBE, 254 | requestId: data.requestId, 255 | success: false, 256 | message: 'Subscription not found for this client', 257 | symbol: data.symbol, 258 | timeframe: data.timeframe 259 | }); 260 | } 261 | clientSubs.delete(key); 262 | // Remove client from ticker listener list 263 | const clients = this.subscriptionClients.get(key); 264 | if (clients) { 265 | clients.delete(ws); 266 | if (clients.size === 0) { 267 | this.subscriptionClients.delete(key); 268 | this.activeSubscriptions.delete(key); 269 | this.emit('unsubscribe', { symbol: data.symbol, timeframe: data.timeframe }); 270 | logger.info('Last client unsubscribed from %s/%s, unsubscribing from TradingView', data.symbol, data.timeframe); 271 | } 272 | } 273 | // Confirm to client 274 | this.sendMessage(ws, { 275 | type: MessageType.UNSUBSCRIBE, 276 | requestId: data.requestId, 277 | success: true, 278 | message: 'Unsubscribed successfully', 279 | symbol: data.symbol, 280 | timeframe: data.timeframe 281 | }); 282 | } 283 | 284 | // Handle get subscription list request 285 | private handleList(ws: WebSocket, data: WSRequest) { 286 | const subscriptions = Array.from(this.activeSubscriptions.values()); 287 | 288 | this.sendMessage(ws, { 289 | type: MessageType.LIST, 290 | requestId: data.requestId, 291 | success: true, 292 | subscriptions 293 | }); 294 | } 295 | 296 | // Bulk subscription 297 | private handleSubscribeMany(ws: WebSocket, data: WSRequest) { 298 | if (!Array.isArray(data.pairs) || data.pairs.length === 0) { 299 | return this.sendMessage(ws, { 300 | type: MessageType.ERROR, 301 | requestId: data.requestId, 302 | success: false, 303 | message: 'pairs[] required for subscribe_many' 304 | }); 305 | } 306 | const results = data.pairs.map(pair => { 307 | if (!pair.symbol || !pair.timeframe) { 308 | logger.warn('[DIAG] Skipping invalid pair in subscribe_many: %o', pair); 309 | return { ...pair, success: false, message: 'symbol and timeframe required' }; 310 | } 311 | const key = `${pair.symbol}_${pair.timeframe}`; 312 | if (this.activeSubscriptions.has(key)) { 313 | logger.info('[DIAG] Skipping subscribe_many for %s/%s: already subscribed', pair.symbol, pair.timeframe); 314 | return { ...pair, success: true, message: 'Already subscribed' }; 315 | } 316 | const subscription: Subscription = { symbol: pair.symbol, timeframe: pair.timeframe }; 317 | this.activeSubscriptions.set(key, subscription); 318 | this.emit('subscribe', subscription); 319 | logger.info('[DIAG] subscribe_many triggers new subscribe for %s/%s', pair.symbol, pair.timeframe); 320 | return { ...pair, success: true, message: 'Subscription created' }; 321 | }); 322 | this.sendMessage(ws, { 323 | type: MessageType.SUBSCRIBE_MANY, 324 | requestId: data.requestId, 325 | success: true, 326 | message: 'Bulk subscribe processed', 327 | subscriptions: this.getActiveSubscriptions(), 328 | results 329 | } as any); 330 | } 331 | 332 | // Bulk unsubscription 333 | private handleUnsubscribeMany(ws: WebSocket, data: WSRequest) { 334 | if (!Array.isArray(data.pairs) || data.pairs.length === 0) { 335 | return this.sendMessage(ws, { 336 | type: MessageType.ERROR, 337 | requestId: data.requestId, 338 | success: false, 339 | message: 'pairs[] required for unsubscribe_many' 340 | }); 341 | } 342 | const results = data.pairs.map(pair => { 343 | if (!pair.symbol || !pair.timeframe) { 344 | return { ...pair, success: false, message: 'symbol and timeframe required' }; 345 | } 346 | const key = `${pair.symbol}_${pair.timeframe}`; 347 | if (!this.activeSubscriptions.has(key)) { 348 | return { ...pair, success: false, message: 'Subscription not found' }; 349 | } 350 | this.activeSubscriptions.delete(key); 351 | this.emit('unsubscribe', { symbol: pair.symbol, timeframe: pair.timeframe }); 352 | return { ...pair, success: true, message: 'Unsubscribed successfully' }; 353 | }); 354 | this.sendMessage(ws, { 355 | type: MessageType.UNSUBSCRIBE_MANY, 356 | requestId: data.requestId, 357 | success: true, 358 | message: 'Bulk unsubscribe processed', 359 | subscriptions: this.getActiveSubscriptions(), 360 | results 361 | } as any); 362 | } 363 | 364 | // Send message to client 365 | private sendMessage(ws: WebSocket, data: WSResponse) { 366 | if (ws.readyState === WebSocket.OPEN) { 367 | ws.send(JSON.stringify(data)); 368 | } 369 | } 370 | 371 | // Send bar to all connected clients 372 | public broadcastBar(bar: Bar) { 373 | const message: WSResponse = { 374 | type: MessageType.BAR, 375 | bar 376 | }; 377 | 378 | this.clients.forEach((client) => { 379 | if (client.readyState === WebSocket.OPEN) { 380 | client.send(JSON.stringify(message)); 381 | } 382 | }); 383 | } 384 | 385 | // Get list of active subscriptions 386 | public getActiveSubscriptions(): Subscription[] { 387 | return Array.from(this.activeSubscriptions.values()); 388 | } 389 | 390 | // Check if subscription exists 391 | public hasSubscription(symbol: string, timeframe: string): boolean { 392 | return this.activeSubscriptions.has(`${symbol}_${timeframe}`); 393 | } 394 | 395 | // Add subscription programmatically (without client request) 396 | public addSubscription(subscription: Subscription) { 397 | const key = `${subscription.symbol}_${subscription.timeframe}`; 398 | if (!this.activeSubscriptions.has(key)) { 399 | this.activeSubscriptions.set(key, subscription); 400 | this.emit('subscribe', subscription); 401 | return true; 402 | } 403 | return false; 404 | } 405 | 406 | // Remove subscription programmatically (without client request) 407 | public removeSubscription(symbol: string, timeframe: string) { 408 | const key = `${symbol}_${timeframe}`; 409 | if (this.activeSubscriptions.has(key)) { 410 | this.activeSubscriptions.delete(key); 411 | this.emit('unsubscribe', { symbol, timeframe }); 412 | return true; 413 | } 414 | return false; 415 | } 416 | 417 | // Close all connections and stop server 418 | public close() { 419 | this.wss.close(() => { 420 | logger.info('WebSocket server closed'); 421 | }); 422 | } 423 | } -------------------------------------------------------------------------------- /src/health.ts: -------------------------------------------------------------------------------- 1 | import { logger } from './logger'; 2 | import { config } from './config'; 3 | import { EventEmitter } from 'events'; 4 | import { TradingViewClient } from './tradingview'; 5 | import { type Bar } from './tradingview'; 6 | import { type Subscription } from './config'; 7 | import { 8 | staleSubscriptionsGauge, 9 | recoveryAttemptsTotal, 10 | successfulRecoveriesTotal, 11 | failedRecoveriesTotal, 12 | fullReconnectsTotal, 13 | lastDataReceivedGauge 14 | } from './metrics'; 15 | 16 | /** 17 | * Configuration for the TradingView health monitor 18 | */ 19 | export interface HealthMonitorConfig { 20 | // How often to check for stale subscriptions (in milliseconds) 21 | checkIntervalMs: number; 22 | 23 | // How long a subscription can go without data before being considered stale (in milliseconds) 24 | // This is a multiplier applied to the timeframe's expected interval 25 | staleThresholdMultiplier: number; 26 | 27 | // Whether to automatically attempt recovery of stale subscriptions 28 | autoRecoveryEnabled: boolean; 29 | 30 | // Maximum number of recovery attempts before giving up 31 | maxRecoveryAttempts: number; 32 | 33 | // Number of stale subscriptions that triggers a full reconnect 34 | fullReconnectThreshold: number; 35 | 36 | // Cooldown period between full reconnects (in milliseconds) 37 | fullReconnectCooldownMs: number; 38 | 39 | // Port for the health API 40 | apiPort: number; 41 | } 42 | 43 | // Default configuration values - NOTE: These are just for type checking. 44 | // The actual defaults are in config.ts to avoid circular dependencies. 45 | const DEFAULT_CONFIG: HealthMonitorConfig = { 46 | checkIntervalMs: 60000, // Check every minute 47 | staleThresholdMultiplier: 3, // Consider stale after 3x the expected interval 48 | autoRecoveryEnabled: true, // Try to recover automatically 49 | maxRecoveryAttempts: 3, // Max 3 recovery attempts per subscription 50 | fullReconnectThreshold: 3, // Number of stale subscriptions that triggers a full reconnect 51 | fullReconnectCooldownMs: 600000, // 10 minutes between full reconnects 52 | apiPort: 8082, // Health API port 53 | }; 54 | 55 | /** 56 | * Converts a timeframe string to milliseconds 57 | */ 58 | function timeframeToMs(timeframe: string): number { 59 | // Convert TradingView timeframe to milliseconds 60 | if (timeframe === 'D') return 24 * 60 * 60 * 1000; // 1 day 61 | if (timeframe === 'W') return 7 * 24 * 60 * 60 * 1000; // 1 week 62 | if (timeframe === 'M') return 30 * 24 * 60 * 60 * 1000; // ~1 month 63 | 64 | // Minutes (1, 5, 15, 30, 60, 120, 240, etc.) 65 | return parseInt(timeframe) * 60 * 1000; 66 | } 67 | 68 | /** 69 | * Health monitor for TradingView data flow 70 | * 71 | * Monitors subscriptions for data flow and attempts recovery when needed 72 | */ 73 | export class TradingViewHealthMonitor extends EventEmitter { 74 | private tvClient: TradingViewClient; 75 | private config: HealthMonitorConfig; 76 | 77 | // Track the last time a bar was received for each subscription 78 | private lastBarTimestamps: Map = new Map(); 79 | 80 | // Track recovery attempts for each subscription 81 | private recoveryAttempts: Map = new Map(); 82 | 83 | // Timer for health checks 84 | private checkTimer: NodeJS.Timeout | null = null; 85 | 86 | // Track the last time a full reconnect was performed 87 | private lastFullReconnectTime: number = 0; 88 | 89 | constructor(tvClient: TradingViewClient, config: HealthMonitorConfig = DEFAULT_CONFIG) { 90 | super(); 91 | this.tvClient = tvClient; 92 | this.config = config; 93 | 94 | // Subscribe to TradingView events 95 | this.tvClient.on('bar', this.onBar.bind(this)); 96 | this.tvClient.on('subscribed', this.onSubscribed.bind(this)); 97 | this.tvClient.on('unsubscribed', this.onUnsubscribed.bind(this)); 98 | this.tvClient.on('connect', this.onConnect.bind(this)); 99 | this.tvClient.on('disconnect', this.onDisconnect.bind(this)); 100 | 101 | // Start health checks 102 | this.startHealthChecks(); 103 | 104 | logger.info('[HEALTH] TradingView health monitor initialized with config: %o', this.config); 105 | } 106 | 107 | /** 108 | * Start periodic health checks 109 | */ 110 | private startHealthChecks(): void { 111 | if (this.checkTimer) { 112 | clearInterval(this.checkTimer); 113 | } 114 | 115 | this.checkTimer = setInterval( 116 | () => this.checkSubscriptionHealth(), 117 | this.config.checkIntervalMs 118 | ); 119 | 120 | logger.info('[HEALTH] Started periodic health checks every %dms', this.config.checkIntervalMs); 121 | } 122 | 123 | /** 124 | * Stop health checks 125 | */ 126 | public stop(): void { 127 | if (this.checkTimer) { 128 | clearInterval(this.checkTimer); 129 | this.checkTimer = null; 130 | } 131 | 132 | // Cleanup event listeners 133 | this.tvClient.removeAllListeners('bar'); 134 | this.tvClient.removeAllListeners('subscribed'); 135 | this.tvClient.removeAllListeners('unsubscribed'); 136 | this.tvClient.removeAllListeners('connect'); 137 | this.tvClient.removeAllListeners('disconnect'); 138 | 139 | logger.info('[HEALTH] Health monitor stopped'); 140 | } 141 | 142 | /** 143 | * Handle new bar event 144 | */ 145 | private onBar(bar: Bar): void { 146 | const key = `${bar.symbol}_${bar.timeframe}`; 147 | const now = Date.now(); 148 | this.lastBarTimestamps.set(key, now); 149 | this.recoveryAttempts.delete(key); // Reset recovery attempts on successful data 150 | logger.debug('[HEALTH] Received bar for %s/%s, updated last bar timestamp', bar.symbol, bar.timeframe); 151 | 152 | // Update metrics 153 | lastDataReceivedGauge.labels(bar.symbol, bar.timeframe).set(0); 154 | } 155 | 156 | /** 157 | * Handle subscription event 158 | */ 159 | private onSubscribed(subscription: Subscription): void { 160 | const key = `${subscription.symbol}_${subscription.timeframe}`; 161 | this.lastBarTimestamps.set(key, Date.now()); // Initialize with current time 162 | logger.info('[HEALTH] New subscription to %s/%s, initialized health tracking', subscription.symbol, subscription.timeframe); 163 | } 164 | 165 | /** 166 | * Handle unsubscription event 167 | */ 168 | private onUnsubscribed({ symbol, timeframe }: { symbol: string, timeframe: string }): void { 169 | const key = `${symbol}_${timeframe}`; 170 | this.lastBarTimestamps.delete(key); 171 | this.recoveryAttempts.delete(key); 172 | logger.info('[HEALTH] Removed health tracking for unsubscribed %s/%s', symbol, timeframe); 173 | } 174 | 175 | /** 176 | * Handle TradingView connection event 177 | */ 178 | private onConnect(): void { 179 | logger.info('[HEALTH] TradingView connected, resetting health tracking'); 180 | // Reset all timestamps on reconnect 181 | for (const key of this.lastBarTimestamps.keys()) { 182 | this.lastBarTimestamps.set(key, Date.now()); 183 | } 184 | // Reset all recovery attempts 185 | this.recoveryAttempts.clear(); 186 | } 187 | 188 | /** 189 | * Handle TradingView disconnection event 190 | */ 191 | private onDisconnect(): void { 192 | logger.warn('[HEALTH] TradingView disconnected, pausing health tracking'); 193 | // We don't clear the timestamps here, as we want to preserve the last known times 194 | // The reconnection will be handled by the TradingViewClient 195 | } 196 | 197 | /** 198 | * Check the health of all active subscriptions 199 | */ 200 | private async checkSubscriptionHealth(): Promise { 201 | const now = Date.now(); 202 | const subscriptions = this.tvClient.getSubscriptions(); 203 | 204 | if (subscriptions.length === 0) { 205 | staleSubscriptionsGauge.set(0); 206 | return; // Nothing to check 207 | } 208 | 209 | logger.debug('[HEALTH] Checking health of %d subscriptions', subscriptions.length); 210 | 211 | let staleCount = 0; 212 | let recoveryCount = 0; 213 | const staleSubscriptions: Subscription[] = []; 214 | 215 | for (const sub of subscriptions) { 216 | const key = `${sub.symbol}_${sub.timeframe}`; 217 | const lastTimestamp = this.lastBarTimestamps.get(key); 218 | 219 | if (!lastTimestamp) { 220 | logger.warn('[HEALTH] No timestamp for %s/%s, initializing', sub.symbol, sub.timeframe); 221 | this.lastBarTimestamps.set(key, now); 222 | lastDataReceivedGauge.labels(sub.symbol, sub.timeframe).set(0); 223 | continue; 224 | } 225 | 226 | const expectedIntervalMs = timeframeToMs(sub.timeframe); 227 | const staleThresholdMs = expectedIntervalMs * this.config.staleThresholdMultiplier; 228 | const timeSinceLastBar = now - lastTimestamp; 229 | 230 | // Update metrics for time since last data 231 | lastDataReceivedGauge.labels(sub.symbol, sub.timeframe).set(timeSinceLastBar / 1000); 232 | 233 | if (timeSinceLastBar > staleThresholdMs) { 234 | staleCount++; 235 | staleSubscriptions.push(sub); 236 | const minutes = Math.floor(timeSinceLastBar / 60000); 237 | const seconds = Math.floor((timeSinceLastBar % 60000) / 1000); 238 | logger.warn( 239 | '[HEALTH] Stale subscription detected for %s/%s - no data for %dm %ds (threshold: %ds)', 240 | sub.symbol, sub.timeframe, minutes, seconds, staleThresholdMs / 1000 241 | ); 242 | } 243 | } 244 | 245 | // Update metrics 246 | staleSubscriptionsGauge.set(staleCount); 247 | 248 | if (staleCount > 0) { 249 | // Check if we need to do a full reconnect 250 | const shouldFullReconnect = 251 | this.config.autoRecoveryEnabled && 252 | staleCount >= this.config.fullReconnectThreshold && 253 | now - this.lastFullReconnectTime > this.config.fullReconnectCooldownMs; 254 | 255 | if (shouldFullReconnect) { 256 | logger.warn( 257 | '[HEALTH] %d stale subscriptions exceeds threshold (%d), performing full reconnect', 258 | staleCount, this.config.fullReconnectThreshold 259 | ); 260 | await this.performFullReconnect(); 261 | this.lastFullReconnectTime = now; 262 | } else if (this.config.autoRecoveryEnabled) { 263 | // Only do individual recovery if we're not doing a full reconnect 264 | for (const sub of staleSubscriptions) { 265 | await this.attemptRecovery(sub); 266 | recoveryCount++; 267 | } 268 | 269 | logger.warn( 270 | '[HEALTH] Found %d stale subscriptions out of %d total, attempted recovery for %d', 271 | staleCount, subscriptions.length, recoveryCount 272 | ); 273 | } else { 274 | logger.warn( 275 | '[HEALTH] Found %d stale subscriptions out of %d total, but auto-recovery is disabled', 276 | staleCount, subscriptions.length 277 | ); 278 | } 279 | 280 | this.emit('stale_subscriptions', { 281 | total: subscriptions.length, 282 | stale: staleCount, 283 | recovered: recoveryCount, 284 | fullReconnect: shouldFullReconnect 285 | }); 286 | } else { 287 | logger.debug('[HEALTH] All %d subscriptions are healthy', subscriptions.length); 288 | } 289 | } 290 | 291 | /** 292 | * Attempt to recover a stale subscription 293 | */ 294 | private async attemptRecovery(subscription: Subscription): Promise { 295 | const { symbol, timeframe } = subscription; 296 | const key = `${symbol}_${timeframe}`; 297 | 298 | // Get current recovery attempts, defaulting to 0 if not present 299 | const attempts = this.recoveryAttempts.get(key) || 0; 300 | 301 | if (attempts >= this.config.maxRecoveryAttempts) { 302 | logger.error( 303 | '[HEALTH] Max recovery attempts (%d) reached for %s/%s, giving up', 304 | this.config.maxRecoveryAttempts, symbol, timeframe 305 | ); 306 | this.emit('max_recovery_attempts', subscription); 307 | return false; 308 | } 309 | 310 | // Increment attempts 311 | this.recoveryAttempts.set(key, attempts + 1); 312 | recoveryAttemptsTotal.inc(); 313 | 314 | logger.info( 315 | '[HEALTH] Attempting recovery for %s/%s (attempt %d/%d)', 316 | symbol, timeframe, attempts + 1, this.config.maxRecoveryAttempts 317 | ); 318 | 319 | try { 320 | // Try to unsubscribe and resubscribe 321 | await this.tvClient.unsubscribe(symbol, timeframe); 322 | await new Promise(resolve => setTimeout(resolve, 1000)); // Give it a moment 323 | const success = await this.tvClient.subscribe(subscription, 'health_recovery'); 324 | 325 | if (success) { 326 | logger.info('[HEALTH] Successfully resubscribed to %s/%s', symbol, timeframe); 327 | this.lastBarTimestamps.set(key, Date.now()); // Reset the timestamp 328 | this.emit('recovery_success', subscription); 329 | successfulRecoveriesTotal.inc(); 330 | return true; 331 | } else { 332 | logger.error('[HEALTH] Failed to resubscribe to %s/%s', symbol, timeframe); 333 | this.emit('recovery_failure', subscription); 334 | failedRecoveriesTotal.inc(); 335 | return false; 336 | } 337 | } catch (err) { 338 | logger.error( 339 | '[HEALTH] Error during recovery for %s/%s: %s', 340 | symbol, timeframe, (err as Error).message 341 | ); 342 | this.emit('recovery_error', { subscription, error: err }); 343 | failedRecoveriesTotal.inc(); 344 | return false; 345 | } 346 | } 347 | 348 | /** 349 | * Perform a full reconnection to TradingView 350 | */ 351 | private async performFullReconnect(): Promise { 352 | logger.warn('[HEALTH] Performing full TradingView reconnect due to multiple stale subscriptions'); 353 | fullReconnectsTotal.inc(); 354 | 355 | try { 356 | // Use the fullReconnect method in TradingViewClient 357 | if (typeof this.tvClient.fullReconnect === 'function') { 358 | const success = await this.tvClient.fullReconnect(); 359 | 360 | if (success) { 361 | logger.info('[HEALTH] Full TradingView reconnect successful'); 362 | 363 | // Reset all timestamps and recovery attempts 364 | const now = Date.now(); 365 | for (const key of this.lastBarTimestamps.keys()) { 366 | this.lastBarTimestamps.set(key, now); 367 | } 368 | this.recoveryAttempts.clear(); 369 | 370 | this.emit('full_reconnect_success'); 371 | return true; 372 | } else { 373 | logger.error('[HEALTH] Full TradingView reconnect failed'); 374 | this.emit('full_reconnect_failure'); 375 | return false; 376 | } 377 | } else { 378 | logger.error('[HEALTH] fullReconnect method not available on TradingViewClient'); 379 | return false; 380 | } 381 | } catch (err) { 382 | logger.error('[HEALTH] Error during full TradingView reconnect: %s', (err as Error).message); 383 | this.emit('full_reconnect_error', err); 384 | return false; 385 | } 386 | } 387 | } 388 | 389 | // Parse health monitor config from environment variables (for standalone use) 390 | export function getHealthMonitorConfig(): HealthMonitorConfig { 391 | return { 392 | checkIntervalMs: parseInt(process.env.HEALTH_CHECK_INTERVAL_MS || '') || DEFAULT_CONFIG.checkIntervalMs, 393 | staleThresholdMultiplier: parseFloat(process.env.HEALTH_STALE_THRESHOLD_MULTIPLIER || '') || DEFAULT_CONFIG.staleThresholdMultiplier, 394 | autoRecoveryEnabled: process.env.HEALTH_AUTO_RECOVERY_ENABLED !== 'false', 395 | maxRecoveryAttempts: parseInt(process.env.HEALTH_MAX_RECOVERY_ATTEMPTS || '') || DEFAULT_CONFIG.maxRecoveryAttempts, 396 | fullReconnectThreshold: parseInt(process.env.HEALTH_FULL_RECONNECT_THRESHOLD || '') || DEFAULT_CONFIG.fullReconnectThreshold, 397 | fullReconnectCooldownMs: parseInt(process.env.HEALTH_FULL_RECONNECT_COOLDOWN_MS || '') || DEFAULT_CONFIG.fullReconnectCooldownMs, 398 | apiPort: parseInt(process.env.HEALTH_API_PORT || '') || DEFAULT_CONFIG.apiPort, 399 | }; 400 | } -------------------------------------------------------------------------------- /examples/node_modules/ws/lib/permessage-deflate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const zlib = require('zlib'); 4 | 5 | const bufferUtil = require('./buffer-util'); 6 | const Limiter = require('./limiter'); 7 | const { kStatusCode } = require('./constants'); 8 | 9 | const FastBuffer = Buffer[Symbol.species]; 10 | const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]); 11 | const kPerMessageDeflate = Symbol('permessage-deflate'); 12 | const kTotalLength = Symbol('total-length'); 13 | const kCallback = Symbol('callback'); 14 | const kBuffers = Symbol('buffers'); 15 | const kError = Symbol('error'); 16 | 17 | // 18 | // We limit zlib concurrency, which prevents severe memory fragmentation 19 | // as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913 20 | // and https://github.com/websockets/ws/issues/1202 21 | // 22 | // Intentionally global; it's the global thread pool that's an issue. 23 | // 24 | let zlibLimiter; 25 | 26 | /** 27 | * permessage-deflate implementation. 28 | */ 29 | class PerMessageDeflate { 30 | /** 31 | * Creates a PerMessageDeflate instance. 32 | * 33 | * @param {Object} [options] Configuration options 34 | * @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support 35 | * for, or request, a custom client window size 36 | * @param {Boolean} [options.clientNoContextTakeover=false] Advertise/ 37 | * acknowledge disabling of client context takeover 38 | * @param {Number} [options.concurrencyLimit=10] The number of concurrent 39 | * calls to zlib 40 | * @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the 41 | * use of a custom server window size 42 | * @param {Boolean} [options.serverNoContextTakeover=false] Request/accept 43 | * disabling of server context takeover 44 | * @param {Number} [options.threshold=1024] Size (in bytes) below which 45 | * messages should not be compressed if context takeover is disabled 46 | * @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on 47 | * deflate 48 | * @param {Object} [options.zlibInflateOptions] Options to pass to zlib on 49 | * inflate 50 | * @param {Boolean} [isServer=false] Create the instance in either server or 51 | * client mode 52 | * @param {Number} [maxPayload=0] The maximum allowed message length 53 | */ 54 | constructor(options, isServer, maxPayload) { 55 | this._maxPayload = maxPayload | 0; 56 | this._options = options || {}; 57 | this._threshold = 58 | this._options.threshold !== undefined ? this._options.threshold : 1024; 59 | this._isServer = !!isServer; 60 | this._deflate = null; 61 | this._inflate = null; 62 | 63 | this.params = null; 64 | 65 | if (!zlibLimiter) { 66 | const concurrency = 67 | this._options.concurrencyLimit !== undefined 68 | ? this._options.concurrencyLimit 69 | : 10; 70 | zlibLimiter = new Limiter(concurrency); 71 | } 72 | } 73 | 74 | /** 75 | * @type {String} 76 | */ 77 | static get extensionName() { 78 | return 'permessage-deflate'; 79 | } 80 | 81 | /** 82 | * Create an extension negotiation offer. 83 | * 84 | * @return {Object} Extension parameters 85 | * @public 86 | */ 87 | offer() { 88 | const params = {}; 89 | 90 | if (this._options.serverNoContextTakeover) { 91 | params.server_no_context_takeover = true; 92 | } 93 | if (this._options.clientNoContextTakeover) { 94 | params.client_no_context_takeover = true; 95 | } 96 | if (this._options.serverMaxWindowBits) { 97 | params.server_max_window_bits = this._options.serverMaxWindowBits; 98 | } 99 | if (this._options.clientMaxWindowBits) { 100 | params.client_max_window_bits = this._options.clientMaxWindowBits; 101 | } else if (this._options.clientMaxWindowBits == null) { 102 | params.client_max_window_bits = true; 103 | } 104 | 105 | return params; 106 | } 107 | 108 | /** 109 | * Accept an extension negotiation offer/response. 110 | * 111 | * @param {Array} configurations The extension negotiation offers/reponse 112 | * @return {Object} Accepted configuration 113 | * @public 114 | */ 115 | accept(configurations) { 116 | configurations = this.normalizeParams(configurations); 117 | 118 | this.params = this._isServer 119 | ? this.acceptAsServer(configurations) 120 | : this.acceptAsClient(configurations); 121 | 122 | return this.params; 123 | } 124 | 125 | /** 126 | * Releases all resources used by the extension. 127 | * 128 | * @public 129 | */ 130 | cleanup() { 131 | if (this._inflate) { 132 | this._inflate.close(); 133 | this._inflate = null; 134 | } 135 | 136 | if (this._deflate) { 137 | const callback = this._deflate[kCallback]; 138 | 139 | this._deflate.close(); 140 | this._deflate = null; 141 | 142 | if (callback) { 143 | callback( 144 | new Error( 145 | 'The deflate stream was closed while data was being processed' 146 | ) 147 | ); 148 | } 149 | } 150 | } 151 | 152 | /** 153 | * Accept an extension negotiation offer. 154 | * 155 | * @param {Array} offers The extension negotiation offers 156 | * @return {Object} Accepted configuration 157 | * @private 158 | */ 159 | acceptAsServer(offers) { 160 | const opts = this._options; 161 | const accepted = offers.find((params) => { 162 | if ( 163 | (opts.serverNoContextTakeover === false && 164 | params.server_no_context_takeover) || 165 | (params.server_max_window_bits && 166 | (opts.serverMaxWindowBits === false || 167 | (typeof opts.serverMaxWindowBits === 'number' && 168 | opts.serverMaxWindowBits > params.server_max_window_bits))) || 169 | (typeof opts.clientMaxWindowBits === 'number' && 170 | !params.client_max_window_bits) 171 | ) { 172 | return false; 173 | } 174 | 175 | return true; 176 | }); 177 | 178 | if (!accepted) { 179 | throw new Error('None of the extension offers can be accepted'); 180 | } 181 | 182 | if (opts.serverNoContextTakeover) { 183 | accepted.server_no_context_takeover = true; 184 | } 185 | if (opts.clientNoContextTakeover) { 186 | accepted.client_no_context_takeover = true; 187 | } 188 | if (typeof opts.serverMaxWindowBits === 'number') { 189 | accepted.server_max_window_bits = opts.serverMaxWindowBits; 190 | } 191 | if (typeof opts.clientMaxWindowBits === 'number') { 192 | accepted.client_max_window_bits = opts.clientMaxWindowBits; 193 | } else if ( 194 | accepted.client_max_window_bits === true || 195 | opts.clientMaxWindowBits === false 196 | ) { 197 | delete accepted.client_max_window_bits; 198 | } 199 | 200 | return accepted; 201 | } 202 | 203 | /** 204 | * Accept the extension negotiation response. 205 | * 206 | * @param {Array} response The extension negotiation response 207 | * @return {Object} Accepted configuration 208 | * @private 209 | */ 210 | acceptAsClient(response) { 211 | const params = response[0]; 212 | 213 | if ( 214 | this._options.clientNoContextTakeover === false && 215 | params.client_no_context_takeover 216 | ) { 217 | throw new Error('Unexpected parameter "client_no_context_takeover"'); 218 | } 219 | 220 | if (!params.client_max_window_bits) { 221 | if (typeof this._options.clientMaxWindowBits === 'number') { 222 | params.client_max_window_bits = this._options.clientMaxWindowBits; 223 | } 224 | } else if ( 225 | this._options.clientMaxWindowBits === false || 226 | (typeof this._options.clientMaxWindowBits === 'number' && 227 | params.client_max_window_bits > this._options.clientMaxWindowBits) 228 | ) { 229 | throw new Error( 230 | 'Unexpected or invalid parameter "client_max_window_bits"' 231 | ); 232 | } 233 | 234 | return params; 235 | } 236 | 237 | /** 238 | * Normalize parameters. 239 | * 240 | * @param {Array} configurations The extension negotiation offers/reponse 241 | * @return {Array} The offers/response with normalized parameters 242 | * @private 243 | */ 244 | normalizeParams(configurations) { 245 | configurations.forEach((params) => { 246 | Object.keys(params).forEach((key) => { 247 | let value = params[key]; 248 | 249 | if (value.length > 1) { 250 | throw new Error(`Parameter "${key}" must have only a single value`); 251 | } 252 | 253 | value = value[0]; 254 | 255 | if (key === 'client_max_window_bits') { 256 | if (value !== true) { 257 | const num = +value; 258 | if (!Number.isInteger(num) || num < 8 || num > 15) { 259 | throw new TypeError( 260 | `Invalid value for parameter "${key}": ${value}` 261 | ); 262 | } 263 | value = num; 264 | } else if (!this._isServer) { 265 | throw new TypeError( 266 | `Invalid value for parameter "${key}": ${value}` 267 | ); 268 | } 269 | } else if (key === 'server_max_window_bits') { 270 | const num = +value; 271 | if (!Number.isInteger(num) || num < 8 || num > 15) { 272 | throw new TypeError( 273 | `Invalid value for parameter "${key}": ${value}` 274 | ); 275 | } 276 | value = num; 277 | } else if ( 278 | key === 'client_no_context_takeover' || 279 | key === 'server_no_context_takeover' 280 | ) { 281 | if (value !== true) { 282 | throw new TypeError( 283 | `Invalid value for parameter "${key}": ${value}` 284 | ); 285 | } 286 | } else { 287 | throw new Error(`Unknown parameter "${key}"`); 288 | } 289 | 290 | params[key] = value; 291 | }); 292 | }); 293 | 294 | return configurations; 295 | } 296 | 297 | /** 298 | * Decompress data. Concurrency limited. 299 | * 300 | * @param {Buffer} data Compressed data 301 | * @param {Boolean} fin Specifies whether or not this is the last fragment 302 | * @param {Function} callback Callback 303 | * @public 304 | */ 305 | decompress(data, fin, callback) { 306 | zlibLimiter.add((done) => { 307 | this._decompress(data, fin, (err, result) => { 308 | done(); 309 | callback(err, result); 310 | }); 311 | }); 312 | } 313 | 314 | /** 315 | * Compress data. Concurrency limited. 316 | * 317 | * @param {(Buffer|String)} data Data to compress 318 | * @param {Boolean} fin Specifies whether or not this is the last fragment 319 | * @param {Function} callback Callback 320 | * @public 321 | */ 322 | compress(data, fin, callback) { 323 | zlibLimiter.add((done) => { 324 | this._compress(data, fin, (err, result) => { 325 | done(); 326 | callback(err, result); 327 | }); 328 | }); 329 | } 330 | 331 | /** 332 | * Decompress data. 333 | * 334 | * @param {Buffer} data Compressed data 335 | * @param {Boolean} fin Specifies whether or not this is the last fragment 336 | * @param {Function} callback Callback 337 | * @private 338 | */ 339 | _decompress(data, fin, callback) { 340 | const endpoint = this._isServer ? 'client' : 'server'; 341 | 342 | if (!this._inflate) { 343 | const key = `${endpoint}_max_window_bits`; 344 | const windowBits = 345 | typeof this.params[key] !== 'number' 346 | ? zlib.Z_DEFAULT_WINDOWBITS 347 | : this.params[key]; 348 | 349 | this._inflate = zlib.createInflateRaw({ 350 | ...this._options.zlibInflateOptions, 351 | windowBits 352 | }); 353 | this._inflate[kPerMessageDeflate] = this; 354 | this._inflate[kTotalLength] = 0; 355 | this._inflate[kBuffers] = []; 356 | this._inflate.on('error', inflateOnError); 357 | this._inflate.on('data', inflateOnData); 358 | } 359 | 360 | this._inflate[kCallback] = callback; 361 | 362 | this._inflate.write(data); 363 | if (fin) this._inflate.write(TRAILER); 364 | 365 | this._inflate.flush(() => { 366 | const err = this._inflate[kError]; 367 | 368 | if (err) { 369 | this._inflate.close(); 370 | this._inflate = null; 371 | callback(err); 372 | return; 373 | } 374 | 375 | const data = bufferUtil.concat( 376 | this._inflate[kBuffers], 377 | this._inflate[kTotalLength] 378 | ); 379 | 380 | if (this._inflate._readableState.endEmitted) { 381 | this._inflate.close(); 382 | this._inflate = null; 383 | } else { 384 | this._inflate[kTotalLength] = 0; 385 | this._inflate[kBuffers] = []; 386 | 387 | if (fin && this.params[`${endpoint}_no_context_takeover`]) { 388 | this._inflate.reset(); 389 | } 390 | } 391 | 392 | callback(null, data); 393 | }); 394 | } 395 | 396 | /** 397 | * Compress data. 398 | * 399 | * @param {(Buffer|String)} data Data to compress 400 | * @param {Boolean} fin Specifies whether or not this is the last fragment 401 | * @param {Function} callback Callback 402 | * @private 403 | */ 404 | _compress(data, fin, callback) { 405 | const endpoint = this._isServer ? 'server' : 'client'; 406 | 407 | if (!this._deflate) { 408 | const key = `${endpoint}_max_window_bits`; 409 | const windowBits = 410 | typeof this.params[key] !== 'number' 411 | ? zlib.Z_DEFAULT_WINDOWBITS 412 | : this.params[key]; 413 | 414 | this._deflate = zlib.createDeflateRaw({ 415 | ...this._options.zlibDeflateOptions, 416 | windowBits 417 | }); 418 | 419 | this._deflate[kTotalLength] = 0; 420 | this._deflate[kBuffers] = []; 421 | 422 | this._deflate.on('data', deflateOnData); 423 | } 424 | 425 | this._deflate[kCallback] = callback; 426 | 427 | this._deflate.write(data); 428 | this._deflate.flush(zlib.Z_SYNC_FLUSH, () => { 429 | if (!this._deflate) { 430 | // 431 | // The deflate stream was closed while data was being processed. 432 | // 433 | return; 434 | } 435 | 436 | let data = bufferUtil.concat( 437 | this._deflate[kBuffers], 438 | this._deflate[kTotalLength] 439 | ); 440 | 441 | if (fin) { 442 | data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4); 443 | } 444 | 445 | // 446 | // Ensure that the callback will not be called again in 447 | // `PerMessageDeflate#cleanup()`. 448 | // 449 | this._deflate[kCallback] = null; 450 | 451 | this._deflate[kTotalLength] = 0; 452 | this._deflate[kBuffers] = []; 453 | 454 | if (fin && this.params[`${endpoint}_no_context_takeover`]) { 455 | this._deflate.reset(); 456 | } 457 | 458 | callback(null, data); 459 | }); 460 | } 461 | } 462 | 463 | module.exports = PerMessageDeflate; 464 | 465 | /** 466 | * The listener of the `zlib.DeflateRaw` stream `'data'` event. 467 | * 468 | * @param {Buffer} chunk A chunk of data 469 | * @private 470 | */ 471 | function deflateOnData(chunk) { 472 | this[kBuffers].push(chunk); 473 | this[kTotalLength] += chunk.length; 474 | } 475 | 476 | /** 477 | * The listener of the `zlib.InflateRaw` stream `'data'` event. 478 | * 479 | * @param {Buffer} chunk A chunk of data 480 | * @private 481 | */ 482 | function inflateOnData(chunk) { 483 | this[kTotalLength] += chunk.length; 484 | 485 | if ( 486 | this[kPerMessageDeflate]._maxPayload < 1 || 487 | this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload 488 | ) { 489 | this[kBuffers].push(chunk); 490 | return; 491 | } 492 | 493 | this[kError] = new RangeError('Max payload size exceeded'); 494 | this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'; 495 | this[kError][kStatusCode] = 1009; 496 | this.removeListener('data', inflateOnData); 497 | 498 | // 499 | // The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the 500 | // fact that in Node.js versions prior to 13.10.0, the callback for 501 | // `zlib.flush()` is not called if `zlib.close()` is used. Utilizing 502 | // `zlib.reset()` ensures that either the callback is invoked or an error is 503 | // emitted. 504 | // 505 | this.reset(); 506 | } 507 | 508 | /** 509 | * The listener of the `zlib.InflateRaw` stream `'error'` event. 510 | * 511 | * @param {Error} err The emitted error 512 | * @private 513 | */ 514 | function inflateOnError(err) { 515 | // 516 | // There is no need to call `Zlib#close()` as the handle is automatically 517 | // closed when an error is emitted. 518 | // 519 | this[kPerMessageDeflate]._inflate = null; 520 | 521 | if (this[kError]) { 522 | this[kCallback](this[kError]); 523 | return; 524 | } 525 | 526 | err[kStatusCode] = 1007; 527 | this[kCallback](err); 528 | } 529 | -------------------------------------------------------------------------------- /examples/node_modules/ws/README.md: -------------------------------------------------------------------------------- 1 | # ws: a Node.js WebSocket library 2 | 3 | [![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws) 4 | [![CI](https://img.shields.io/github/actions/workflow/status/websockets/ws/ci.yml?branch=master&label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster) 5 | [![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws) 6 | 7 | ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and 8 | server implementation. 9 | 10 | Passes the quite extensive Autobahn test suite: [server][server-report], 11 | [client][client-report]. 12 | 13 | **Note**: This module does not work in the browser. The client in the docs is a 14 | reference to a backend with the role of a client in the WebSocket communication. 15 | Browser clients must use the native 16 | [`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) 17 | object. To make the same code work seamlessly on Node.js and the browser, you 18 | can use one of the many wrappers available on npm, like 19 | [isomorphic-ws](https://github.com/heineiuo/isomorphic-ws). 20 | 21 | ## Table of Contents 22 | 23 | - [Protocol support](#protocol-support) 24 | - [Installing](#installing) 25 | - [Opt-in for performance](#opt-in-for-performance) 26 | - [Legacy opt-in for performance](#legacy-opt-in-for-performance) 27 | - [API docs](#api-docs) 28 | - [WebSocket compression](#websocket-compression) 29 | - [Usage examples](#usage-examples) 30 | - [Sending and receiving text data](#sending-and-receiving-text-data) 31 | - [Sending binary data](#sending-binary-data) 32 | - [Simple server](#simple-server) 33 | - [External HTTP/S server](#external-https-server) 34 | - [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server) 35 | - [Client authentication](#client-authentication) 36 | - [Server broadcast](#server-broadcast) 37 | - [Round-trip time](#round-trip-time) 38 | - [Use the Node.js streams API](#use-the-nodejs-streams-api) 39 | - [Other examples](#other-examples) 40 | - [FAQ](#faq) 41 | - [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client) 42 | - [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections) 43 | - [How to connect via a proxy?](#how-to-connect-via-a-proxy) 44 | - [Changelog](#changelog) 45 | - [License](#license) 46 | 47 | ## Protocol support 48 | 49 | - **HyBi drafts 07-12** (Use the option `protocolVersion: 8`) 50 | - **HyBi drafts 13-17** (Current default, alternatively option 51 | `protocolVersion: 13`) 52 | 53 | ## Installing 54 | 55 | ``` 56 | npm install ws 57 | ``` 58 | 59 | ### Opt-in for performance 60 | 61 | [bufferutil][] is an optional module that can be installed alongside the ws 62 | module: 63 | 64 | ``` 65 | npm install --save-optional bufferutil 66 | ``` 67 | 68 | This is a binary addon that improves the performance of certain operations such 69 | as masking and unmasking the data payload of the WebSocket frames. Prebuilt 70 | binaries are available for the most popular platforms, so you don't necessarily 71 | need to have a C++ compiler installed on your machine. 72 | 73 | To force ws to not use bufferutil, use the 74 | [`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This 75 | can be useful to enhance security in systems where a user can put a package in 76 | the package search path of an application of another user, due to how the 77 | Node.js resolver algorithm works. 78 | 79 | #### Legacy opt-in for performance 80 | 81 | If you are running on an old version of Node.js (prior to v18.14.0), ws also 82 | supports the [utf-8-validate][] module: 83 | 84 | ``` 85 | npm install --save-optional utf-8-validate 86 | ``` 87 | 88 | This contains a binary polyfill for [`buffer.isUtf8()`][]. 89 | 90 | To force ws not to use utf-8-validate, use the 91 | [`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable. 92 | 93 | ## API docs 94 | 95 | See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and 96 | utility functions. 97 | 98 | ## WebSocket compression 99 | 100 | ws supports the [permessage-deflate extension][permessage-deflate] which enables 101 | the client and server to negotiate a compression algorithm and its parameters, 102 | and then selectively apply it to the data payloads of each WebSocket message. 103 | 104 | The extension is disabled by default on the server and enabled by default on the 105 | client. It adds a significant overhead in terms of performance and memory 106 | consumption so we suggest to enable it only if it is really needed. 107 | 108 | Note that Node.js has a variety of issues with high-performance compression, 109 | where increased concurrency, especially on Linux, can lead to [catastrophic 110 | memory fragmentation][node-zlib-bug] and slow performance. If you intend to use 111 | permessage-deflate in production, it is worthwhile to set up a test 112 | representative of your workload and ensure Node.js/zlib will handle it with 113 | acceptable performance and memory usage. 114 | 115 | Tuning of permessage-deflate can be done via the options defined below. You can 116 | also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly 117 | into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs]. 118 | 119 | See [the docs][ws-server-options] for more options. 120 | 121 | ```js 122 | import WebSocket, { WebSocketServer } from 'ws'; 123 | 124 | const wss = new WebSocketServer({ 125 | port: 8080, 126 | perMessageDeflate: { 127 | zlibDeflateOptions: { 128 | // See zlib defaults. 129 | chunkSize: 1024, 130 | memLevel: 7, 131 | level: 3 132 | }, 133 | zlibInflateOptions: { 134 | chunkSize: 10 * 1024 135 | }, 136 | // Other options settable: 137 | clientNoContextTakeover: true, // Defaults to negotiated value. 138 | serverNoContextTakeover: true, // Defaults to negotiated value. 139 | serverMaxWindowBits: 10, // Defaults to negotiated value. 140 | // Below options specified as default values. 141 | concurrencyLimit: 10, // Limits zlib concurrency for perf. 142 | threshold: 1024 // Size (in bytes) below which messages 143 | // should not be compressed if context takeover is disabled. 144 | } 145 | }); 146 | ``` 147 | 148 | The client will only use the extension if it is supported and enabled on the 149 | server. To always disable the extension on the client, set the 150 | `perMessageDeflate` option to `false`. 151 | 152 | ```js 153 | import WebSocket from 'ws'; 154 | 155 | const ws = new WebSocket('ws://www.host.com/path', { 156 | perMessageDeflate: false 157 | }); 158 | ``` 159 | 160 | ## Usage examples 161 | 162 | ### Sending and receiving text data 163 | 164 | ```js 165 | import WebSocket from 'ws'; 166 | 167 | const ws = new WebSocket('ws://www.host.com/path'); 168 | 169 | ws.on('error', console.error); 170 | 171 | ws.on('open', function open() { 172 | ws.send('something'); 173 | }); 174 | 175 | ws.on('message', function message(data) { 176 | console.log('received: %s', data); 177 | }); 178 | ``` 179 | 180 | ### Sending binary data 181 | 182 | ```js 183 | import WebSocket from 'ws'; 184 | 185 | const ws = new WebSocket('ws://www.host.com/path'); 186 | 187 | ws.on('error', console.error); 188 | 189 | ws.on('open', function open() { 190 | const array = new Float32Array(5); 191 | 192 | for (var i = 0; i < array.length; ++i) { 193 | array[i] = i / 2; 194 | } 195 | 196 | ws.send(array); 197 | }); 198 | ``` 199 | 200 | ### Simple server 201 | 202 | ```js 203 | import { WebSocketServer } from 'ws'; 204 | 205 | const wss = new WebSocketServer({ port: 8080 }); 206 | 207 | wss.on('connection', function connection(ws) { 208 | ws.on('error', console.error); 209 | 210 | ws.on('message', function message(data) { 211 | console.log('received: %s', data); 212 | }); 213 | 214 | ws.send('something'); 215 | }); 216 | ``` 217 | 218 | ### External HTTP/S server 219 | 220 | ```js 221 | import { createServer } from 'https'; 222 | import { readFileSync } from 'fs'; 223 | import { WebSocketServer } from 'ws'; 224 | 225 | const server = createServer({ 226 | cert: readFileSync('/path/to/cert.pem'), 227 | key: readFileSync('/path/to/key.pem') 228 | }); 229 | const wss = new WebSocketServer({ server }); 230 | 231 | wss.on('connection', function connection(ws) { 232 | ws.on('error', console.error); 233 | 234 | ws.on('message', function message(data) { 235 | console.log('received: %s', data); 236 | }); 237 | 238 | ws.send('something'); 239 | }); 240 | 241 | server.listen(8080); 242 | ``` 243 | 244 | ### Multiple servers sharing a single HTTP/S server 245 | 246 | ```js 247 | import { createServer } from 'http'; 248 | import { WebSocketServer } from 'ws'; 249 | 250 | const server = createServer(); 251 | const wss1 = new WebSocketServer({ noServer: true }); 252 | const wss2 = new WebSocketServer({ noServer: true }); 253 | 254 | wss1.on('connection', function connection(ws) { 255 | ws.on('error', console.error); 256 | 257 | // ... 258 | }); 259 | 260 | wss2.on('connection', function connection(ws) { 261 | ws.on('error', console.error); 262 | 263 | // ... 264 | }); 265 | 266 | server.on('upgrade', function upgrade(request, socket, head) { 267 | const { pathname } = new URL(request.url, 'wss://base.url'); 268 | 269 | if (pathname === '/foo') { 270 | wss1.handleUpgrade(request, socket, head, function done(ws) { 271 | wss1.emit('connection', ws, request); 272 | }); 273 | } else if (pathname === '/bar') { 274 | wss2.handleUpgrade(request, socket, head, function done(ws) { 275 | wss2.emit('connection', ws, request); 276 | }); 277 | } else { 278 | socket.destroy(); 279 | } 280 | }); 281 | 282 | server.listen(8080); 283 | ``` 284 | 285 | ### Client authentication 286 | 287 | ```js 288 | import { createServer } from 'http'; 289 | import { WebSocketServer } from 'ws'; 290 | 291 | function onSocketError(err) { 292 | console.error(err); 293 | } 294 | 295 | const server = createServer(); 296 | const wss = new WebSocketServer({ noServer: true }); 297 | 298 | wss.on('connection', function connection(ws, request, client) { 299 | ws.on('error', console.error); 300 | 301 | ws.on('message', function message(data) { 302 | console.log(`Received message ${data} from user ${client}`); 303 | }); 304 | }); 305 | 306 | server.on('upgrade', function upgrade(request, socket, head) { 307 | socket.on('error', onSocketError); 308 | 309 | // This function is not defined on purpose. Implement it with your own logic. 310 | authenticate(request, function next(err, client) { 311 | if (err || !client) { 312 | socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); 313 | socket.destroy(); 314 | return; 315 | } 316 | 317 | socket.removeListener('error', onSocketError); 318 | 319 | wss.handleUpgrade(request, socket, head, function done(ws) { 320 | wss.emit('connection', ws, request, client); 321 | }); 322 | }); 323 | }); 324 | 325 | server.listen(8080); 326 | ``` 327 | 328 | Also see the provided [example][session-parse-example] using `express-session`. 329 | 330 | ### Server broadcast 331 | 332 | A client WebSocket broadcasting to all connected WebSocket clients, including 333 | itself. 334 | 335 | ```js 336 | import WebSocket, { WebSocketServer } from 'ws'; 337 | 338 | const wss = new WebSocketServer({ port: 8080 }); 339 | 340 | wss.on('connection', function connection(ws) { 341 | ws.on('error', console.error); 342 | 343 | ws.on('message', function message(data, isBinary) { 344 | wss.clients.forEach(function each(client) { 345 | if (client.readyState === WebSocket.OPEN) { 346 | client.send(data, { binary: isBinary }); 347 | } 348 | }); 349 | }); 350 | }); 351 | ``` 352 | 353 | A client WebSocket broadcasting to every other connected WebSocket clients, 354 | excluding itself. 355 | 356 | ```js 357 | import WebSocket, { WebSocketServer } from 'ws'; 358 | 359 | const wss = new WebSocketServer({ port: 8080 }); 360 | 361 | wss.on('connection', function connection(ws) { 362 | ws.on('error', console.error); 363 | 364 | ws.on('message', function message(data, isBinary) { 365 | wss.clients.forEach(function each(client) { 366 | if (client !== ws && client.readyState === WebSocket.OPEN) { 367 | client.send(data, { binary: isBinary }); 368 | } 369 | }); 370 | }); 371 | }); 372 | ``` 373 | 374 | ### Round-trip time 375 | 376 | ```js 377 | import WebSocket from 'ws'; 378 | 379 | const ws = new WebSocket('wss://websocket-echo.com/'); 380 | 381 | ws.on('error', console.error); 382 | 383 | ws.on('open', function open() { 384 | console.log('connected'); 385 | ws.send(Date.now()); 386 | }); 387 | 388 | ws.on('close', function close() { 389 | console.log('disconnected'); 390 | }); 391 | 392 | ws.on('message', function message(data) { 393 | console.log(`Round-trip time: ${Date.now() - data} ms`); 394 | 395 | setTimeout(function timeout() { 396 | ws.send(Date.now()); 397 | }, 500); 398 | }); 399 | ``` 400 | 401 | ### Use the Node.js streams API 402 | 403 | ```js 404 | import WebSocket, { createWebSocketStream } from 'ws'; 405 | 406 | const ws = new WebSocket('wss://websocket-echo.com/'); 407 | 408 | const duplex = createWebSocketStream(ws, { encoding: 'utf8' }); 409 | 410 | duplex.on('error', console.error); 411 | 412 | duplex.pipe(process.stdout); 413 | process.stdin.pipe(duplex); 414 | ``` 415 | 416 | ### Other examples 417 | 418 | For a full example with a browser client communicating with a ws server, see the 419 | examples folder. 420 | 421 | Otherwise, see the test cases. 422 | 423 | ## FAQ 424 | 425 | ### How to get the IP address of the client? 426 | 427 | The remote IP address can be obtained from the raw socket. 428 | 429 | ```js 430 | import { WebSocketServer } from 'ws'; 431 | 432 | const wss = new WebSocketServer({ port: 8080 }); 433 | 434 | wss.on('connection', function connection(ws, req) { 435 | const ip = req.socket.remoteAddress; 436 | 437 | ws.on('error', console.error); 438 | }); 439 | ``` 440 | 441 | When the server runs behind a proxy like NGINX, the de-facto standard is to use 442 | the `X-Forwarded-For` header. 443 | 444 | ```js 445 | wss.on('connection', function connection(ws, req) { 446 | const ip = req.headers['x-forwarded-for'].split(',')[0].trim(); 447 | 448 | ws.on('error', console.error); 449 | }); 450 | ``` 451 | 452 | ### How to detect and close broken connections? 453 | 454 | Sometimes, the link between the server and the client can be interrupted in a 455 | way that keeps both the server and the client unaware of the broken state of the 456 | connection (e.g. when pulling the cord). 457 | 458 | In these cases, ping messages can be used as a means to verify that the remote 459 | endpoint is still responsive. 460 | 461 | ```js 462 | import { WebSocketServer } from 'ws'; 463 | 464 | function heartbeat() { 465 | this.isAlive = true; 466 | } 467 | 468 | const wss = new WebSocketServer({ port: 8080 }); 469 | 470 | wss.on('connection', function connection(ws) { 471 | ws.isAlive = true; 472 | ws.on('error', console.error); 473 | ws.on('pong', heartbeat); 474 | }); 475 | 476 | const interval = setInterval(function ping() { 477 | wss.clients.forEach(function each(ws) { 478 | if (ws.isAlive === false) return ws.terminate(); 479 | 480 | ws.isAlive = false; 481 | ws.ping(); 482 | }); 483 | }, 30000); 484 | 485 | wss.on('close', function close() { 486 | clearInterval(interval); 487 | }); 488 | ``` 489 | 490 | Pong messages are automatically sent in response to ping messages as required by 491 | the spec. 492 | 493 | Just like the server example above, your clients might as well lose connection 494 | without knowing it. You might want to add a ping listener on your clients to 495 | prevent that. A simple implementation would be: 496 | 497 | ```js 498 | import WebSocket from 'ws'; 499 | 500 | function heartbeat() { 501 | clearTimeout(this.pingTimeout); 502 | 503 | // Use `WebSocket#terminate()`, which immediately destroys the connection, 504 | // instead of `WebSocket#close()`, which waits for the close timer. 505 | // Delay should be equal to the interval at which your server 506 | // sends out pings plus a conservative assumption of the latency. 507 | this.pingTimeout = setTimeout(() => { 508 | this.terminate(); 509 | }, 30000 + 1000); 510 | } 511 | 512 | const client = new WebSocket('wss://websocket-echo.com/'); 513 | 514 | client.on('error', console.error); 515 | client.on('open', heartbeat); 516 | client.on('ping', heartbeat); 517 | client.on('close', function clear() { 518 | clearTimeout(this.pingTimeout); 519 | }); 520 | ``` 521 | 522 | ### How to connect via a proxy? 523 | 524 | Use a custom `http.Agent` implementation like [https-proxy-agent][] or 525 | [socks-proxy-agent][]. 526 | 527 | ## Changelog 528 | 529 | We're using the GitHub [releases][changelog] for changelog entries. 530 | 531 | ## License 532 | 533 | [MIT](LICENSE) 534 | 535 | [`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input 536 | [bufferutil]: https://github.com/websockets/bufferutil 537 | [changelog]: https://github.com/websockets/ws/releases 538 | [client-report]: http://websockets.github.io/ws/autobahn/clients/ 539 | [https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent 540 | [node-zlib-bug]: https://github.com/nodejs/node/issues/8871 541 | [node-zlib-deflaterawdocs]: 542 | https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options 543 | [permessage-deflate]: https://tools.ietf.org/html/rfc7692 544 | [server-report]: http://websockets.github.io/ws/autobahn/servers/ 545 | [session-parse-example]: ./examples/express-session-parse 546 | [socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent 547 | [utf-8-validate]: https://github.com/websockets/utf-8-validate 548 | [ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback 549 | --------------------------------------------------------------------------------