├── .gitignore ├── .npmignore ├── .prettierrc ├── CONTRIBUTE.md ├── LICENSE ├── NOTE.md ├── README.md ├── docs ├── .gitignore ├── doczrc.js ├── package.json ├── public │ └── favicon.ico ├── src │ ├── PromisePlayground.tsx │ ├── SnapshotPlayground.tsx │ ├── SubscriptionPlayground.tsx │ ├── gatsby-theme-docz │ │ └── wrapper.tsx │ ├── index.mdx │ ├── promise-playground.mdx │ ├── snapshot-playground.mdx │ ├── subscription-playground.mdx │ └── usage.mdx ├── tsconfig.json └── yarn.lock ├── examples └── nodejs-candles.cjs ├── package.json ├── rollup.config.js ├── src ├── __mocks__ │ └── endpoint.ts ├── config.ts ├── endpoint.ts ├── eventFlags.ts ├── feed.test.ts ├── feed.ts ├── index.ts ├── interfaces.ts ├── subscriptions.ts ├── timeSeriesAggregator.ts └── utils.ts ├── tsconfig.json ├── tslint.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Meta 2 | **/.DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-error.log 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # node-waf configuration 28 | .lock-wscript 29 | 30 | # Compiled binary addons (http://nodejs.org/api/addons.html) 31 | build/Release 32 | 33 | # Dependency directories 34 | node_modules 35 | jspm_packages 36 | 37 | # Optional npm cache directory 38 | .npm 39 | 40 | # Optional REPL history 41 | .node_repl_history 42 | 43 | # Distribution 44 | dist 45 | 46 | # Temp env 47 | .env 48 | 49 | .npmrc 50 | 51 | /lib 52 | 53 | #IDE 54 | .idea 55 | .vscode -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | docs 3 | examples 4 | tsconfig.json 5 | tslint.json 6 | yarn.lock 7 | .prettierrc 8 | .idea 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "trailingComma": "all", 4 | "singleQuote": true 5 | } -------------------------------------------------------------------------------- /CONTRIBUTE.md: -------------------------------------------------------------------------------- 1 | # Contributing to js-api 2 | 3 | We would be glad if you contribute to our source code and help us to improve js-api even more! We propose you to follow these guidelines: 4 | ## Contributing a Patch 5 | 6 | 1. Create your change to the repo in question. 7 | - Fork the desired repo, develop and test your code changes. 8 | - Ensure that your code is clear and comprehensible. 9 | - Ensure that your code has an appropriate set of unit tests which all pass. 10 | 11 | 2. Submit a pull request. 12 | 3. The repo owner will review your request. If it is approved, the change will be merged. If it needs additional work, the repo owner will respond with useful comments. 13 | 14 | ## Question or problem 15 | If you have any questions about how to use js-api, please contact via Github issues. Just create an issue with a question. 16 | 17 | ## Submitting an issue 18 | You can help us by submitting an issue to our GitHub Repository in case you find a bug in the source code, a mistake in the documentation or a proposal. A Pull Request with a fix would be even better. Before you submit an issue, search the archive, maybe your question was already answered. 19 | 20 | Here are the submission guidelines we would like you to follow: 21 | * Detailed overview of the Issue 22 | * Related issues - has a similar issue been reported before? 23 | * Reproduce the Error - A set of steps to reproduce the error (in case of a bug) 24 | * Suggest a Fix - if you can't fix the bug yourself, perhaps you can point to what might be causing the problem (line of code or commit) 25 | 26 | ## Documentation fixes 27 | If you have any ideas how to help us to improve the documentation, please let others know what you're working on by creating a new issue or comment on a related existing one (please, follow the guidelines contained in Submitting an issue). 28 | 29 |   30 | 31 |   32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /NOTE.md: -------------------------------------------------------------------------------- 1 | Copyright ©2000 Devexperts LLC. All rights reserved. 2 | 3 | This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 4 | 5 | If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # @dxfeed/api [![Version](https://img.shields.io/npm/v/@dxfeed/api.svg?style=flat-square)](https://www.npmjs.com/package/@dxfeed/api) 2 | ## IMPORTANT: This API is outdated, please use [DXLINK API](https://github.com/dxFeed/dxLink) 3 | 4 | This package provides access to [dxFeed](https://www.dxfeed.com/) streaming data. 5 | 6 | Our package is easy to integrate with any modern framework. 7 | 8 | ## Install 9 | 10 | ```sh 11 | npm install @dxfeed/api 12 | ``` 13 | 14 | ## NodeJS usage 15 | 16 | Install `cometd-nodejs-client` package 17 | 18 | ```sh 19 | npm install cometd-nodejs-client 20 | ``` 21 | 22 | and use it in your code 23 | 24 | ```js 25 | require('cometd-nodejs-client').adapt() 26 | // or 27 | import * as CometdNodejsClient from 'cometd-nodejs-client' 28 | CometdNodejsClient.adapt() 29 | ``` 30 | 31 | ## Basic Usage 32 | 33 | We have several classes in implementation: 34 | 35 | - Feed **_(public)_** 36 | - Endpoint **_(private)_** 37 | - Subscriptions **_(private)_** 38 | 39 | The _Feed_ is entry point for configuration and creating subscriptions. 40 | _Feed_ manages private classes for connecting and subscribing. 41 | The _Endpoint_ is responsible for managing the web socket connection. 42 | _Subscriptions_ for managing open subscriptions. 43 | 44 | ## Import package 45 | 46 | ```ts 47 | import Feed from '@dxfeed/api' 48 | ``` 49 | 50 | ## Configure & Create connection 51 | 52 | Create instance of Feed. 53 | 54 | ```ts 55 | const feed = new Feed() 56 | ``` 57 | 58 | Provide auth token if needed. 59 | 60 | ```ts 61 | feed.setAuthToken('authToken') 62 | ``` 63 | 64 | Set web socket address and open connection. 65 | 66 | ```ts 67 | feed.connect('wss://demo.dxfeed.com/webservice/cometd') 68 | ``` 69 | 70 | ## Configure & Create subscription 71 | 72 | You should specify event types and symbol names. 73 | 74 | ```ts 75 | feed.subscribe<{ value: number }>( 76 | [EventType.Summary, EventType.Trade] /* event types */, 77 | ['AEX.IND:TEI'] /* symbols */, 78 | handleEvent 79 | ) 80 | ``` 81 | 82 | For timed subscription you should also provide time to start subscription from. 83 | 84 | For Candle event type along with base symbol, you should specify an aggregation period. You can also set price type. More details: [https://kb.dxfeed.com/en/data-access/rest-api.html#candle-symbols](https://kb.dxfeed.com/en/data-access/rest-api.html#candle-symbols) 85 | 86 | ```ts 87 | feed.subscribeTimeSeries<{ value: number }>( 88 | [EventType.Summary, EventType.Trade] /* event types */, 89 | ['AEX.IND:TEI'] /* symbols */, 90 | 0 /* fromTime */, 91 | handleEvent 92 | ) 93 | ``` 94 | 95 | Last argument its event handler for process incoming events. 96 | 97 | ```ts 98 | const handleEvent = (event) => { 99 | /* process event */ 100 | } 101 | ``` 102 | 103 | ## Close subscription 104 | 105 | All subscribe methods return unsubscribe handler, you need to call this method for unsubscribe. 106 | 107 | ```ts 108 | const unsubscribe = feed.subscribe(eventTypes, symbols, handleEvent) 109 | 110 | onExit(() => unsubscribe()) 111 | ``` 112 | 113 | ## Aggregated API 114 | 115 | ### Get TimeSeries 116 | 117 | If you want to get TimeSeries events for a given time period, refer to example below. 118 | 119 | ```ts 120 | // inside async function 121 | const events = await feed.getTimeSeries( 122 | 'AAPL{=15m}', 123 | EventType.Candle, 124 | fromDate.getTime(), 125 | toDate.getTime() 126 | ) 127 | ``` 128 | 129 | ### Subscribe TimeSeries snapshot 130 | 131 | If you want to subscribe to TimeSeries events, refer to example below. 132 | 133 | ```ts 134 | const unsubscribe = feed.subscribeTimeSeriesSnapshot('AAPL{=15m}', EventType.Candle, (candles) => { 135 | // process candles 136 | chart.setCandles(candles) 137 | }) 138 | ``` 139 | 140 | ## Close connection 141 | 142 | If you need to close the web socket connection 143 | 144 | ```ts 145 | feed.disconnect() 146 | ``` 147 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Meta 2 | **/.DS_Store 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-error.log 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | 15 | # Directory for instrumented libs generated by jscoverage/JSCover 16 | lib-cov 17 | 18 | # Coverage directory used by tools like istanbul 19 | coverage 20 | 21 | # nyc test coverage 22 | .nyc_output 23 | 24 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 25 | .grunt 26 | 27 | # node-waf configuration 28 | .lock-wscript 29 | 30 | # Compiled binary addons (http://nodejs.org/api/addons.html) 31 | build/Release 32 | 33 | # Dependency directories 34 | node_modules 35 | jspm_packages 36 | 37 | # Optional npm cache directory 38 | .npm 39 | 40 | # Optional REPL history 41 | .node_repl_history 42 | 43 | # Distribution 44 | dist 45 | 46 | # Temp env 47 | .env 48 | 49 | .npmrc 50 | 51 | .docz 52 | -------------------------------------------------------------------------------- /docs/doczrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | typescript: true, 3 | dest: '/dist', 4 | menu: [ 5 | 'Getting Started', 6 | 'Basic Usage', 7 | 'Playground', 8 | ], 9 | title: '@dxfeed/api', 10 | description: 'This package provides access to dxFeed streaming data. Easy integrates with any modern framework.', 11 | repository: 'https://github.com/dxfeed/dxfeed-js-api', 12 | } 13 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dxfeed/api-docs", 3 | "version": "1.0.0", 4 | "license": "MPL-2.0", 5 | "repository": "https://github.com/dxFeed/dxfeed-js-api.git", 6 | "private": true, 7 | "scripts": { 8 | "start": "docz dev", 9 | "build": "docz build --base=/dxfeed-js-api/", 10 | "deploy": "gh-pages -d dist" 11 | }, 12 | "dependencies": { 13 | "@emotion/core": "^10.0.14", 14 | "@emotion/styled": "^10.0.14", 15 | "@material-ui/core": "^4.11.0", 16 | "docz": "^2.3.1", 17 | "react": "^16.8.6", 18 | "react-dom": "^16.8.6", 19 | "react-inspector": "^5.0.1" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^16.8.23", 23 | "@types/react-dom": "^16.8.4", 24 | "@types/react-inspector": "^4.0.0", 25 | "@types/theme-ui": "^0.3.5", 26 | "gh-pages": "^3.1.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dxFeed/dxfeed-js-api/35a4073f6bc8e9c0add3a00f9a7d4a1f036ec754/docs/public/favicon.ico -------------------------------------------------------------------------------- /docs/src/PromisePlayground.tsx: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2022 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import React from 'react' 9 | import { Inspector } from 'react-inspector' 10 | 11 | import Button from '@material-ui/core/Button' 12 | import FormControl from '@material-ui/core/FormControl' 13 | import FormControlLabel from '@material-ui/core/FormControlLabel' 14 | import FormLabel from '@material-ui/core/FormLabel' 15 | import Grid from '@material-ui/core/Grid' 16 | import Radio from '@material-ui/core/Radio' 17 | import RadioGroup from '@material-ui/core/RadioGroup' 18 | import Input from '@material-ui/core/Input' 19 | import { useComponents } from 'docz' 20 | 21 | import Feed, { EventType } from '../../src' 22 | 23 | const SYMBOLS = ['AAPL', 'GOOG'] as const 24 | 25 | const minusDay = (date: Date, days: number): Date => { 26 | return new Date(date.getTime() - days * 24 * 60 * 60 * 1000) 27 | } 28 | 29 | const DataViewer = ({ events }: { events: unknown[] }) => 30 | events.length > 0 && ( 31 | 32 | {events.slice(0, Math.min(10, events.length)).map((event, idx) => ( 33 | 34 | 35 | 36 | ))} 37 | Only displaying 10 events, total: {events.length} 38 | 39 | ) 40 | 41 | function Playground() { 42 | const { playground, pre, h2 } = useComponents() 43 | const PlaygroundComponent = playground as React.FunctionComponent<{ 44 | scope: Record 45 | language: string 46 | code: string 47 | }> 48 | const PreComponent = pre as React.FunctionComponent<{}> 49 | const H2Component = h2 as React.FunctionComponent<{}> 50 | 51 | const [eventType, setEventType] = React.useState(EventType.Candle) 52 | const [symbolName, setSymbolName] = React.useState(SYMBOLS[0]) 53 | const [days, setDays] = React.useState(1) 54 | 55 | const feed = React.useMemo(() => new Feed(), []) 56 | 57 | React.useEffect(() => { 58 | feed.connect('wss://demo.dxfeed.com/webservice/cometd') 59 | 60 | return () => feed.disconnect() 61 | }, []) 62 | 63 | return ( 64 | <> 65 | Configure 66 | 67 | 68 | 69 | 70 | Select type 71 | {}}> 72 | } label="Time Series" /> 73 | 74 | 75 | 76 | 77 | 78 | 79 | Select Event Type 80 | { 85 | setEventType(event.target.value as any) 86 | }} 87 | > 88 | {Object.keys(EventType).map((key) => { 89 | const value = key as EventType 90 | 91 | return ( 92 | } label={value} /> 93 | ) 94 | })} 95 | 96 | 97 | 98 | 99 | 100 | 101 | Select Symbol Name 102 | { 107 | setSymbolName(event.target.value as any) 108 | }} 109 | > 110 | {SYMBOLS.map((value) => ( 111 | } label={value} /> 112 | ))} 113 | 114 | 115 | 116 | 117 | 118 | 119 | Period in days 120 | { 125 | const value = event.target.value 126 | setDays(value.length ? parseInt(value, 10) : 0) 127 | }} 128 | /> 129 | 130 | 131 | 132 | 133 | Example 134 | 135 | Example demonstrates how to work with it in React 136 | 137 | 138 | 0 148 | this.disconnect = () => 0 149 | } 150 | }, 151 | Button, 152 | DataViewer, 153 | }} 154 | language="js" 155 | code={`() => { 156 | const [events, setEvents] = React.useState([]) 157 | 158 | const feed = React.useMemo(() => new Feed(), []) 159 | React.useEffect(() => { 160 | feed.connect('wss://demo.dxfeed.com/webservice/cometd') 161 | return () => feed.disconnect() 162 | }, []) 163 | 164 | const request = () => { 165 | feed.getTimeSeries( 166 | '${symbolName}${eventType === EventType.Candle ? '{=1m}' : ''}', 167 | '${eventType}', 168 | ${minusDay(new Date(), days).getTime()}, 169 | ${new Date().getTime()}, 170 | ).then(setEvents) 171 | } 172 | 173 | return ( 174 | <> 175 | 178 | 179 | 180 | ) 181 | }`} 182 | /> 183 | 184 | ) 185 | } 186 | 187 | export default Playground 188 | -------------------------------------------------------------------------------- /docs/src/SnapshotPlayground.tsx: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2022 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import React from 'react' 9 | import { Inspector } from 'react-inspector' 10 | 11 | import Button from '@material-ui/core/Button' 12 | import FormControl from '@material-ui/core/FormControl' 13 | import FormControlLabel from '@material-ui/core/FormControlLabel' 14 | import FormLabel from '@material-ui/core/FormLabel' 15 | import Grid from '@material-ui/core/Grid' 16 | import Radio from '@material-ui/core/Radio' 17 | import RadioGroup from '@material-ui/core/RadioGroup' 18 | import Input from '@material-ui/core/Input' 19 | import { useComponents } from 'docz' 20 | 21 | import Feed, { EventType } from '../../src' 22 | 23 | const SYMBOLS = ['AAPL', 'GOOG'] as const 24 | 25 | const minusDay = (date: Date, days: number): Date => { 26 | return new Date(date.getTime() - days * 24 * 60 * 60 * 1000) 27 | } 28 | 29 | const DataViewer = ({ events }: { events: unknown[] }) => 30 | events.length > 0 && ( 31 | 32 | {events.slice(0, Math.min(10, events.length)).map((event, idx) => ( 33 | 34 | 35 | 36 | ))} 37 | Only displaying 10 events, total: {events.length} 38 | 39 | ) 40 | 41 | function Playground() { 42 | const { playground, pre, h2 } = useComponents() 43 | const PlaygroundComponent = playground as React.FunctionComponent<{ 44 | scope: Record 45 | language: string 46 | code: string 47 | }> 48 | const PreComponent = pre as React.FunctionComponent<{}> 49 | const H2Component = h2 as React.FunctionComponent<{}> 50 | 51 | const [eventType, setEventType] = React.useState(EventType.Candle) 52 | const [symbolName, setSymbolName] = React.useState(SYMBOLS[0]) 53 | const [days, setDays] = React.useState(5) 54 | 55 | const feed = React.useMemo(() => new Feed(), []) 56 | 57 | React.useEffect(() => { 58 | feed.connect('wss://demo.dxfeed.com/webservice/cometd') 59 | 60 | return () => feed.disconnect() 61 | }, []) 62 | 63 | return ( 64 | <> 65 | Configure 66 | 67 | 68 | 69 | 70 | Select type 71 | {}}> 72 | } label="Time Series" /> 73 | 74 | 75 | 76 | 77 | 78 | 79 | Select Event Type 80 | { 85 | setEventType(event.target.value as any) 86 | }} 87 | > 88 | {Object.keys(EventType).map((key) => { 89 | const value = key as EventType 90 | 91 | return ( 92 | } label={value} /> 93 | ) 94 | })} 95 | 96 | 97 | 98 | 99 | 100 | 101 | Select Symbol Name 102 | { 107 | setSymbolName(event.target.value as any) 108 | }} 109 | > 110 | {SYMBOLS.map((value) => ( 111 | } label={value} /> 112 | ))} 113 | 114 | 115 | 116 | 117 | 118 | 119 | Period in days 120 | { 125 | const value = event.target.value 126 | setDays(value.length ? parseInt(value, 10) : 0) 127 | }} 128 | /> 129 | 130 | 131 | 132 | 133 | Example 134 | 135 | Example demonstrates how to work with it in React 136 | 137 | 138 | 0 148 | this.disconnect = () => 0 149 | } 150 | }, 151 | Button, 152 | DataViewer, 153 | }} 154 | language="js" 155 | code={`() => { 156 | const [events, setEvents] = React.useState([]) 157 | 158 | const feed = React.useMemo(() => new Feed(), []) 159 | React.useEffect(() => { 160 | feed.connect('wss://demo.dxfeed.com/webservice/cometd') 161 | return () => feed.disconnect() 162 | }, []) 163 | 164 | const subscriptionRef = React.useRef() 165 | const subscribe = () => { 166 | // Clear 167 | subscriptionRef.current && subscriptionRef.current.unsubscribe() 168 | setEvents([]) 169 | 170 | // Subscribe 171 | subscriptionRef.current = feed.subscribeTimeSeriesSnapshot( 172 | '${symbolName}${eventType === EventType.Candle ? '{=d}' : ''}', 173 | '${eventType}', 174 | ${minusDay(new Date(), days).getTime()}, 175 | (snapshot) => { 176 | setEvents(snapshot) 177 | } 178 | ) 179 | } 180 | 181 | return ( 182 | <> 183 | 186 | 187 | 188 | ) 189 | }`} 190 | /> 191 | 192 | ) 193 | } 194 | 195 | export default Playground 196 | -------------------------------------------------------------------------------- /docs/src/SubscriptionPlayground.tsx: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import React from 'react' 9 | import { Inspector } from 'react-inspector' 10 | 11 | import Button from '@material-ui/core/Button' 12 | import FormControl from '@material-ui/core/FormControl' 13 | import FormControlLabel from '@material-ui/core/FormControlLabel' 14 | import FormLabel from '@material-ui/core/FormLabel' 15 | import Grid from '@material-ui/core/Grid' 16 | import Radio from '@material-ui/core/Radio' 17 | import RadioGroup from '@material-ui/core/RadioGroup' 18 | import { useComponents } from 'docz' 19 | 20 | import Feed, { EventType } from '../../src' 21 | 22 | const SYMBOLS = ['ETH/USD', 'EUR/USD'] as const 23 | 24 | const DataViewer = ({ play, events }: { play: boolean; events: unknown[] }) => 25 | (play || events.length > 0) && ( 26 | 27 | {events.length === 0 && Waiting events} 28 | {events.map((event, idx) => ( 29 | 30 | 31 | 32 | ))} 33 | 34 | ) 35 | 36 | function Playground() { 37 | const { playground, pre, h2 } = useComponents() 38 | const PlaygroundComponent = playground as React.FunctionComponent<{ 39 | scope: Record 40 | language: string 41 | code: string 42 | }> 43 | const PreComponent = pre as React.FunctionComponent<{}> 44 | const H2Component = h2 as React.FunctionComponent<{}> 45 | 46 | const feedRef = React.useRef(null) 47 | const [type, setType] = React.useState<'series' | 'timeSeries'>('series') 48 | const [eventType, setEventType] = React.useState(EventType.Trade) 49 | const [symbolName, setSymbolName] = React.useState(SYMBOLS[0]) 50 | 51 | const feed = React.useMemo(() => new Feed(), []) 52 | 53 | React.useEffect(() => { 54 | feed.connect('wss://demo.dxfeed.com/webservice/cometd') 55 | 56 | return () => feed.disconnect() 57 | }, []) 58 | 59 | return ( 60 | <> 61 | Configure 62 | 63 | 64 | 65 | 66 | Select type 67 | { 72 | setType(event.target.value as any) 73 | }} 74 | > 75 | } label="Series" /> 76 | } label="Time Series" /> 77 | 78 | 79 | 80 | 81 | 82 | 83 | Select Event Type 84 | { 89 | setEventType(event.target.value as any) 90 | }} 91 | > 92 | {Object.keys(EventType).map((key) => { 93 | const value = key as EventType 94 | 95 | return ( 96 | } label={value} /> 97 | ) 98 | })} 99 | 100 | 101 | 102 | 103 | 104 | 105 | Select Symbol Name 106 | { 111 | setSymbolName(event.target.value as any) 112 | }} 113 | > 114 | {SYMBOLS.map((value) => ( 115 | } label={value} /> 116 | ))} 117 | 118 | 119 | 120 | 121 | 122 | Example 123 | 124 | Example demonstrates how to work with it in React 125 | 126 | 127 | 0 137 | this.disconnect = () => 0 138 | } 139 | }, 140 | Button, 141 | DataViewer, 142 | }} 143 | language="js" 144 | code={`() => { 145 | const [play, setPlay] = React.useState(false) 146 | const [forceUpdate, setForceUpdate] = React.useState(0) 147 | const [events, setEvents] = React.useState([]) 148 | const handleEvent = React.useCallback((event) => { 149 | setEvents((prevState) => [...prevState, event]) 150 | }, []) 151 | 152 | const feed = React.useMemo(() => new Feed(), []) 153 | React.useEffect(() => { 154 | feed.connect('wss://demo.dxfeed.com/webservice/cometd') 155 | return () => feed.disconnect() 156 | }, []) 157 | 158 | React.useEffect(() => { 159 | let unsubscribe 160 | if (play) { 161 | setEvents([]) 162 | unsubscribe = feed.${ 163 | type === 'series' 164 | ? `subscribe(['${eventType}'], ['${symbolName}'], handleEvent)` 165 | : `subscribeTimeSeries(['${eventType}'], ['${symbolName}'], 0, handleEvent)` 166 | } 167 | } 168 | return () => unsubscribe && unsubscribe() 169 | }, [play, forceUpdate]) 170 | 171 | return ( 172 | <> 173 | 176 | {play && ( 177 | 180 | )} 181 | 182 | 183 | ) 184 | }`} 185 | /> 186 | 187 | ) 188 | } 189 | 190 | export default Playground 191 | -------------------------------------------------------------------------------- /docs/src/gatsby-theme-docz/wrapper.tsx: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | /* tslint:disable:file-name-casing */ 9 | import * as React from 'react' 10 | import { Helmet } from 'react-helmet-async' 11 | 12 | import { createMuiTheme, ThemeProvider } from '@material-ui/core/styles' 13 | import { useColorMode } from 'theme-ui' 14 | 15 | // The doc prop contains some metadata about the page being rendered that you can use. 16 | const Wrapper: React.FunctionComponent = ({ children }) => { 17 | const [colorMode] = useColorMode() 18 | const theme = React.useMemo( 19 | () => 20 | createMuiTheme({ 21 | palette: { 22 | type: colorMode as 'light' | 'dark', 23 | }, 24 | }), 25 | [colorMode] 26 | ) 27 | 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | {children} 35 | 36 | ) 37 | } 38 | 39 | export default Wrapper 40 | -------------------------------------------------------------------------------- /docs/src/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Getting Started 3 | route: / 4 | --- 5 | 6 | # Getting Started 7 | 8 | This package provides access to [dxFeed](https://www.dxfeed.com/) streaming data. 9 | 10 | Our package is easy to integrate with any modern framework. 11 | 12 | On this website, you will find usage examples. 13 | 14 | ## Install 15 | Install package from NPM 16 | 17 | ```sh 18 | npm install @dxfeed/api 19 | ``` 20 | -------------------------------------------------------------------------------- /docs/src/promise-playground.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Promise 3 | route: /playground/promise 4 | menu: Playground 5 | --- 6 | 7 | # Promise 8 | 9 | import Playground from './PromisePlayground' 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/src/snapshot-playground.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Snapshot 3 | route: /playground/snapshot 4 | menu: Playground 5 | --- 6 | 7 | # Snapshot 8 | 9 | import Playground from './SnapshotPlayground' 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/src/subscription-playground.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Subscription 3 | route: /playground/subscription 4 | menu: Playground 5 | --- 6 | 7 | # Subscription Playground 8 | 9 | import Playground from './SubscriptionPlayground' 10 | 11 | 12 | -------------------------------------------------------------------------------- /docs/src/usage.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | name: Basic Usage 3 | route: /basic-usage 4 | --- 5 | 6 | # NodeJS usage 7 | 8 | Install `cometd-nodejs-client` package 9 | 10 | ```sh 11 | npm install cometd-nodejs-client 12 | ``` 13 | 14 | and use it in your code 15 | 16 | ```js 17 | require('cometd-nodejs-client').adapt() 18 | // or 19 | import * as CometdNodejsClient from 'cometd-nodejs-client' 20 | CometdNodejsClient.adapt() 21 | ``` 22 | 23 | # Basic Usage 24 | 25 | We have several classes in implementation: 26 | 27 | - Feed **_(public)_** 28 | - Endpoint **_(private)_** 29 | - Subscriptions **_(private)_** 30 | 31 | The _Feed_ is entry point for configuration and creating subscriptions. 32 | _Feed_ manages private classes for connecting and subscribing. 33 | The _Endpoint_ is responsible for managing the web socket connection. 34 | _Subscriptions_ for managing open subscriptions. 35 | 36 | ## Import package 37 | 38 | ```ts 39 | import Feed from '@dxfeed/api' 40 | ``` 41 | 42 | ## Configure & Create connection 43 | 44 | Create instance of Feed. 45 | 46 | ```ts 47 | const feed = new Feed() 48 | ``` 49 | 50 | Provide auth token if needed. 51 | 52 | ```ts 53 | feed.setAuthToken('authToken') 54 | ``` 55 | 56 | Set web socket address and open connection. 57 | 58 | ```ts 59 | feed.connect('wss://demo.dxfeed.com/webservice/cometd') 60 | ``` 61 | 62 | ## Configure & Create subscription 63 | 64 | You should specify event types and symbol names. 65 | 66 | ```ts 67 | feed.subscribe<{ value: number }>( 68 | [EventType.Summary, EventType.Trade] /* event types */, 69 | ['AEX.IND:TEI'] /* symbols */, 70 | handleEvent 71 | ) 72 | ``` 73 | 74 | For timed subscription you should also provide time to start subscription from. 75 | 76 | For Candle event type along with base symbol, you should specify an aggregation period. You can also set price type. More details: https://kb.dxfeed.com/display/DS/REST+API#RESTAPI-Candlesymbols. 77 | 78 | ```ts 79 | feed.subscribeTimeSeries<{ value: number }>( 80 | [EventType.Summary, EventType.Trade] /* event types */, 81 | ['AEX.IND:TEI'] /* symbols */, 82 | 0 /* fromTime */, 83 | handleEvent 84 | ) 85 | ``` 86 | 87 | Last argument its event handler for process incoming events. 88 | 89 | ```ts 90 | const handleEvent = (event) => { 91 | /* process event */ 92 | } 93 | ``` 94 | 95 | ## Close subscription 96 | 97 | All subscribe methods return unsubscribe handler, you need to call this method for unsubscribe. 98 | 99 | ```ts 100 | const unsubscribe = feed.subscribe(eventTypes, symbols, handleEvent) 101 | 102 | onExit(() => unsubscribe()) 103 | ``` 104 | 105 | ## Aggregated API 106 | 107 | ### Get TimeSeries 108 | 109 | If you want to get TimeSeries events for a given time period, refer to example below. 110 | 111 | ```ts 112 | // inside async function 113 | const events = await feed.getTimeSeries( 114 | 'AAPL{=15m}', 115 | EventType.Candle, 116 | fromDate.getTime(), 117 | toDate.getTime() 118 | ) 119 | ``` 120 | 121 | ### Subscribe TimeSeries snapshot 122 | 123 | If you want to subscribe to TimeSeries events, refer to example below. 124 | 125 | ```ts 126 | const unsubscribe = feed.subscribeTimeSeriesSnapshot('AAPL{=15m}', EventType.Candle, (candles) => { 127 | // process candles 128 | chart.setCandles(candles) 129 | }) 130 | ``` 131 | 132 | ## Close connection 133 | 134 | If you need to close the web socket connection 135 | 136 | ```ts 137 | feed.disconnect() 138 | ``` 139 | -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "moduleResolution": "node", 5 | "strict": true, 6 | "resolveJsonModule": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": false, 9 | "noEmit": true, 10 | "jsx": "react" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /examples/nodejs-candles.cjs: -------------------------------------------------------------------------------- 1 | /** 2 | * This example demonstrates how to subscribe to a snapshot of time series in Node.js. 3 | */ 4 | const { Feed, EventType } = require('@dxfeed/api') 5 | 6 | // We are using `cometd` library to connect to the feed, so we need to adapt it to work in Node.js. 7 | require('cometd-nodejs-client').adapt() 8 | 9 | const feed = new Feed() 10 | // Connect to the demo dxFeed feed 11 | // Demo feed is a limited version of dxFeed Feed API, which provides delayed market data 12 | feed.connect('wss://demo.dxfeed.com/webservice/cometd') 13 | 14 | // Subscribe to a snapshot Candles 15 | feed.subscribeTimeSeriesSnapshot( 16 | 'EUR/USD:AFX{=d,mm=CFH2,price=mark}', // subscription symbol 17 | EventType.Candle, // subscription event type 18 | new Date('2023-07-25T14:00:00').getTime(), // subscription fromTime 19 | (snapshot) => { 20 | console.log('Candles', snapshot) 21 | // process snapshot, for example chart.setCandles(snapshot) 22 | } 23 | ) 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dxfeed/api", 3 | "version": "1.6.0", 4 | "description": "This package provides access to dxFeed streaming data", 5 | "type": "module", 6 | "main": "lib/index.cjs", 7 | "module": "lib/index.mjs", 8 | "types": "lib/index.d.ts", 9 | "repository": "https://github.com/dxFeed/dxfeed-js-api.git", 10 | "author": "Dmitry Petrov ", 11 | "license": "MPL-2.0", 12 | "scripts": { 13 | "prebuild": "rimraf lib", 14 | "build": "rollup -c", 15 | "docs": "typedoc src/index.ts --out examples/public/docs", 16 | "format": "prettier --write \"src/**/*.ts\"", 17 | "lint": "tslint -p tsconfig.json", 18 | "test": "jest src" 19 | }, 20 | "dependencies": { 21 | "@types/cometd": "^4.0.7", 22 | "cometd": "^5.0.1" 23 | }, 24 | "devDependencies": { 25 | "@types/jest": "^26.0.5", 26 | "esbuild": "^0.18.4", 27 | "jest": "^26.1.0", 28 | "prettier": "^2.0.5", 29 | "rimraf": "^3.0.2", 30 | "rollup": "^3.25.1", 31 | "rollup-plugin-dts": "^5.3.0", 32 | "rollup-plugin-esbuild": "^5.0.0", 33 | "ts-jest": "^26.1.3", 34 | "tslint": "^6.1.1", 35 | "tslint-config-prettier": "^1.18.0", 36 | "typescript": "^4.9.5" 37 | }, 38 | "jest": { 39 | "preset": "ts-jest" 40 | }, 41 | "prettier": { 42 | "trailingComma": "es5", 43 | "tabWidth": 2, 44 | "semi": false, 45 | "singleQuote": true, 46 | "printWidth": 100 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import dts from 'rollup-plugin-dts' 2 | import esbuild from 'rollup-plugin-esbuild' 3 | 4 | const name = 'lib/index' 5 | 6 | const bundle = config => ({ 7 | ...config, 8 | input: 'src/index.ts', 9 | external: id => !/^[./]/.test(id), 10 | }) 11 | 12 | export default [ 13 | bundle({ 14 | plugins: [esbuild()], 15 | output: [ 16 | { 17 | file: `${name}.cjs`, 18 | format: 'cjs', 19 | sourcemap: true, 20 | }, 21 | { 22 | file: `${name}.mjs`, 23 | format: 'es', 24 | sourcemap: true, 25 | }, 26 | ], 27 | }), 28 | bundle({ 29 | plugins: [dts()], 30 | output: { 31 | file: `${name}.d.ts`, 32 | format: 'es', 33 | }, 34 | }), 35 | ] -------------------------------------------------------------------------------- /src/__mocks__/endpoint.ts: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | /* tslint:disable:no-empty */ 9 | 10 | type StateChangeHandler = (state: any) => void 11 | type DataChangeHandler = (message: any, state: boolean) => void 12 | 13 | export class Endpoint { 14 | handlers: { 15 | onStateChange?: StateChangeHandler 16 | onData?: DataChangeHandler 17 | } = {} 18 | mock = true 19 | 20 | constructor() {} 21 | 22 | registerStateChangeHandler = jest.fn((onStateChange: StateChangeHandler) => { 23 | this.handlers.onStateChange = onStateChange 24 | }) 25 | 26 | registerDataChangeHandler = jest.fn((onData: DataChangeHandler) => { 27 | this.handlers.onData = onData 28 | }) 29 | 30 | connected = jest.fn(() => true) 31 | 32 | setAuthToken = jest.fn() // (_token: string) {} 33 | 34 | disconnect = jest.fn() 35 | 36 | updateSubscriptions = jest.fn() // (message: ISubscribeMessage) {} 37 | } 38 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | export const HEADER_AUTH_TOKEN_KEY = 'com.devexperts.auth.AuthToken' 9 | -------------------------------------------------------------------------------- /src/endpoint.ts: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { CometD, Configuration, Message } from 'cometd' 9 | 10 | import { HEADER_AUTH_TOKEN_KEY } from './config' 11 | import { IFeedImplState, IncomingData, ISubscribeMessage } from './interfaces' 12 | 13 | type StateChangeHandler = (state: Partial) => void 14 | 15 | type DataChangeHandler = (message: IncomingData, timeSeries: boolean) => void 16 | 17 | export class Endpoint { 18 | cometd: CometD | null = null 19 | authToken: string | null = null 20 | connectionAlive: boolean = false 21 | 22 | handlers: { 23 | onStateChange?: StateChangeHandler 24 | onData?: DataChangeHandler 25 | } = {} 26 | 27 | registerStateChangeHandler = (onStateChange: StateChangeHandler) => { 28 | this.handlers.onStateChange = onStateChange 29 | } 30 | 31 | registerDataChangeHandler = (onData: DataChangeHandler) => { 32 | this.handlers.onData = onData 33 | } 34 | 35 | private updateConnectedState(connected: boolean) { 36 | if (this.connectionAlive !== connected) { 37 | this.connectionAlive = connected 38 | this.handlers?.onStateChange({ connected }) 39 | } 40 | } 41 | 42 | private onMetaUpdate = (message: Message) => { 43 | if (!this.isConnected()) { 44 | return this.updateConnectedState(false) 45 | } 46 | 47 | this.updateConnectedState(message.successful) 48 | } 49 | 50 | private onMetaUnsuccessful = () => { 51 | this.updateConnectedState(false) 52 | } 53 | 54 | private onServiceState = (message: Message) => { 55 | this.handlers?.onStateChange(message.data) 56 | } 57 | 58 | private onServiceData = (message: Message) => { 59 | this.handlers?.onData(message.data, false) 60 | } 61 | 62 | private onServiceTimeSeriesData = (message: Message) => { 63 | this.handlers?.onData(message.data, true) 64 | } 65 | 66 | isConnected = () => this.cometd !== null && !this.cometd.isDisconnected() 67 | 68 | connect(config: Configuration) { 69 | const cometd = this.cometd || this.createCometD() 70 | 71 | cometd.configure(config) 72 | 73 | const authToken = this.authToken 74 | if (authToken === null) { 75 | // @ts-ignore cometd types differs from code, need research 76 | cometd.handshake() 77 | } else { 78 | // @ts-ignore cometd types differs from code, need research 79 | cometd.handshake({ ext: { [HEADER_AUTH_TOKEN_KEY]: authToken } }) 80 | } 81 | } 82 | 83 | private createCometD = () => { 84 | const cometd = new CometD() 85 | 86 | cometd.addListener('/meta/handshake', this.onMetaUpdate) 87 | cometd.addListener('/meta/connect', this.onMetaUpdate) 88 | cometd.addListener('/meta/unsuccessful', this.onMetaUnsuccessful) 89 | cometd.addListener('/service/state', this.onServiceState) 90 | cometd.addListener('/service/data', this.onServiceData) 91 | cometd.addListener('/service/timeSeriesData', this.onServiceTimeSeriesData) 92 | 93 | this.cometd = cometd 94 | return cometd 95 | } 96 | 97 | setAuthToken(token: string) { 98 | this.authToken = token 99 | } 100 | 101 | disconnect() { 102 | if (this.cometd !== null) { 103 | // @ts-ignore cometd types differs from code, need research 104 | this.cometd.disconnect(true) 105 | this.cometd = null 106 | } 107 | } 108 | 109 | updateSubscriptions(message: ISubscribeMessage) { 110 | return this.publish('sub', message) 111 | } 112 | 113 | private publish(service: 'sub', message: object) { 114 | if (!this.cometd) { 115 | throw new Error('CometD not connected') 116 | } 117 | 118 | return this.cometd.publish('/service/' + service, message) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/eventFlags.ts: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | export enum EventFlag { 9 | /** 10 | * (0x01) TX_PENDING indicates a pending transactional update. When TX_PENDING is 1, it means that an ongoing transaction 11 | * update, that spans multiple events, is in process. 12 | */ 13 | TxPending = 0x01, 14 | 15 | /** 16 | * (0x02) REMOVE_EVENT indicates that the event with the corresponding index has to be removed. 17 | */ 18 | RemoveEvent = 0x02, 19 | 20 | /** 21 | * (0x04) SNAPSHOT_BEGIN indicates when the loading of a snapshot starts. Snapshot load starts on new subscription and 22 | * the first indexed event that arrives for each exchange code (in the case of a regional record) on a new 23 | * subscription may have SNAPSHOT_BEGIN set to true. It means that an ongoing snapshot consisting of multiple 24 | * events is incoming. 25 | */ 26 | SnapshotBegin = 0x04, 27 | 28 | /** 29 | * (0x08) SNAPSHOT_END or (0x10) SNAPSHOT_SNIP indicates the end of a snapshot. The difference between SNAPSHOT_END and 30 | * SNAPSHOT_SNIP is the following: SNAPSHOT_END indicates that the data source sent all the data pertaining to 31 | * the subscription for the corresponding indexed event, while SNAPSHOT_SNIP indicates that some limit on the 32 | * amount of data was reached and while there still might be more data available, it will not be provided. 33 | */ 34 | SnapshotEnd = 0x08, 35 | 36 | /** 37 | * (0x08) SNAPSHOT_END or (0x10) SNAPSHOT_SNIP indicates the end of a snapshot. The difference between SNAPSHOT_END and 38 | * SNAPSHOT_SNIP is the following: SNAPSHOT_END indicates that the data source sent all the data pertaining to 39 | * the subscription for the corresponding indexed event, while SNAPSHOT_SNIP indicates that some limit on the 40 | * amount of data was reached and while there still might be more data available, it will not be provided. 41 | */ 42 | SnapshotSnip = 0x10, 43 | } 44 | 45 | export interface EventFlags { 46 | txPending: boolean 47 | shouldBeRemoved: boolean 48 | snapshotBegin: boolean 49 | snapshotEnd: boolean 50 | snapshotSnip: boolean 51 | } 52 | 53 | /* tslint:disable:no-bitwise */ 54 | export const parseEventFlags = (flags: number): EventFlags => ({ 55 | txPending: (flags & EventFlag.TxPending) > 0, 56 | shouldBeRemoved: (flags & EventFlag.RemoveEvent) > 0, 57 | snapshotBegin: (flags & EventFlag.SnapshotBegin) > 0, 58 | snapshotEnd: (flags & EventFlag.SnapshotEnd) > 0, 59 | snapshotSnip: (flags & EventFlag.SnapshotSnip) > 0, 60 | }) 61 | -------------------------------------------------------------------------------- /src/feed.test.ts: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | jest.mock('./endpoint') 9 | 10 | import { EventFlag } from './eventFlags' 11 | import { Feed, AbortedError, TimeoutError } from './feed' 12 | import { EventType, IncomingData } from './interfaces' 13 | 14 | describe('Feed', () => { 15 | let instance: Feed 16 | 17 | beforeEach(() => { 18 | jest.useFakeTimers() 19 | instance = new Feed() 20 | }) 21 | 22 | it('mock should work', () => { 23 | expect((instance.endpoint as any).mock).toBeTruthy() 24 | }) 25 | 26 | it('should trigger series sub event on data', () => { 27 | const eventType = EventType.Summary 28 | const eventSymbol = 'AEX.IND:TEI' 29 | 30 | let dataResults 31 | const handleEvent = jest.fn((data) => { 32 | dataResults = data 33 | }) 34 | 35 | instance.subscribe([eventType], [eventSymbol], handleEvent) 36 | 37 | instance.endpoint.handlers?.onData?.( 38 | [ 39 | [eventType, ['eventSymbol', 'eventTime']], 40 | [eventSymbol, 0], 41 | ], 42 | false 43 | ) 44 | 45 | jest.runAllTimers() 46 | 47 | expect(handleEvent).toBeCalled() 48 | 49 | expect(dataResults).toEqual({ 50 | eventSymbol, 51 | eventTime: 0, 52 | eventType, 53 | }) 54 | }) 55 | 56 | it('should trigger time series sub event on data', () => { 57 | const eventType = EventType.Candle 58 | const eventSymbol = 'AEX.IND:TEI{=15m}' 59 | 60 | let dataResults 61 | const handleEvent = jest.fn((data) => { 62 | dataResults = data 63 | }) 64 | 65 | instance.subscribeTimeSeries([eventType], [eventSymbol], 0, handleEvent) 66 | 67 | instance.endpoint.handlers?.onData?.( 68 | [ 69 | [eventType, ['eventSymbol', 'eventTime', 'eventFlags', 'index', 'time']], 70 | [ 71 | ...[eventSymbol, 0, 0, 6829250544717005000, 1590058800000], 72 | ...[eventSymbol, 0, 0, 6829246679246438000, 1590057900000], 73 | ], 74 | ], 75 | true 76 | ) 77 | 78 | jest.runAllTimers() 79 | 80 | expect(handleEvent).toBeCalled() 81 | expect(dataResults).toBeDefined() 82 | 83 | expect(dataResults).toHaveProperty('eventType', eventType) 84 | expect(dataResults).toHaveProperty('eventSymbol', eventSymbol) 85 | }) 86 | 87 | // TODO: Нужен тест проверяющий разные типы обработки Data, когда уже есть структура в памяти для какого-то эвента 88 | }) 89 | 90 | describe('Feed - subscriptions', () => { 91 | let instance: Feed 92 | 93 | const eventType = EventType.Summary 94 | const symbolsSet1 = ['1', '2', '3', '4'] 95 | const symbolsSet2 = ['2', '3'] 96 | 97 | const createSubscription = (symbols: string[]) => 98 | instance.subscribe([eventType], symbols, () => 0) 99 | 100 | const createMultipleSubscriptions = (symbols: string[]) => 101 | symbols.map((symbol) => instance.subscribe([eventType], [symbol], () => 0)) 102 | 103 | beforeEach(() => { 104 | jest.useFakeTimers() 105 | instance = new Feed() 106 | }) 107 | 108 | it('should unsubscribe crossing subscriptions correctly', () => { 109 | const publishFirstTime = jest.fn() 110 | instance.endpoint.updateSubscriptions = publishFirstTime 111 | 112 | const unsubscribe1 = createSubscription(symbolsSet1) 113 | 114 | const unsubscribe2 = createSubscription(symbolsSet2) 115 | 116 | jest.runAllTimers() 117 | 118 | const publishSecondTime = jest.fn() 119 | instance.endpoint.updateSubscriptions = publishSecondTime 120 | 121 | unsubscribe2() 122 | 123 | jest.runAllTimers() 124 | 125 | const publishThirdTime = jest.fn() 126 | instance.endpoint.updateSubscriptions = publishThirdTime 127 | 128 | unsubscribe1() 129 | 130 | jest.runAllTimers() 131 | 132 | expect(publishFirstTime).toBeCalledTimes(1) 133 | expect(publishFirstTime).toBeCalledWith({ add: { [eventType]: symbolsSet1 } }) 134 | 135 | expect(publishSecondTime).toBeCalledTimes(0) 136 | 137 | expect(publishThirdTime).toBeCalledTimes(1) 138 | expect(publishThirdTime).toBeCalledWith({ 139 | remove: { [eventType]: symbolsSet1 }, 140 | }) 141 | }) 142 | 143 | it('should remove and add correctly in 1 tick', () => { 144 | const publishFirstTime = jest.fn() 145 | instance.endpoint.updateSubscriptions = publishFirstTime 146 | 147 | const unsubscribeSet1 = createMultipleSubscriptions(symbolsSet1) 148 | 149 | jest.runAllTimers() 150 | 151 | const publishSecondTime = jest.fn() 152 | instance.endpoint.updateSubscriptions = publishSecondTime 153 | 154 | unsubscribeSet1.forEach((unsubscribe) => { 155 | unsubscribe() 156 | }) 157 | 158 | createMultipleSubscriptions(symbolsSet2) 159 | 160 | jest.runAllTimers() 161 | 162 | expect(publishFirstTime).toBeCalledTimes(1) 163 | expect(publishFirstTime).toBeCalledWith({ add: { [eventType]: symbolsSet1 } }) 164 | 165 | expect(publishSecondTime).toBeCalledTimes(1) 166 | expect(publishSecondTime).toBeCalledWith({ 167 | add: { [eventType]: symbolsSet2 }, 168 | remove: { [eventType]: symbolsSet1 }, 169 | }) 170 | }) 171 | 172 | it('should remove and add correctly in different ticks', () => { 173 | const publishFirstTime = jest.fn() 174 | instance.endpoint.updateSubscriptions = publishFirstTime 175 | 176 | const unsubscribeSet1 = createMultipleSubscriptions(symbolsSet1) 177 | 178 | jest.runAllTimers() 179 | 180 | const publishSecondTime = jest.fn() 181 | instance.endpoint.updateSubscriptions = publishSecondTime 182 | 183 | unsubscribeSet1.forEach((unsubscribe) => { 184 | unsubscribe() 185 | }) 186 | 187 | jest.runAllTimers() 188 | 189 | const publishThirdTime = jest.fn() 190 | instance.endpoint.updateSubscriptions = publishThirdTime 191 | 192 | createMultipleSubscriptions(symbolsSet2) 193 | 194 | jest.runAllTimers() 195 | 196 | expect(publishFirstTime).toBeCalledTimes(1) 197 | expect(publishFirstTime).toBeCalledWith({ add: { [eventType]: symbolsSet1 } }) 198 | 199 | expect(publishSecondTime).toBeCalledTimes(1) 200 | expect(publishSecondTime).toBeCalledWith({ 201 | remove: { [eventType]: symbolsSet1 }, 202 | }) 203 | 204 | expect(publishThirdTime).toBeCalledTimes(1) 205 | expect(publishThirdTime).toBeCalledWith({ 206 | add: { [eventType]: symbolsSet2 }, 207 | }) 208 | }) 209 | 210 | it('should send subscription immediately if queue is full', () => { 211 | const publishFirstTime = jest.fn() 212 | instance.endpoint.updateSubscriptions = publishFirstTime 213 | 214 | const longSymbolSet = new Array(400).fill(0).map((_, idx) => idx.toString()) 215 | const unsubscribe = createSubscription(longSymbolSet) 216 | 217 | jest.runAllTimers() 218 | 219 | const publishSecondTime = jest.fn() 220 | instance.endpoint.updateSubscriptions = publishSecondTime 221 | 222 | unsubscribe() 223 | 224 | jest.runAllTimers() 225 | 226 | expect(publishFirstTime).toBeCalledTimes(2) 227 | 228 | expect(publishSecondTime).toBeCalledTimes(2) 229 | }) 230 | }) 231 | 232 | describe('Feed - subscriptions time series', () => { 233 | let instance: Feed 234 | 235 | const eventType = EventType.Candle 236 | const symbolsSet1 = ['1', '2', '3', '4'] 237 | const symbolsSet2 = ['2', '3'] 238 | 239 | const createSubscriptionTimeSeries = (symbols: string[], fromTime: number) => 240 | instance.subscribeTimeSeries([eventType], symbols, fromTime, () => 0) 241 | 242 | beforeEach(() => { 243 | jest.useFakeTimers() 244 | instance = new Feed() 245 | }) 246 | 247 | it('should unsubscribe crossing subscriptions correctly', () => { 248 | const publishFirstTime = jest.fn() 249 | instance.endpoint.updateSubscriptions = publishFirstTime 250 | 251 | const fromTime1 = 1000 252 | const fromTime2 = 10 253 | 254 | const unsubscribe1 = createSubscriptionTimeSeries(symbolsSet1, fromTime1) 255 | 256 | const unsubscribe2 = createSubscriptionTimeSeries(symbolsSet2, fromTime2) 257 | 258 | jest.runAllTimers() 259 | 260 | const publishSecondTime = jest.fn() 261 | instance.endpoint.updateSubscriptions = publishSecondTime 262 | 263 | unsubscribe2() 264 | 265 | jest.runAllTimers() 266 | 267 | const publishThirdTime = jest.fn() 268 | instance.endpoint.updateSubscriptions = publishThirdTime 269 | 270 | unsubscribe1() 271 | 272 | jest.runAllTimers() 273 | 274 | expect(publishFirstTime).toBeCalledTimes(1) 275 | expect(publishFirstTime).toBeCalledWith({ 276 | addTimeSeries: { 277 | [eventType]: [ 278 | { eventSymbol: '1', fromTime: fromTime1 }, 279 | { eventSymbol: '2', fromTime: fromTime2 }, 280 | { eventSymbol: '3', fromTime: fromTime2 }, 281 | { eventSymbol: '4', fromTime: fromTime1 }, 282 | ], 283 | }, 284 | }) 285 | 286 | expect(publishSecondTime).toBeCalledTimes(1) 287 | expect(publishSecondTime).toBeCalledWith({ 288 | addTimeSeries: { 289 | [eventType]: [ 290 | { eventSymbol: '2', fromTime: fromTime1 }, 291 | { eventSymbol: '3', fromTime: fromTime1 }, 292 | ], 293 | }, 294 | }) 295 | 296 | expect(publishThirdTime).toBeCalledTimes(1) 297 | expect(publishThirdTime).toBeCalledWith({ 298 | removeTimeSeries: { [eventType]: symbolsSet1 }, 299 | }) 300 | }) 301 | 302 | it('should send subscription immediately if queue is full', () => { 303 | const publishFirstTime = jest.fn() 304 | instance.endpoint.updateSubscriptions = publishFirstTime 305 | 306 | const longSymbolSet = new Array(400).fill(0).map((_, idx) => idx.toString()) 307 | const unsubscribe = createSubscriptionTimeSeries(longSymbolSet, 0) 308 | 309 | jest.runAllTimers() 310 | 311 | const publishSecondTime = jest.fn() 312 | instance.endpoint.updateSubscriptions = publishSecondTime 313 | 314 | unsubscribe() 315 | 316 | jest.runAllTimers() 317 | 318 | expect(publishFirstTime).toBeCalledTimes(2) 319 | 320 | expect(publishSecondTime).toBeCalledTimes(2) 321 | }) 322 | }) 323 | 324 | describe('Feed - promises time series', () => { 325 | let instance: Feed 326 | 327 | beforeEach(() => { 328 | jest.useFakeTimers() 329 | instance = new Feed() 330 | }) 331 | 332 | const newMockDataHead = (eventType: EventType): [EventType, string[]] => [ 333 | eventType, 334 | ['eventSymbol', 'eventTime', 'eventFlags', 'index', 'time'], 335 | ] 336 | const newMockDataBody = ( 337 | eventSymbol: string, 338 | time: number, 339 | eventFlags: number, 340 | index: number 341 | ): (string | number)[] => [eventSymbol, 0, eventFlags, index, time] 342 | 343 | describe('aggregation logic', () => { 344 | const ONE_DAY = 1000 * 60 * 60 * 24 345 | const TO_TIME = new Date().getTime() 346 | const FROM_TIME = TO_TIME - 7 * ONE_DAY 347 | 348 | const SYMBOL = 'AAPL' 349 | const EVENT_TYPE = EventType.Candle 350 | 351 | const pushData = (data: IncomingData) => { 352 | instance.endpoint.handlers?.onData?.(data, true) 353 | } 354 | 355 | test('should finish aggergation on snip flag', async () => { 356 | const promise = instance.getTimeSeries(SYMBOL, EVENT_TYPE, FROM_TIME, TO_TIME) 357 | 358 | pushData([ 359 | newMockDataHead(EVENT_TYPE), 360 | [ 361 | ...newMockDataBody(SYMBOL, TO_TIME - ONE_DAY, 0, 0), 362 | ...newMockDataBody(SYMBOL, TO_TIME - ONE_DAY * 2, EventFlag.SnapshotSnip, 1), 363 | ], 364 | ]) 365 | 366 | const events = await promise 367 | expect(events).toStrictEqual([ 368 | expect.objectContaining({ time: TO_TIME - ONE_DAY, index: 0 }), 369 | expect.objectContaining({ time: TO_TIME - ONE_DAY * 2, index: 1 }), 370 | ]) 371 | }) 372 | 373 | test('should finish aggergation after getting message with time === from', async () => { 374 | const promise = instance.getTimeSeries(SYMBOL, EVENT_TYPE, FROM_TIME, TO_TIME) 375 | 376 | pushData([ 377 | newMockDataHead(EVENT_TYPE), 378 | [ 379 | ...newMockDataBody(SYMBOL, TO_TIME - ONE_DAY, 0, 0), 380 | ...newMockDataBody(SYMBOL, FROM_TIME, 0, 1), 381 | ], 382 | ]) 383 | 384 | const events = await promise 385 | expect(events).toStrictEqual([ 386 | expect.objectContaining({ time: TO_TIME - ONE_DAY, index: 0 }), 387 | expect.objectContaining({ time: FROM_TIME, index: 1 }), 388 | ]) 389 | }) 390 | 391 | test('should return data sorted by index', async () => { 392 | const promise = instance.getTimeSeries(SYMBOL, EVENT_TYPE, FROM_TIME, TO_TIME) 393 | 394 | pushData([ 395 | newMockDataHead(EVENT_TYPE), 396 | [ 397 | ...newMockDataBody(SYMBOL, TO_TIME - ONE_DAY, 0, 2), 398 | ...newMockDataBody(SYMBOL, TO_TIME - ONE_DAY, 0, 1), 399 | ...newMockDataBody(SYMBOL, TO_TIME - 1, 0, 50), 400 | ...newMockDataBody(SYMBOL, TO_TIME - 2, 0, 10), 401 | ...newMockDataBody(SYMBOL, TO_TIME - 3, EventFlag.SnapshotSnip, 100), 402 | ], 403 | ]) 404 | 405 | const events = await promise 406 | expect(events).toStrictEqual([ 407 | expect.objectContaining({ time: TO_TIME - ONE_DAY, index: 1 }), 408 | expect.objectContaining({ time: TO_TIME - ONE_DAY, index: 2 }), 409 | expect.objectContaining({ time: TO_TIME - 2, index: 10 }), 410 | expect.objectContaining({ time: TO_TIME - 1, index: 50 }), 411 | expect.objectContaining({ time: TO_TIME - 3, index: 100 }), 412 | ]) 413 | }) 414 | 415 | test('aggregation should work until pending flag is false', async () => { 416 | const promise = instance.getTimeSeries(SYMBOL, EVENT_TYPE, FROM_TIME, TO_TIME) 417 | 418 | pushData([ 419 | newMockDataHead(EVENT_TYPE), 420 | [ 421 | ...newMockDataBody(SYMBOL, TO_TIME - ONE_DAY, 0, 0), 422 | ...newMockDataBody(SYMBOL, FROM_TIME, EventFlag.TxPending, 1), 423 | ...newMockDataBody(SYMBOL, TO_TIME - 1, EventFlag.TxPending, 2), 424 | ...newMockDataBody(SYMBOL, FROM_TIME, 0, 3), 425 | ], 426 | ]) 427 | 428 | const events = await promise 429 | expect(events).toStrictEqual([ 430 | expect.objectContaining({ time: TO_TIME - ONE_DAY, index: 0 }), 431 | expect.objectContaining({ time: FROM_TIME, index: 1 }), 432 | expect.objectContaining({ time: TO_TIME - 1, index: 2 }), 433 | expect.objectContaining({ time: FROM_TIME, index: 3 }), 434 | ]) 435 | }) 436 | 437 | test('events with delete flag should be removed from event list', async () => { 438 | const promise = instance.getTimeSeries(SYMBOL, EVENT_TYPE, FROM_TIME, TO_TIME) 439 | 440 | pushData([ 441 | newMockDataHead(EVENT_TYPE), 442 | [ 443 | ...newMockDataBody(SYMBOL, TO_TIME - ONE_DAY, 0, 0), 444 | ...newMockDataBody(SYMBOL, TO_TIME - ONE_DAY, EventFlag.RemoveEvent, 0), 445 | ...newMockDataBody(SYMBOL, TO_TIME - 2 * ONE_DAY, EventFlag.SnapshotSnip, 1), 446 | ], 447 | ]) 448 | 449 | const events = await promise 450 | expect(events).toStrictEqual([ 451 | expect.objectContaining({ time: TO_TIME - 2 * ONE_DAY, index: 1 }), 452 | ]) 453 | }) 454 | 455 | test('messages out of time range should be ignored', async () => { 456 | const promise = instance.getTimeSeries(SYMBOL, EVENT_TYPE, FROM_TIME, TO_TIME) 457 | 458 | pushData([ 459 | newMockDataHead(EVENT_TYPE), 460 | [ 461 | ...newMockDataBody(SYMBOL, TO_TIME - ONE_DAY, 0, 0), 462 | ...newMockDataBody(SYMBOL, FROM_TIME - 1, 0, 1), 463 | ...newMockDataBody(SYMBOL, FROM_TIME - 2, 0, 2), 464 | ...newMockDataBody(SYMBOL, TO_TIME - ONE_DAY - 1, 0, 3), 465 | ...newMockDataBody(SYMBOL, TO_TIME - ONE_DAY - 2, EventFlag.SnapshotSnip, 4), 466 | ], 467 | ]) 468 | 469 | const events = await promise 470 | expect(events).toStrictEqual([ 471 | expect.objectContaining({ time: TO_TIME - ONE_DAY, index: 0 }), 472 | expect.objectContaining({ time: TO_TIME - ONE_DAY - 1, index: 3 }), 473 | expect.objectContaining({ time: TO_TIME - ONE_DAY - 2, index: 4 }), 474 | ]) 475 | }) 476 | }) 477 | 478 | describe('cleanup', () => { 479 | const setupExpectCleanup = () => { 480 | instance.endpoint.updateSubscriptions = jest.fn() 481 | 482 | return () => { 483 | expect(instance.endpoint.updateSubscriptions).toHaveBeenCalledWith( 484 | expect.objectContaining({ 485 | removeTimeSeries: { 486 | [EventType.Candle]: ['AAPL'], 487 | }, 488 | }) 489 | ) 490 | } 491 | } 492 | 493 | test('cleanup on abort should work', () => { 494 | const expectCleanup = setupExpectCleanup() 495 | 496 | const abortController = new AbortController() 497 | expect( 498 | instance.getTimeSeries( 499 | 'AAPL', 500 | EventType.Candle, 501 | new Date().getTime(), 502 | new Date().getTime(), 503 | { 504 | signal: abortController.signal, 505 | } 506 | ) 507 | ).rejects.toThrowError(AbortedError) 508 | 509 | abortController.abort() 510 | // let time to send unsub message 511 | jest.advanceTimersByTime(100) 512 | 513 | expectCleanup() 514 | }) 515 | 516 | test('cleanup on timeout should work', () => { 517 | const expectCleanup = setupExpectCleanup() 518 | 519 | expect( 520 | instance.getTimeSeries('AAPL', EventType.Candle, new Date().getTime(), new Date().getTime()) 521 | ).rejects.toThrowError(TimeoutError) 522 | 523 | jest.advanceTimersByTime(16_000) 524 | 525 | expectCleanup() 526 | }) 527 | 528 | test('cleanup on aggergation finish should work', () => { 529 | const expectCleanup = setupExpectCleanup() 530 | 531 | expect( 532 | instance.getTimeSeries('AAPL', EventType.Candle, new Date().getTime(), new Date().getTime()) 533 | ).resolves.not.toThrow() 534 | instance.endpoint.handlers?.onData?.( 535 | [ 536 | newMockDataHead(EventType.Candle), 537 | newMockDataBody('AAPL', new Date().getTime(), EventFlag.SnapshotSnip, 0), 538 | ], 539 | true 540 | ) 541 | 542 | // let aggregation finish 543 | jest.advanceTimersByTime(1000) 544 | 545 | expectCleanup() 546 | }) 547 | }) 548 | }) 549 | -------------------------------------------------------------------------------- /src/feed.ts: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { Endpoint } from './endpoint' 9 | import { parseEventFlags } from './eventFlags' 10 | import { EventType, IEvent, ITimeSeriesEvent } from './interfaces' 11 | import { Subscriptions } from './subscriptions' 12 | import { 13 | isFinishedTimeSeriesAggregationResult, 14 | newTimeSeriesAggregator, 15 | } from './timeSeriesAggregator' 16 | import { newPromiseWithResource } from './utils' 17 | 18 | /* tslint:disable:max-classes-per-file */ 19 | export class TimeoutError extends Error {} 20 | export class AbortedError extends Error {} 21 | 22 | export class Feed { 23 | endpoint: Endpoint 24 | subscriptions: Subscriptions 25 | 26 | constructor() { 27 | this.endpoint = new Endpoint() 28 | this.subscriptions = new Subscriptions(this.endpoint) 29 | } 30 | 31 | setAuthToken = (token: string) => { 32 | this.endpoint.setAuthToken(token) 33 | } 34 | 35 | connect = (url: string) => { 36 | this.endpoint.connect({ url }) 37 | } 38 | 39 | disconnect = () => { 40 | this.endpoint.disconnect() 41 | } 42 | 43 | subscribe = ( 44 | eventTypes: EventType[], 45 | eventSymbols: string[], 46 | onChange: (event: TEvent) => void 47 | ) => this.subscriptions.subscribe(eventTypes, eventSymbols, onChange) 48 | 49 | subscribeTimeSeries = ( 50 | eventTypes: EventType[], 51 | eventSymbols: string[], 52 | fromTime: number, 53 | onChange: (event: TEvent) => void 54 | ) => this.subscriptions.subscribeTimeSeries(eventTypes, eventSymbols, fromTime, onChange) 55 | 56 | /** 57 | * requires that incoming events have index, time and eventFlags to work correctly 58 | * 59 | * (!) expected that this method is not used alongside Feed.subscribeTimeSeries, it may not work correctly 60 | * 61 | * @param fromTime - A Number representing the milliseconds elapsed since the UNIX epoch 62 | * @param toTime - A Number representing the milliseconds elapsed since the UNIX epoch 63 | * @param options - default options has 15 seconds timeout 64 | */ 65 | getTimeSeries = ( 66 | eventSymbol: string, 67 | eventType: EventType, 68 | fromTime: number, 69 | toTime: number, 70 | options?: { 71 | signal?: AbortController['signal'] 72 | timeoutMs?: number 73 | } 74 | ): Promise => 75 | newPromiseWithResource((resolve, reject, useResource) => { 76 | useResource(() => { 77 | const timeoutId = setTimeout(() => { 78 | reject(new TimeoutError()) 79 | }, options?.timeoutMs ?? 15_000) 80 | return () => clearTimeout(timeoutId) 81 | }) 82 | 83 | useResource(() => { 84 | const abortSignalListener = () => reject(new AbortedError()) 85 | options?.signal?.addEventListener('abort', abortSignalListener) 86 | 87 | return () => options?.signal?.removeEventListener('abort', abortSignalListener) 88 | }) 89 | 90 | useResource(() => { 91 | const aggregator = newTimeSeriesAggregator(fromTime, toTime) 92 | 93 | const handleEvent = (event: TEvent) => { 94 | const result = aggregator.newEvent(event) 95 | 96 | if (isFinishedTimeSeriesAggregationResult(result)) resolve(result.events) 97 | } 98 | 99 | const unsubscribeTimeSeries = this.subscriptions.subscribeTimeSeries( 100 | [eventType], 101 | [eventSymbol], 102 | fromTime, 103 | handleEvent 104 | ) 105 | 106 | return () => unsubscribeTimeSeries() 107 | }) 108 | }) 109 | 110 | /** 111 | * requires that incoming events have index, time and eventFlags to work correctly 112 | * 113 | * (!) expected that this method is not used alongside Feed.subscribeTimeSeries, it may not work correctly 114 | * 115 | * @param eventSymbol - A String representing the symbol of the event 116 | * @param eventType - A String representing the type of the event 117 | * @param fromTime - A Number representing the milliseconds elapsed since the UNIX epoch 118 | * @param onChange - A Function called when the snapshot is received 119 | * @returns - A Function that unsubscribes from the snapshot updates 120 | */ 121 | subscribeTimeSeriesSnapshot = ( 122 | eventSymbol: string, 123 | eventType: EventType, 124 | fromTime: number, 125 | onChange: (snapshot: IEvent[]) => void 126 | ) => { 127 | let snapshotPart = false // snapshot pending 128 | let snapshotFull = false // snapshot received in pending queue 129 | let tx = false // transaction pending 130 | 131 | let pQueue: IEvent[] = [] // pending queue 132 | 133 | let events: Record = {} // events accumulator 134 | 135 | return this.subscribeTimeSeries([eventType], [eventSymbol], fromTime, (event) => { 136 | const flags = parseEventFlags(event.eventFlags) 137 | 138 | tx = flags.txPending 139 | 140 | // Process snapshot start and clear params 141 | if (flags.snapshotBegin) { 142 | pQueue = [] // clear pending queue on new snapshot 143 | snapshotPart = true // snapshot pending 144 | snapshotFull = false // snapshot not received yet 145 | } 146 | 147 | // Process snapshot end after snapshot begin was received 148 | if (snapshotPart && (flags.snapshotEnd || flags.snapshotSnip)) { 149 | snapshotPart = false 150 | snapshotFull = true 151 | } 152 | 153 | pQueue.push(event) 154 | 155 | if (snapshotPart || tx) { 156 | return 157 | } 158 | 159 | if (snapshotFull) { 160 | snapshotFull = false 161 | events = {} // remove any unprocessed leftovers on new snapshot 162 | } 163 | 164 | // process pending queue 165 | let hasChanged = false 166 | for (const event of pQueue) { 167 | const { shouldBeRemoved } = parseEventFlags(event.eventFlags) 168 | 169 | if (shouldBeRemoved) { 170 | if (events[event.index] === undefined) { 171 | // nothing to do on remove on non-existing event 172 | continue 173 | } 174 | 175 | // remove existing event 176 | delete events[event.index] 177 | } else { 178 | // cleanup the flags in the stored event 179 | event.eventFlags = 0 180 | events[event.index] = event 181 | } 182 | 183 | hasChanged = true 184 | } 185 | pQueue = [] 186 | 187 | if (hasChanged) { 188 | // notify about changes 189 | onChange(Object.values(events).sort((a, b) => a.index - b.index)) 190 | } 191 | }) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { Feed } from './feed' 9 | 10 | export * from './interfaces' 11 | export * from './eventFlags' 12 | 13 | export { Feed } 14 | export default Feed 15 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | export enum EventType { 9 | Quote = 'Quote', 10 | Candle = 'Candle', 11 | Trade = 'Trade', 12 | TradeETH = 'TradeETH', 13 | Summary = 'Summary', 14 | Profile = 'Profile', 15 | Greeks = 'Greeks', 16 | TheoPrice = 'TheoPrice', 17 | TimeAndSale = 'TimeAndSale', 18 | Underlying = 'Underlying', 19 | Order = 'Order', 20 | Configuration = 'Configuration', 21 | } 22 | 23 | export interface IEvent { 24 | eventType: EventType 25 | eventSymbol: string 26 | [key: string]: string | number | boolean 27 | } 28 | 29 | export interface ITimeSeriesEvent extends IEvent { 30 | index: number 31 | time: number 32 | eventFlags: number 33 | } 34 | 35 | export type CometdSeries = { 36 | [eventType in EventType]?: { 37 | [eventSymbol: string]: T 38 | } 39 | } 40 | 41 | export type ISubscriptionList = { 42 | [eventType in EventType]?: string[] 43 | } 44 | 45 | export type ITimeSeriesList = { 46 | [eventType in EventType]?: { fromTime: number; eventSymbol: string }[] 47 | } 48 | 49 | export type IncomingData = [ 50 | EventType | [EventType, string[]], // head 51 | (string | number)[] // body 52 | ] 53 | 54 | export interface ITotalSubItem { 55 | listeners: ((event: IEvent) => void)[] 56 | } 57 | 58 | export interface ITotalTimeSeriesSubItem { 59 | listeners: ((event: IEvent) => void)[] 60 | fromTime: number 61 | fromTimes: number[] 62 | } 63 | 64 | export interface IFeedImplState { 65 | connected: boolean 66 | replaySupported?: boolean 67 | replay: boolean 68 | clear: boolean 69 | time: number 70 | speed: number 71 | [key: string]: IFeedImplState[keyof IFeedImplState] 72 | } 73 | 74 | export interface ISubscribeMessage { 75 | reset?: boolean 76 | 77 | add?: ISubscriptionList 78 | remove?: ISubscriptionList 79 | addTimeSeries?: ITimeSeriesList 80 | removeTimeSeries?: ISubscriptionList 81 | } 82 | -------------------------------------------------------------------------------- /src/subscriptions.ts: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { Endpoint } from './endpoint' 9 | import { 10 | CometdSeries, 11 | EventType, 12 | IEvent, 13 | IFeedImplState, 14 | IncomingData, 15 | ISubscribeMessage, 16 | ITimeSeriesEvent, 17 | ITotalSubItem, 18 | ITotalTimeSeriesSubItem, 19 | } from './interfaces' 20 | import { 21 | isEmptySet, 22 | splitChunks, 23 | subMapToSetOfLists, 24 | timeSeriesSubMapToSetOfLists, 25 | toBooleanCometdSub, 26 | } from './utils' 27 | 28 | interface ITimeSeriesOptions { 29 | fromTime: number 30 | } 31 | 32 | type Queue = { 33 | reset: boolean 34 | 35 | count: number 36 | 37 | add: CometdSeries 38 | remove: CometdSeries 39 | 40 | addTimeSeries: CometdSeries 41 | removeTimeSeries: CometdSeries 42 | } 43 | 44 | const createDefaultQueue = (): Queue => ({ 45 | reset: false, 46 | 47 | count: 0, 48 | 49 | add: {}, 50 | remove: {}, 51 | 52 | addTimeSeries: {}, 53 | removeTimeSeries: {}, 54 | }) 55 | 56 | const MAX_QUEUE_SIZE = 200 57 | 58 | export class Subscriptions { 59 | endpoint: Endpoint 60 | sendSubTimeout: number | null = null 61 | 62 | schemeTypes: { 63 | [key: string]: string[] 64 | } = {} 65 | 66 | state: IFeedImplState = { 67 | connected: false, 68 | replaySupported: undefined, // will determined after connection 69 | replay: false, 70 | clear: false, 71 | time: 0, 72 | speed: 0, 73 | } 74 | 75 | subscriptions: CometdSeries = {} 76 | timeSeriesSubscriptions: CometdSeries = {} 77 | 78 | queue: Queue = createDefaultQueue() 79 | 80 | constructor(endpoint: Endpoint) { 81 | this.endpoint = endpoint 82 | 83 | endpoint.registerStateChangeHandler(this.onStateChange) 84 | endpoint.registerDataChangeHandler(this.onData) 85 | } 86 | 87 | sendSub = () => { 88 | const message: ISubscribeMessage = {} 89 | 90 | const queue = this.queue 91 | this.queue = createDefaultQueue() 92 | 93 | if (queue.reset) { 94 | message.reset = true 95 | queue.add = toBooleanCometdSub(this.subscriptions) 96 | queue.remove = {} 97 | queue.addTimeSeries = this.timeSeriesSubscriptions 98 | queue.removeTimeSeries = {} 99 | queue.reset = false 100 | } 101 | 102 | if (!isEmptySet(queue.add)) { 103 | message.add = subMapToSetOfLists(queue.add) 104 | } 105 | if (!isEmptySet(queue.remove)) { 106 | message.remove = subMapToSetOfLists(queue.remove) 107 | } 108 | if (!isEmptySet(queue.addTimeSeries)) { 109 | message.addTimeSeries = timeSeriesSubMapToSetOfLists(queue.addTimeSeries) 110 | } 111 | if (!isEmptySet(queue.removeTimeSeries)) { 112 | message.removeTimeSeries = subMapToSetOfLists(queue.removeTimeSeries) 113 | } 114 | if (!isEmptySet(message)) { 115 | this.endpoint.updateSubscriptions(message) 116 | } 117 | } 118 | 119 | scheduleSendSub() { 120 | // if queue is full, send it immediately 121 | if (this.queue.count > MAX_QUEUE_SIZE) { 122 | // Clear scheduled sendSub 123 | if (this.sendSubTimeout !== null) { 124 | clearTimeout(this.sendSubTimeout) 125 | this.sendSubTimeout = null 126 | } 127 | return this.sendSub() 128 | } 129 | 130 | if (this.sendSubTimeout === null) { 131 | this.sendSubTimeout = setTimeout(() => { 132 | this.sendSubTimeout = null 133 | this.sendSub() 134 | }, 0) 135 | } 136 | } 137 | 138 | subscribe( 139 | eventTypes: EventType[], 140 | eventSymbols: string[], 141 | onChange: (event: TEvent) => void 142 | ) { 143 | eventTypes.forEach((eventType) => { 144 | if (!this.subscriptions[eventType]) { 145 | this.subscriptions[eventType] = {} 146 | } 147 | 148 | eventSymbols.forEach((eventSymbol) => { 149 | if (!this.subscriptions[eventType][eventSymbol]) { 150 | this.subscriptions[eventType][eventSymbol] = { 151 | listeners: [], 152 | } 153 | } 154 | 155 | // Add listeners 156 | this.subscriptions[eventType][eventSymbol].listeners.push(onChange /* FIXME */ as any) 157 | 158 | /** 159 | * When backend receives message with `add` for the first time for symbol subscription, it immediately 160 | * pushes last ticker's value to a subscriber. The same is relevant for situation when backend receives 161 | * message with `remove`, and then, some ticks later, it receives another message with `add`. 162 | * 163 | * If `add` and `remove` are sent in one message, backend treats them in the following order: first remove 164 | * subscription, then add it back and push last value of ticker into it. 165 | * 166 | * When code below is not commented out, it deletes `remove` from message in situation 167 | * when `add` and `remove` occur in one tick. This makes backend treat message as "update subscription", 168 | * which effectively means "do nothing". New subscriber won't receive last value of ticker because subscription 169 | * already exists, and will only receive the next ticker update. 170 | * 171 | * This sometimes leads to cases when ticker appears empty for new subscribers of a rarely updated symbols. 172 | * (Related issue: EN-4718) 173 | */ 174 | 175 | // if (this.queue.remove[eventType]) { 176 | // delete this.queue.remove[eventType][eventSymbol] 177 | // } 178 | 179 | this.queueAction('add', eventType, eventSymbol) 180 | }) 181 | }) 182 | 183 | // Return unsubscribe handler 184 | return () => { 185 | eventTypes.forEach((eventType) => { 186 | eventSymbols.forEach((eventSymbol) => { 187 | const subscription = this.subscriptions[eventType][eventSymbol] 188 | 189 | const newListeners = subscription.listeners.filter((listener) => listener !== onChange) 190 | if (newListeners.length === 0) { 191 | delete this.subscriptions[eventType][eventSymbol] 192 | 193 | // Remove from add queue 194 | if (this.queue.add[eventType]) { 195 | delete this.queue.add[eventType][eventSymbol] 196 | } 197 | 198 | this.queueAction('remove', eventType, eventSymbol) 199 | } else { 200 | subscription.listeners = newListeners 201 | } 202 | }) 203 | }) 204 | } 205 | } 206 | 207 | subscribeTimeSeries( 208 | eventTypes: EventType[], 209 | eventSymbols: string[], 210 | fromTime: number, 211 | onChange: (event: TEvent) => void 212 | ) { 213 | const handleEvent = (event: TEvent) => { 214 | if (event.time >= fromTime) { 215 | onChange(event) 216 | } 217 | } 218 | 219 | eventTypes.forEach((eventType) => { 220 | if (!this.timeSeriesSubscriptions[eventType]) { 221 | this.timeSeriesSubscriptions[eventType] = {} 222 | } 223 | 224 | eventSymbols.forEach((eventSymbol) => { 225 | if (!this.timeSeriesSubscriptions[eventType][eventSymbol]) { 226 | this.timeSeriesSubscriptions[eventType][eventSymbol] = { 227 | listeners: [], 228 | fromTime: Number.MAX_SAFE_INTEGER, 229 | fromTimes: [], 230 | } 231 | } 232 | 233 | // Add listeners 234 | const subscription = this.timeSeriesSubscriptions[eventType][eventSymbol] 235 | subscription.listeners.push(handleEvent /* FIXME */ as any) 236 | subscription.fromTimes.push(fromTime) 237 | 238 | // Delete from remove queue 239 | if (this.queue.removeTimeSeries[eventType]) { 240 | delete this.queue.removeTimeSeries[eventType][eventSymbol] 241 | } 242 | 243 | /* 244 | * Cases when incoming subscription timestamp is the same must trigger subscription too 245 | * (e.g. two simultaneous subscriptions coming from different clients) 246 | */ 247 | if (fromTime <= subscription.fromTime) { 248 | subscription.fromTime = fromTime 249 | 250 | this.queueTimeSeriesAction('add', eventType, eventSymbol, { 251 | fromTime, 252 | }) 253 | } 254 | }) 255 | }) 256 | 257 | // Return unsubscribe handler 258 | return () => { 259 | eventTypes.forEach((eventType) => { 260 | eventSymbols.forEach((eventSymbol) => { 261 | const subscription = this.timeSeriesSubscriptions[eventType][eventSymbol] 262 | 263 | // Remove time from list 264 | subscription.fromTimes.splice(subscription.fromTimes.indexOf(fromTime), 1) 265 | 266 | const newListeners = subscription.listeners.filter((listener) => listener !== handleEvent) 267 | if (newListeners.length === 0) { 268 | delete this.timeSeriesSubscriptions[eventType][eventSymbol] 269 | 270 | // Remove from add queue 271 | if (this.queue.addTimeSeries[eventType]) { 272 | delete this.queue.addTimeSeries[eventType][eventSymbol] 273 | } 274 | 275 | this.queueTimeSeriesAction('remove', eventType, eventSymbol) 276 | } else { 277 | subscription.listeners = newListeners 278 | 279 | const newFromTime = subscription.fromTimes.reduce( 280 | (result, time) => (fromTime < result ? time : result), 281 | Number.POSITIVE_INFINITY 282 | ) 283 | if (subscription.fromTime !== newFromTime) { 284 | subscription.fromTime = newFromTime 285 | 286 | this.queueTimeSeriesAction('add', eventType, eventSymbol, { 287 | fromTime: newFromTime, 288 | }) 289 | } 290 | } 291 | }) 292 | }) 293 | } 294 | } 295 | 296 | private onStateChange = (stateChange: Partial) => { 297 | if (stateChange.connected) { 298 | this.queue.reset = true 299 | this.scheduleSendSub() 300 | } 301 | 302 | Object.entries(stateChange).forEach(([key, val]) => { 303 | this.state[key] = val 304 | }) 305 | } 306 | 307 | private queueAction = (action: 'add' | 'remove', eventType: EventType, eventSymbol: string) => { 308 | this.queue[action] = { 309 | ...this.queue[action], 310 | [eventType]: { 311 | ...this.queue[action][eventType], 312 | [eventSymbol]: true, 313 | }, 314 | } 315 | 316 | this.queue.count++ 317 | 318 | this.scheduleSendSub() 319 | } 320 | 321 | private queueTimeSeriesAction = ( 322 | action: 'add' | 'remove', 323 | eventType: EventType, 324 | eventSymbol: string, 325 | options?: ITimeSeriesOptions 326 | ) => { 327 | if (action === 'add') { 328 | this.queue.addTimeSeries = { 329 | ...this.queue.addTimeSeries, 330 | [eventType]: { 331 | ...this.queue.addTimeSeries[eventType], 332 | [eventSymbol]: options, 333 | }, 334 | } 335 | } else { 336 | this.queue.removeTimeSeries = { 337 | ...this.queue.removeTimeSeries, 338 | [eventType]: { 339 | ...this.queue.removeTimeSeries[eventType], 340 | [eventSymbol]: true, 341 | }, 342 | } 343 | } 344 | 345 | this.queue.count++ 346 | 347 | this.scheduleSendSub() 348 | } 349 | 350 | private onData = ([headData, bodyData]: IncomingData, timeSeries: boolean) => { 351 | const subscriptions = timeSeries ? this.timeSeriesSubscriptions : this.subscriptions 352 | 353 | let eventType: EventType 354 | let scheme: string[] 355 | 356 | if (typeof headData === 'string') { 357 | eventType = headData 358 | scheme = this.schemeTypes[eventType] 359 | } else { 360 | ;[eventType, scheme] = headData 361 | 362 | this.schemeTypes[eventType] = scheme 363 | } 364 | 365 | const subscription = subscriptions[eventType] 366 | if (!subscription) { 367 | return 368 | } 369 | 370 | const eventsValues = splitChunks(bodyData, scheme.length) 371 | eventsValues.forEach((values) => { 372 | const event: IEvent = { 373 | eventType, 374 | eventSymbol: '', 375 | } 376 | 377 | scheme.forEach((eventPropertyName, eventPropertyIndex) => { 378 | event[eventPropertyName] = values[eventPropertyIndex] 379 | }) 380 | 381 | subscription[event.eventSymbol]?.listeners.forEach((listener) => listener(event)) 382 | }) 383 | } 384 | } 385 | -------------------------------------------------------------------------------- /src/timeSeriesAggregator.ts: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { parseEventFlags } from './eventFlags' 9 | import { ITimeSeriesEvent } from './interfaces' 10 | 11 | // AggregationEvent 12 | type AggregationEvent = TEvent & { 13 | time: number 14 | /** 15 | * unique event identifier 16 | */ 17 | index: number 18 | eventFlags: number 19 | } 20 | 21 | const isAggregationEvent = ( 22 | event: TEvent 23 | ): event is AggregationEvent => 24 | typeof event.time === 'number' && 25 | typeof event.index === 'number' && 26 | typeof event.eventFlags === 'number' 27 | 28 | // TimeSeriesAggregationResult 29 | export interface ContinueTimeSeriesAggregationResult { 30 | kind: 'Continue' 31 | } 32 | 33 | export interface FinishedTimeSeriesAggregationResult { 34 | kind: 'Finished' 35 | events: TEvent[] 36 | } 37 | 38 | export type TimeSeriesAggregationResult = 39 | | ContinueTimeSeriesAggregationResult 40 | | FinishedTimeSeriesAggregationResult 41 | 42 | export const isFinishedTimeSeriesAggregationResult = ( 43 | result: TimeSeriesAggregationResult 44 | ): result is FinishedTimeSeriesAggregationResult => result.kind === 'Finished' 45 | 46 | // TimeSeriesAggregator 47 | export interface TimeSeriesAggregator { 48 | newEvent: (event: TEvent) => TimeSeriesAggregationResult 49 | } 50 | 51 | export const newTimeSeriesAggregator = ( 52 | fromTime: number, 53 | toTime: number 54 | ): TimeSeriesAggregator => { 55 | let complete = false 56 | let txPending = false 57 | const events: Record> = {} 58 | 59 | const isDone = () => complete && !txPending 60 | 61 | const updateEvents = (event: AggregationEvent, remove: boolean) => { 62 | if (remove) { 63 | delete events[event.index] 64 | } else { 65 | events[event.index] = event 66 | } 67 | } 68 | 69 | const processEvent = (event: TEvent) => { 70 | if (!isAggregationEvent(event)) { 71 | return 72 | } 73 | 74 | const time = event.time 75 | const flags = parseEventFlags(event.eventFlags) 76 | txPending = flags.txPending 77 | 78 | if (time >= fromTime && time <= toTime) { 79 | const remove = flags.shouldBeRemoved 80 | event.eventFlags = 0 81 | updateEvents(event, remove) 82 | } 83 | if (time <= fromTime || flags.snapshotSnip) complete = true 84 | } 85 | 86 | const newEvent = (event: TEvent): TimeSeriesAggregationResult => { 87 | processEvent(event) 88 | return isDone() 89 | ? { kind: 'Finished', events: Object.values(events).sort((a, b) => a.index - b.index) } 90 | : { kind: 'Continue' } 91 | } 92 | 93 | return { 94 | newEvent, 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | /** @license 2 | * Copyright ©2020 Devexperts LLC. All rights reserved. 3 | * 4 | * This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. 5 | * If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/. 6 | */ 7 | 8 | import { CometdSeries, EventType, ISubscriptionList } from './interfaces' 9 | 10 | export function subMapToSetOfLists(sub: CometdSeries): ISubscriptionList { 11 | // sub : (type :-> symbol :-> subItem), returns (type :-> [symbol]) 12 | return Object.entries(sub).reduce((acc, value) => { 13 | acc[value[0] as EventType] = Object.keys(value[1]) 14 | return acc 15 | }, {} as ISubscriptionList) 16 | } 17 | 18 | export const toBooleanCometdSub = (sub: CometdSeries) => 19 | Object.keys(sub).reduce((acc, key) => { 20 | const eventType = key as EventType 21 | acc[eventType] = Object.keys(sub[eventType]).reduce((innerAcc, innerKey) => { 22 | innerAcc[innerKey] = Boolean(sub[eventType][innerKey]) 23 | return innerAcc 24 | }, {} as Record) 25 | return acc 26 | }, {} as CometdSeries) 27 | 28 | function timeSeriesSubSetToList(obj: Record) { 29 | return Object.keys(obj).map((key) => ({ 30 | eventSymbol: key, 31 | fromTime: obj[key].fromTime, 32 | })) 33 | } 34 | 35 | export function timeSeriesSubMapToSetOfLists(sub: CometdSeries<{ fromTime: number }>) { 36 | return Object.entries(sub).reduce((acc, value) => { 37 | acc[value[0]] = timeSeriesSubSetToList(value[1]) 38 | return acc 39 | }, {} as Record) 40 | } 41 | 42 | export const isEmptySet = (obj: object) => Object.keys(obj).length === 0 43 | 44 | export const splitChunks = (values: Value[], chunkSize: number) => { 45 | const results: Value[][] = [] 46 | 47 | for (let offset = 0; offset < values.length; offset += chunkSize) { 48 | results.push(values.slice(offset, offset + chunkSize)) 49 | } 50 | 51 | return results 52 | } 53 | 54 | type Cleanup = () => void 55 | /** 56 | * regular promise creator but with the ability to handle code that requires cleanup in a simpler way 57 | * 58 | * @example 59 | * // setup promise that will be rejected after 5 seconds 60 | * newPromiseWithResource((resolve, reject, useResource) => { 61 | * useResource(() => { 62 | * const id = setTimeout(() => reject('Timeout'), 5_000) 63 | * return () => clearTimeout(id) 64 | * }) 65 | * // additional body steps, like http requests 66 | * }) 67 | */ 68 | export const newPromiseWithResource = ( 69 | executor: ( 70 | resolve: (value: T) => void, 71 | reject: (reason: unknown) => void, 72 | useResource: (resourceFn: () => Cleanup) => void 73 | ) => void 74 | ): Promise => { 75 | const cleanups: Cleanup[] = [] 76 | 77 | const cleanupAll = () => { 78 | cleanups.forEach((cleanup) => cleanup()) 79 | } 80 | 81 | return new Promise((resolve, reject) => { 82 | const useResource = (resourceFn: () => Cleanup) => { 83 | const cleanup = resourceFn() 84 | cleanups.push(cleanup) 85 | } 86 | 87 | const resolveWithCleanup = (value: T) => { 88 | cleanupAll() 89 | resolve(value) 90 | } 91 | const rejectWithCleanup = (reason: unknown) => { 92 | cleanupAll() 93 | reject(reason) 94 | } 95 | 96 | executor(resolveWithCleanup, rejectWithCleanup, useResource) 97 | }) 98 | } 99 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "allowUnusedLabels": true, 5 | "baseUrl": ".", 6 | "experimentalDecorators": true, 7 | "esModuleInterop": true, 8 | "declaration": true, 9 | "lib": ["dom", "es2015", "esnext"], 10 | "module": "commonjs", 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "types": ["jest"], 14 | "removeComments": false, 15 | "inlineSourceMap": true, 16 | "strict": true, 17 | "strictNullChecks": false, 18 | "skipLibCheck": true, 19 | "target": "es2017" 20 | }, 21 | "include": ["src"], 22 | "exclude": ["node_modules", "*/__mocks__", "*/**/*.test.ts"] 23 | } 24 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"], 3 | "jsRules": { 4 | "object-literal-sort-keys": [false] 5 | }, 6 | "rules": { 7 | "ordered-imports": [ 8 | true, 9 | { 10 | "import-sources-order": "lowercase-first", 11 | "module-source-path": "full", 12 | "grouped-imports": true, 13 | "groups": [ 14 | { 15 | "match": "^react", 16 | "order": 1 17 | }, 18 | { 19 | "match": "^@/(components|containers)", 20 | "order": 31 21 | }, 22 | { 23 | "match": "^@/", 24 | "order": 20 25 | }, 26 | { 27 | "name": "Parent dir", 28 | "match": "^[.][.]", 29 | "order": 100 30 | }, 31 | { 32 | "name": "Current dir", 33 | "match": "^[.]", 34 | "order": 110 35 | }, 36 | { 37 | "match": "^[^\\.]", 38 | "order": 10 39 | } 40 | ] 41 | } 42 | ], 43 | "object-literal-sort-keys": [false], 44 | "arrow-return-shorthand": [true, "multiline"], 45 | "variable-name": { 46 | "options": ["ban-keywords", "check-format", "allow-pascal-case", "allow-leading-underscore"] 47 | }, 48 | "no-duplicate-imports": [true], 49 | "no-return-await": true, 50 | "jsx-boolean-value": [false], 51 | "jsx-no-lambda": [false], 52 | "no-default-export": [false], 53 | "member-access": [true, "no-public"], 54 | "file-name-casing": [ 55 | true, 56 | { 57 | "index.tsx": "camel-case", 58 | "stories.tsx": "camel-case", 59 | ".tsx": "pascal-case", 60 | ".ts": "camel-case" 61 | } 62 | ] 63 | } 64 | } 65 | --------------------------------------------------------------------------------