├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md └── python ├── .gitignore ├── DisableThrottling.reg ├── README.md ├── TDAmeritrade_RTD.md ├── demo ├── Finnhub_rtd_demo.xlsx ├── Stockrow_rtd_demo.xlsx ├── TD_rtd_demo.xlsx ├── stockrow_rtd_demo1.gif ├── stockrow_rtd_demo2.gif └── td_rtd_demo.gif ├── excel_rtd ├── __init__.py └── excel_rtd_server.py ├── finnhub_rtd.py ├── finnhubapi ├── __init__.py └── finnhub_ws_conn.py ├── requirements.txt ├── stockrow_rtd.py ├── td_rtd.py └── tdapi ├── LICENSE ├── __init__.py ├── fields.py ├── stream.json ├── td_client.py ├── td_config.py └── td_oauth.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # celery beat schedule file 95 | celerybeat-schedule 96 | 97 | # SageMath parsed files 98 | *.sage.py 99 | 100 | # Environments 101 | .env 102 | .venv 103 | env/ 104 | venv/ 105 | ENV/ 106 | env.bak/ 107 | venv.bak/ 108 | 109 | # Spyder project settings 110 | .spyderproject 111 | .spyproject 112 | 113 | # Rope project settings 114 | .ropeproject 115 | 116 | # mkdocs documentation 117 | /site 118 | 119 | # mypy 120 | .mypy_cache/ 121 | .dmypy.json 122 | dmypy.json 123 | 124 | # Pyre type checker 125 | .pyre/ 126 | 127 | .vscode/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 C.S. Lim 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExcelRTD 2 | 3 | ExcelRTD project aims provides a collection of samples implementing Excel RTD (RealTimeData) COM servers. 4 | Each sample can be useful by itself or will give some Excel RTD impementation ideas. 5 | 6 | > For introduction on Excel RTD (RealTimeData), visit [Intro RTD](https://docs.microsoft.com/en-us/office/troubleshoot/excel/set-up-realtimedata-function) and Kenny Kerr's Excel [RTD articles](https://kennykerr.ca/articles/) 7 | 8 | ## Python implementations 9 | 10 | * Go to [Python RTD Readme](https://github.com/chaelim/ExcelRTD/blob/master/python/README.md) 11 | 12 | -------------------------------------------------------------------------------- /python/.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | logs/ 3 | -------------------------------------------------------------------------------- /python/DisableThrottling.reg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaelim/ExcelRTD/2a8e5860d1f3f091261f08d49bc1f426038a9aa7/python/DisableThrottling.reg -------------------------------------------------------------------------------- /python/README.md: -------------------------------------------------------------------------------- 1 | # Prerequisites 2 | 3 | ## Excel version 4 | 5 | * __IMPORTANT__: Office 365 Version 1909 (16.0.12130.20XXX) or later is required. 6 | * Due to the issue described [here](https://mail.python.org/pipermail/python-win32/2012-April/012207.html), it won't work in older builds. 7 | 8 | ## Python version 3.x for Windows 9 | 10 | * Install it from https://www.python.org/downloads/windows 11 | * Recommend install 64 bit (x64) build 12 | 13 | # Setup 14 | 15 | ## Install pywin32 16 | 17 | * go to the [pywin32 release site](https://github.com/mhammond/pywin32/releases) 18 | * You should choose matching installation file with your Python version and bitness. 19 | * For Python 3.8 64bit, use `pywin32-228.win-amd64-py3.8.exe` 20 | * __IMPORTANT__: This folder `\Lib\site-packages\pywin32_system32` need to be added to the `PATH` environment variable. 21 | * Alternatively (This is what I'm doing), you can copy these two DLL files `pythoncom38.dll` and `pywintypes38.dll` under the `\Lib\site-packages\pywin32_system32` folder to `` folder assuming the Python installation path is already in the `PATH` environment variable. 22 | * If you didn't change Python installation folder from the Python Installer, default Python installation folder would be `C:\User\\AppData\Local\Programs\Python\Python38` or `%LOCALAPPDATA%\Programs\Python\Python38`. 23 | * If your Python application path was not added to the `PATH` environment variable at the Python installation time, you can follow instructions in [this web page](https://datatofish.com/add-python-to-windows-path/) to add Python application path to the `PATH` environment variable. 24 | 25 | ## Install required python packages 26 | 27 | * Run `pip install -r requirements.txt` or `py -m pip install -r requirements.txt` 28 | 29 | ## How to register Python RTD 30 | 31 | * To install Python Excel RTD COM server: 32 | * Type `py --register` 33 | * e.g. `py stockrow_rtd.py --register` 34 | 35 | ## Change `RTDThrottleInterval` to zero (strongly recommended) 36 | 37 | * By setting `RTDThrottleInterval` to zero, any update from the RTD COM server will be refreshed to Excel as quickly as possible. 38 | 39 | * Use one of following ways to change `RTDThrottleInterval` value to zero 40 | 1. Type this command line `reg add HKCU\SOFTWARE\Microsoft\Office\16.0\Excel\Options /v RTDThrottleInterval /t REG_DWORD /d 0 /f` 41 | 2. Run `DisableThrottling.reg` (You can double click this from the explorer) 42 | 43 | # Python RTD implementation 44 | 45 | ## TD Ameritrade RTD (`td_rtd.py`) 46 | 47 | > To get Level I realtime quote from the TD Ameritrade via websocket stereaming data, look at [TD Ameritrade RTD](https://github.com/chaelim/ExcelRTD/blob/master/python/TDAmeritrade_RTD.md) 48 | 49 | ## Finnhub RTD (`finnhub_rtd.py`) 50 | 51 | * Getting real time price update [Finnhub Stock API](https://finnhub.io/docs/api) via websocket. 52 | 53 | ## Stockrow RTD (`stockrow_rtd.py`) 54 | 55 | * Periodically query stock quotes to stockrow.com. 56 | 57 | ![](demo/stockrow_rtd_demo1.gif) 58 | -------------------------------------------------------------------------------- /python/TDAmeritrade_RTD.md: -------------------------------------------------------------------------------- 1 | # TD Ameritrade RTD 2 | 3 | ## Caveat 4 | 5 | > This project is work in progress and haven't been tested extensively. You may exprience various issues. Also the Python code quality is not where I want to be yet. 6 | 7 | ## Introduction 8 | 9 | * TD Ameritrade offers Level I and II live stream quotes free of charge if you have an account with them. (You can open an account without any deposit). See https://www.tdameritrade.com/api.page for more details. 10 | * This Excel TD RTD utilizes the Level I live stream quotes to update TD RTD stockquote function in Excel in realtime. 11 | 12 | ## How to install 13 | 14 | * First, please follow the instructions in the [README](https://github.com/chaelim/ExcelRTD/blob/master/python/README.md) 15 | * Run `py td_rtd.py --register` to register TD RTD. 16 | 17 | ## Features 18 | 19 | * OAuth login to your TD Ameritrade account via web browser and the refresh token will be saved for next login. 20 | * Refresh token is encrypted and stored under `%APPDATA%` folder. As long as your refresh token is valid, no OAuth login is necessary. 21 | * Currently supports all Level I live stream quotes via [TD Ameritrade Streaming Data](https://developer.tdameritrade.com/content/streaming-data). 22 | * [TD Level I Quote Document](https://developer.tdameritrade.com/content/streaming-data#_Toc504640597). 23 | * For full field list look for `level_one_quote` in [fields.py](https://github.com/chaelim/ExcelRTD/blob/master/python/tdapi/fields.py). 24 | * Unlike the `tos.rtd` provided by the ThinkorSwim platform from TD Ameritrade, you don't need to install and run the TOS software to use this TD RTD in Excel. 25 | 26 | ## Security Concerns 27 | 28 | * As mentioned above, the OAuth token is encrypted with your Windows account credential and stored under `%APPDATA%` folder. Unless somebody can log in or unlock the machine using your Windows account credential, it can't be easily decrypted. 29 | * TD RTD only sends quote requests to the TD Ameritrade server. 30 | * Never send any other information to any unknown servers. 31 | 32 | ## Demo 33 | 34 | * [TD RTD Demo workbook](https://github.com/chaelim/ExcelRTD/blob/master/python/demo/TD_rtd_demo.xlsx). 35 | 36 | ![](demo/td_rtd_demo.gif) 37 | -------------------------------------------------------------------------------- /python/demo/Finnhub_rtd_demo.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaelim/ExcelRTD/2a8e5860d1f3f091261f08d49bc1f426038a9aa7/python/demo/Finnhub_rtd_demo.xlsx -------------------------------------------------------------------------------- /python/demo/Stockrow_rtd_demo.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaelim/ExcelRTD/2a8e5860d1f3f091261f08d49bc1f426038a9aa7/python/demo/Stockrow_rtd_demo.xlsx -------------------------------------------------------------------------------- /python/demo/TD_rtd_demo.xlsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaelim/ExcelRTD/2a8e5860d1f3f091261f08d49bc1f426038a9aa7/python/demo/TD_rtd_demo.xlsx -------------------------------------------------------------------------------- /python/demo/stockrow_rtd_demo1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaelim/ExcelRTD/2a8e5860d1f3f091261f08d49bc1f426038a9aa7/python/demo/stockrow_rtd_demo1.gif -------------------------------------------------------------------------------- /python/demo/stockrow_rtd_demo2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaelim/ExcelRTD/2a8e5860d1f3f091261f08d49bc1f426038a9aa7/python/demo/stockrow_rtd_demo2.gif -------------------------------------------------------------------------------- /python/demo/td_rtd_demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chaelim/ExcelRTD/2a8e5860d1f3f091261f08d49bc1f426038a9aa7/python/demo/td_rtd_demo.gif -------------------------------------------------------------------------------- /python/excel_rtd/__init__.py: -------------------------------------------------------------------------------- 1 | from .excel_rtd_server import RTDServer 2 | from .excel_rtd_server import RTDTopic 3 | 4 | __all__ = [ 5 | 'RTDServer', 6 | 'RTDTopic' 7 | ] 8 | -------------------------------------------------------------------------------- /python/excel_rtd/excel_rtd_server.py: -------------------------------------------------------------------------------- 1 | # Originally from https://github.com/mhammond/pywin32/blob/master/com/win32com/demos/excelRTDServer.py and slightly modified. 2 | # - Added support for calling SignalExcel from a different python worker thread, not from the Excel's main thread. 3 | # - RefreshData in RTDServer only sends new values in "self.updatedTopics" list to Excel. 4 | 5 | """Excel IRTDServer implementation. 6 | 7 | This module is a functional example of how to implement the IRTDServer interface 8 | in python, using the pywin32 extensions. Further details, about this interface 9 | and it can be found at: 10 | http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dnexcl2k2/html/odc_xlrtdfaq.asp 11 | """ 12 | 13 | # Copyright (c) 2003-2004 by Chris Nilsson 14 | # 15 | # By obtaining, using, and/or copying this software and/or its 16 | # associated documentation, you agree that you have read, understood, 17 | # and will comply with the following terms and conditions: 18 | # 19 | # Permission to use, copy, modify, and distribute this software and 20 | # its associated documentation for any purpose and without fee is 21 | # hereby granted, provided that the above copyright notice appears in 22 | # all copies, and that both that copyright notice and this permission 23 | # notice appear in supporting documentation, and that the name of 24 | # Christopher Nilsson (the author) not be used in advertising or publicity 25 | # pertaining to distribution of the software without specific, written 26 | # prior permission. 27 | # 28 | # THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD 29 | # TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT- 30 | # ABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR 31 | # BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY 32 | # DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, 33 | # WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS 34 | # ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE 35 | # OF THIS SOFTWARE. 36 | 37 | import pythoncom 38 | import threading 39 | import win32api 40 | import win32com.client 41 | import logging 42 | from win32com import universal 43 | from win32com.client import gencache 44 | from win32com.server.exception import COMException 45 | 46 | # Typelib info for version 10 - aka Excel XP. 47 | # This is the minimum version of excel that we can work with as this is when 48 | # Microsoft introduced these interfaces. 49 | EXCEL_TLB_GUID = '{00020813-0000-0000-C000-000000000046}' # LIBID_Excel 50 | EXCEL_TLB_LCID = 0 51 | EXCEL_TLB_MAJOR = 1 52 | EXCEL_TLB_MINOR = 4 53 | 54 | IID_RTDUpdateEvent = '{A43788C1-D91B-11D3-8F39-00C04F3651B8}' 55 | 56 | # Import the excel typelib to make sure we've got early-binding going on. 57 | # The "ByRef" parameters we use later won't work without this. 58 | gencache.EnsureModule(EXCEL_TLB_GUID, EXCEL_TLB_LCID, \ 59 | EXCEL_TLB_MAJOR, EXCEL_TLB_MINOR) 60 | 61 | # Tell pywin to import these extra interfaces. 62 | # -- 63 | # QUESTION: Why? The interfaces seem to descend from IDispatch, so 64 | # I'd have thought, for example, calling callback.UpdateNotify() (on the 65 | # IRTDUpdateEvent callback excel gives us) would work without molestation. 66 | # But the callback needs to be cast to a "real" IRTDUpdateEvent type. Hmm... 67 | # This is where my small knowledge of the pywin framework / COM gets hazy. 68 | # -- 69 | # Again, we feed in the Excel typelib as the source of these interfaces. 70 | universal.RegisterInterfaces(EXCEL_TLB_GUID, 71 | EXCEL_TLB_LCID, EXCEL_TLB_MAJOR, EXCEL_TLB_MINOR, 72 | ['IRtdServer','IRTDUpdateEvent']) 73 | 74 | class RTDServer(object): 75 | """Base RTDServer class. 76 | 77 | Provides most of the features needed to implement the IRtdServer interface. 78 | Manages topic adding, removal, and packing up the values for excel. 79 | 80 | Shouldn't be instanciated directly. 81 | 82 | Instead, descendant classes should override the CreateTopic() method. 83 | Topic objects only need to provide a GetValue() function to play nice here. 84 | The values given need to be atomic (eg. string, int, float... etc). 85 | 86 | Also note: nothing has been done within this class to ensure that we get 87 | time to check our topics for updates. I've left that up to the subclass 88 | since the ways, and needs, of refreshing your topics will vary greatly. For 89 | example, the sample implementation uses a timer thread to wake itself up. 90 | Whichever way you choose to do it, your class needs to be able to wake up 91 | occasionally, since excel will never call your class without being asked to 92 | first. 93 | 94 | Excel will communicate with our object in this order: 95 | 1. Excel instantiates our object and calls ServerStart, providing us with 96 | an IRTDUpdateEvent callback object. 97 | 2. Excel calls ConnectData when it wants to subscribe to a new "topic". 98 | 3. When we have new data to provide, we call the UpdateNotify method of the 99 | callback object we were given. 100 | 4. Excel calls our RefreshData method, and receives a 2d SafeArray (row-major) 101 | containing the Topic ids in the 1st dim, and the topic values in the 102 | 2nd dim. 103 | 5. When not needed anymore, Excel will call our DisconnectData to 104 | unsubscribe from a topic. 105 | 6. When there are no more topics left, Excel will call our ServerTerminate 106 | method to kill us. 107 | 108 | Throughout, at undetermined periods, Excel will call our Heartbeat 109 | method to see if we're still alive. It must return a non-zero value, or 110 | we'll be killed. 111 | 112 | NOTE: By default, excel will at most call RefreshData once every 2 seconds. 113 | This is a setting that needs to be changed excel-side. To change this, 114 | you can set the throttle interval like this in the excel VBA object model: 115 | Application.RTD.ThrottleInterval = 1000 ' milliseconds 116 | """ 117 | _com_interfaces_ = ['IRtdServer'] 118 | _public_methods_ = ['ConnectData','DisconnectData','Heartbeat', 119 | 'RefreshData','ServerStart','ServerTerminate'] 120 | _reg_clsctx_ = pythoncom.CLSCTX_INPROC_SERVER 121 | #_reg_clsid_ = "# subclass must provide this class attribute" 122 | #_reg_desc_ = "# subclass should provide this description" 123 | #_reg_progid_ = "# subclass must provide this class attribute" 124 | 125 | ALIVE = 1 126 | NOT_ALIVE = 0 127 | 128 | def __init__(self): 129 | """Constructor""" 130 | super(RTDServer, self).__init__() 131 | self.IsAlive = self.ALIVE 132 | self.__marshall_callback = None 133 | self.__callback = None 134 | self.__excel_thread_id = None 135 | self.topics = {} 136 | self.updatedTopics = {} 137 | 138 | def SignalExcel(self): 139 | """Use the callback we were given to tell excel new data is available.""" 140 | if self.__callback is None: 141 | raise COMException(desc="Callback excel provided is Null") 142 | self.__callback.UpdateNotify() 143 | 144 | def ConnectData(self, TopicID, Strings, GetNewValues): 145 | """Creates a new topic out of the Strings excel gives us.""" 146 | try: 147 | self.topics[TopicID] = self.CreateTopic(TopicID, Strings) 148 | except Exception as e: 149 | raise COMException(desc=repr(e)) 150 | 151 | """ 152 | If this is called during the file load, GetNewValues will be False and doesn't need to send Excel new data. 153 | If we change "GetNewValues" to True on file load, we have to send back new data. If we don't send a new value, it will result in a #N/A. 154 | GetNewValues input value is False if the ConnectData is called while user is opening a file with existing RTD formulas in it. 155 | In other cases (e.g. user entered a new RTD formula) it will be True. 156 | """ 157 | if GetNewValues: 158 | result = self.topics[TopicID] 159 | if result is None: 160 | result = "# %s: Waiting for update" % self.__class__.__name__ 161 | else: 162 | result = result.GetValue() 163 | else: 164 | # Tell Excel use a cached value 165 | # Note: Excel can use the cached value only for the binary (.xlb and .xls) format files. 166 | result = None 167 | 168 | # fire out internal event... 169 | self.OnConnectData(TopicID) 170 | 171 | # GetNewValues as per interface is ByRef, so we need to pass it back too. 172 | return result, GetNewValues 173 | 174 | def DisconnectData(self, TopicID): 175 | """Deletes the given topic.""" 176 | self.OnDisconnectData(TopicID) 177 | 178 | if TopicID in self.topics: 179 | self.topics[TopicID] = None 180 | del self.topics[TopicID] 181 | 182 | def Heartbeat(self): 183 | """Called by excel to see if we're still here.""" 184 | return self.IsAlive 185 | 186 | def RefreshData(self, TopicCount): 187 | """Packs up the topic values. Called by excel when it's ready for an update. 188 | 189 | Needs to: 190 | * Return the current number of topics, via the "ByRef" TopicCount 191 | * Return a 2d SafeArray of the topic data. 192 | - 1st dim: topic numbers 193 | - 2nd dim: topic values 194 | """ 195 | 196 | # Excel expects a 2-dimensional array. The first dim contains the 197 | # topic numbers, and the second contains the values for the topics. 198 | # In true VBA style (yuck), we need to pack the array in row-major format, 199 | # which looks like: 200 | # ( (topic_num1, topic_num2, ..., topic_numN), \ 201 | # (topic_val1, topic_val2, ..., topic_valN) ) 202 | topicIDs = [] 203 | topicValues = [] 204 | updatedTopics = self.updatedTopics 205 | self.updatedTopics = {} 206 | 207 | self.OnRefreshData() 208 | 209 | for topicID, topicValue in updatedTopics.items(): 210 | topicIDs.append(topicID) 211 | topicValues.append(topicValue) 212 | 213 | results = [topicIDs, topicValues] 214 | TopicCount = len(topicIDs) 215 | 216 | # TopicCount is meant to be passed to us ByRef, so return it as well, as per 217 | # the way pywin32 handles ByRef arguments. 218 | return tuple(results), TopicCount 219 | 220 | def ServerStart(self, CallbackObject): 221 | """Excel has just created us... We take its callback for later, and set up shop.""" 222 | self.IsAlive = self.ALIVE 223 | 224 | if CallbackObject is None: 225 | raise COMException(desc='Excel did not provide a callback') 226 | 227 | try: 228 | self.__excel_thread_id = threading.current_thread().ident 229 | 230 | # Need to "cast" the raw PyIDispatch object to the IRTDUpdateEvent interface 231 | IRTDUpdateEventKlass = win32com.client.CLSIDToClass.GetClass(IID_RTDUpdateEvent) 232 | self.__callback = IRTDUpdateEventKlass(CallbackObject) 233 | 234 | # Prepare for marshalling callback object because we're going to likely call SignalExcel in a different thread. 235 | self.__marshall_callback = pythoncom.CoMarshalInterThreadInterfaceInStream(pythoncom.IID_IDispatch, self.__callback) 236 | 237 | self.OnServerStart() 238 | except Exception as e: 239 | logging.error("ServerStart: {}".format(repr(e))) 240 | 241 | logging.info("ServerStart Done") 242 | return self.IsAlive 243 | 244 | def ServerTerminate(self): 245 | """Called when excel no longer wants us.""" 246 | self.IsAlive = self.NOT_ALIVE # On next heartbeat, excel will free us 247 | self.OnServerTerminate() 248 | 249 | def CreateTopic(self, TopicId, TopicStrings=None): 250 | """Topic factory method. Subclass must override. 251 | 252 | Topic objects need to provide: 253 | * GetValue() method which returns an atomic value. 254 | 255 | Will raise NotImplemented if not overridden. 256 | """ 257 | raise NotImplemented('Subclass must implement') 258 | 259 | def SetCallbackThread(self): 260 | if self.__marshall_callback is None: 261 | raise COMException(desc='self.__marshall_callback is not initialized') 262 | 263 | self.__callback = win32com.client.Dispatch ( 264 | pythoncom.CoGetInterfaceAndReleaseStream ( 265 | self.__marshall_callback, 266 | pythoncom.IID_IDispatch 267 | ) 268 | ) 269 | 270 | logging.info("SetCallbackThread Done") 271 | 272 | # Overridable class events... 273 | def OnConnectData(self, TopicID): 274 | """Called when a new topic has been created, at excel's request.""" 275 | pass 276 | def OnDisconnectData(self, TopicID): 277 | """Called when a topic is about to be deleted, at excel's request.""" 278 | pass 279 | def OnRefreshData(self): 280 | """Called when excel has requested all current topic data.""" 281 | pass 282 | def OnServerStart(self): 283 | """Called when excel has instanciated us.""" 284 | pass 285 | def OnServerTerminate(self): 286 | """Called when excel is about to destroy us.""" 287 | pass 288 | 289 | class RTDTopic(object): 290 | """Base RTD Topic. 291 | Only method required by our RTDServer implementation is GetValue(). 292 | The others are more for convenience.""" 293 | def __init__(self, TopicStrings): 294 | super(RTDTopic, self).__init__() 295 | self.TopicStrings = TopicStrings 296 | self.__currentValue = None 297 | self.__dirty = False 298 | 299 | def Update(self, sender): 300 | """Called by the RTD Server. 301 | Gives us a chance to check if our topic data needs to be 302 | changed (eg. check a file, quiz a database, etc).""" 303 | raise NotImplemented('subclass must implement') 304 | 305 | def Reset(self): 306 | """Call when this topic isn't considered "dirty" anymore.""" 307 | self.__dirty = False 308 | 309 | def GetValue(self): 310 | return self.__currentValue 311 | 312 | def SetValue(self, value): 313 | self.__dirty = True 314 | self.__currentValue = value 315 | 316 | def HasChanged(self): 317 | return self.__dirty 318 | -------------------------------------------------------------------------------- /python/finnhub_rtd.py: -------------------------------------------------------------------------------- 1 | """Excel RTD (RealTimeData) Server sample for real-time stock quote. 2 | 3 | Using Finnhub real-time websocket: https://finnhub.io/docs/api#websocket-trades 4 | """ 5 | import excel_rtd as rtd 6 | import finnhubapi as fa 7 | from datetime import datetime 8 | import threading 9 | import pythoncom 10 | import win32api 11 | import win32com.client 12 | from win32com.server.exception import COMException 13 | import logging 14 | import os 15 | import time 16 | import asyncio 17 | import json 18 | 19 | LOG_FILE_FOLDER = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'logs') 20 | LOG_FILENAME = os.path.join(LOG_FILE_FOLDER, 'Finnhub_{:%Y%m%d_%H%M%S}.log'.format(datetime.now())) 21 | 22 | # Finnhub token can be set here or pass the token through "set_token" command using RTD (see demo\Finnhub_rtd_demo.xlsx) 23 | FINHUB_TOKEN = "" 24 | 25 | if not os.path.exists(LOG_FILE_FOLDER): 26 | os.makedirs(LOG_FILE_FOLDER) 27 | 28 | logging.basicConfig( 29 | filename=LOG_FILENAME, 30 | level=logging.INFO, 31 | format="%(asctime)s:%(levelname)s:%(message)s" 32 | ) 33 | 34 | class FinnhubServer(rtd.RTDServer): 35 | _reg_clsid_ = '{1540C97E-BD8B-444E-9F18-B38FB33711E5}' 36 | _reg_progid_ = 'FINNHUB' 37 | _reg_desc_ = "RTD server for realtime stock quote" 38 | 39 | # other class attributes... 40 | 41 | def __init__(self): 42 | super(FinnhubServer, self).__init__() 43 | self.finnhub_cli = fa.FinnhubClient() 44 | self.start_conn_event = threading.Event() 45 | self.async_loop = None 46 | 47 | if FINHUB_TOKEN: 48 | self.start_conn_event.set() 49 | self.finnhub_token = FINHUB_TOKEN 50 | else: 51 | self.finnhub_token = None 52 | 53 | self.topics_by_key = {} 54 | 55 | self.update_thread = threading.Thread(target = self.update_thread_handler) 56 | self.shutdown = False 57 | 58 | def OnServerStart(self): 59 | logging.info("OnServerStart Begin") 60 | 61 | self.update_thread.start() 62 | while not self.async_loop: 63 | time.sleep(0.1) 64 | 65 | def OnServerTerminate(self): 66 | logging.info("OnServerTerminate Begin") 67 | 68 | self.shutdown = True 69 | 70 | if self.finnhub_cli: 71 | self.finnhub_cli.close() 72 | self.finnhub_cli = None 73 | 74 | if not self.start_conn_event.is_set(): 75 | self.start_conn_event.set() 76 | if not self.ready_to_send.is_set(): 77 | self.ready_to_send.set() 78 | 79 | self.start_conn_event.clear() 80 | self.ready_to_send.clear() 81 | 82 | def _on_recv_message(self, message) -> None: 83 | # e.g. {"data":[{"p":379.6,"s":"AAPL","t":1594228987324,"v":21}],"type":"trade"} 84 | response = json.loads(message) 85 | if response['type'] == 'trade': 86 | updates = {} 87 | trades = response['data'] 88 | 89 | for trade in trades: 90 | dt = datetime.fromtimestamp(trade['t']/1000.0) 91 | tr_time = dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] 92 | tr_ticker = trade['s'] 93 | tr_price = trade['p'] 94 | tr_volume = trade['v'] 95 | #print(f'{tr_time}: Ticker={tr_ticker}, Price={tr_price}, Volume={tr_volume}') 96 | updates[f"{tr_ticker}|last_price"] = tr_price 97 | updates[f"{tr_ticker}|volume"] = tr_volume 98 | updates[f"{tr_ticker}|last_update_time"] = tr_time 99 | 100 | self.async_loop.call_soon_threadsafe(lambda: self.update_message_queue.put_nowait(updates)) 101 | else: 102 | logging.info(message) 103 | # e.g. {"type":"ping"} 104 | 105 | def update_thread_handler(self) -> None: 106 | logging.info("update_thread_handler start") 107 | try: 108 | pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED) 109 | 110 | asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) 111 | loop = asyncio.new_event_loop() 112 | asyncio.set_event_loop(loop) 113 | self.async_loop = loop 114 | self.update_message_queue = asyncio.Queue(loop=self.async_loop) 115 | self.send_message_queue = asyncio.Queue(loop=self.async_loop) 116 | self.ready_to_send = asyncio.Event(loop=self.async_loop) 117 | 118 | # Following call can cause deadlock if mainthread is not pumping Windows message. 119 | self.SetCallbackThread() 120 | 121 | update_msg_coro = self._update_msg_handler() 122 | send_msg_coro = self._send_msg_handler() 123 | loop.run_until_complete(asyncio.gather(update_msg_coro, send_msg_coro)) 124 | loop.close() 125 | except Exception as e: 126 | logging.error("update_thread_handler: {}".format(repr(e))) 127 | finally: 128 | pythoncom.CoUninitialize() 129 | 130 | # 131 | # _update_msg_handler coro 132 | # 133 | async def _update_msg_handler(self) -> None: 134 | logging.debug("_update_msg_handler: start") 135 | 136 | self.start_conn_event.wait() 137 | if self.shutdown: 138 | return 139 | 140 | self.finnhub_cli.connect(self._on_recv_message, self.finnhub_token) 141 | self.ready_to_send.set() 142 | logging.debug("_update_msg_handler: ready_to_send.set()") 143 | 144 | while not self.shutdown: 145 | msgs = await self.update_message_queue.get() 146 | 147 | try: 148 | # Check if any of our topics have new info to pass on 149 | if not len(self.topics): 150 | pass 151 | 152 | # {"MSFT|last_price":166.79,"MSFT|volume":21266168,"MSFT|last_update_time":"09:48:54","CSCO|last_price":40.519,"CSCO|volume":11838414,"CSCO|last_update_time":"09:48:54","QCOM|volume":4211871,"HPQ|bid":21.12,"COF|volume":1096158,"DIS|volume":6656830} 153 | if msgs: 154 | for k, v in msgs.items(): 155 | ticker, field = k.split('|') 156 | if (ticker, field) in self.topics_by_key: 157 | topic = self.topics_by_key[(ticker, field)] 158 | topic.Update(v) 159 | 160 | if topic.HasChanged(): 161 | self.updatedTopics[topic.topicID] = topic.GetValue() 162 | 163 | if self.updatedTopics: 164 | # Retry when com_error occurs 165 | # e.g. RPC_E_SERVERCALL_RETRYLATER = com_error(-2147417846, 'The message filter indicated that the application is busy.', None, None) 166 | while True: 167 | try: 168 | self.SignalExcel() 169 | break 170 | except pythoncom.com_error as error: 171 | await asyncio.sleep(0.01) 172 | 173 | except Exception as e: 174 | logging.error("Update: {}".format(repr(e))) 175 | #raise COMException(desc=repr(e)) 176 | 177 | async def _send_msg_handler(self) -> None: 178 | self.ready_to_send.wait() 179 | logging.debug(f"_send_msg_handler: ready_to_send signalled") 180 | if self.shutdown: 181 | return 182 | 183 | while not self.shutdown: 184 | msg = await self.send_message_queue.get() 185 | if msg: 186 | logging.debug(f"_send_msg_handler: {msg}") 187 | self.finnhub_cli.send(msg) 188 | 189 | def CreateTopic(self, TopicId, TopicStrings=None): 190 | """Topic factory. Builds a StockTickTopic object out of the given TopicStrings.""" 191 | if len(TopicStrings) >= 2: 192 | ticker, field = TopicStrings 193 | logging.info(f"CreateTopic {TopicId}, {ticker}|{field}") 194 | if not ticker: 195 | return None 196 | 197 | if ticker == "set_token": 198 | self.finnhub_token = field 199 | self.start_conn_event.set() 200 | 201 | new_topic = SimpeVarTopic(TopicId, TopicStrings) 202 | self.topics_by_key[(ticker)] = field 203 | self.updatedTopics[TopicId] = "Finnhub token set" 204 | else: 205 | new_topic = StockTickTopic(TopicId, TopicStrings) 206 | ticker = ticker.upper() 207 | self.topics_by_key[(ticker, field)] = new_topic 208 | subscribe_msg = f"{{\"type\":\"subscribe\",\"symbol\":\"{ticker}\"}}" 209 | logging.debug(subscribe_msg) 210 | try: 211 | self.async_loop.call_soon_threadsafe(lambda: self.send_message_queue.put_nowait(subscribe_msg)) 212 | except Exception as e: 213 | logging.error("CreateTopic: {}".format(repr(e))) 214 | else: 215 | logging.error(f"Unknown param: CreateTopic {TopicId}, {TopicStrings}") 216 | return None 217 | return new_topic 218 | 219 | class SimpeVarTopic(rtd.RTDTopic): 220 | def __init__(self, topicID, TopicStrings): 221 | super(SimpeVarTopic, self).__init__(TopicStrings) 222 | try: 223 | cmd, var = self.TopicStrings 224 | self.topicID = topicID 225 | except Exception as e: 226 | raise ValueError("Invalid topic strings: %s" % str(TopicStrings)) 227 | 228 | # setup our initial value 229 | self.checkpoint = self.timestamp() 230 | self.SetValue(var) 231 | 232 | def timestamp(self): 233 | return datetime.now() 234 | 235 | def Update(self, value): 236 | self.SetValue(value) 237 | self.checkpoint = self.timestamp() 238 | 239 | class StockTickTopic(rtd.RTDTopic): 240 | def __init__(self, topicID, TopicStrings): 241 | super(StockTickTopic, self).__init__(TopicStrings) 242 | try: 243 | ticker, field = self.TopicStrings 244 | self.topicID = topicID 245 | self.ticker = ticker 246 | self.field = field 247 | except Exception as e: 248 | raise ValueError("Invalid topic strings: %s" % str(TopicStrings)) 249 | 250 | # setup our initial value 251 | self.checkpoint = self.timestamp() 252 | self.SetValue("#WatingDataFromFinnhub") 253 | 254 | def __key(self): 255 | return (self.ticker, self.field) 256 | 257 | def __hash__(self): 258 | return hash(self.__key()) 259 | 260 | def __eq__(self, other): 261 | if isinstance(other, StockTickTopic): 262 | return self.__key() == other.__key() 263 | return NotImplemented 264 | 265 | def timestamp(self): 266 | return datetime.now() 267 | 268 | def Update(self, value): 269 | self.SetValue(value) 270 | self.checkpoint = self.timestamp() 271 | 272 | if __name__ == "__main__": 273 | import win32com.server.register 274 | 275 | # Register/Unregister FinnhubServer example 276 | # eg. at the command line: finnhub_rtd.py --register 277 | # Then type in an excel cell something like: 278 | # =RTD("FINNHUB","","set_token","your_token_string") 279 | # =RTD("FINNHUB","","MSFT","last_price") 280 | # =RTD("FINNHUB","","BINANCE:BTCUSDT","last_price") 281 | 282 | win32com.server.register.UseCommandLine(FinnhubServer) 283 | -------------------------------------------------------------------------------- /python/finnhubapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .finnhub_ws_conn import FinnhubClient 2 | 3 | __all__ = [ 4 | 'FinnhubClient' 5 | ] 6 | -------------------------------------------------------------------------------- /python/finnhubapi/finnhub_ws_conn.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import threading 4 | import websockets 5 | import json 6 | from datetime import datetime 7 | 8 | import signal 9 | import sys 10 | import time 11 | 12 | logger = logging.getLogger(__name__) 13 | logger.setLevel(logging.DEBUG) 14 | logger.addHandler(logging.StreamHandler()) 15 | 16 | class FinnhubWSConn(object): 17 | 18 | def __init__(self): 19 | """Constructor""" 20 | super(FinnhubWSConn, self).__init__() 21 | self.ws = None 22 | 23 | async def send(self, message) -> None: 24 | if self.ws: 25 | await self.ws.send(message) 26 | 27 | async def recv_message(self) -> str: 28 | message = await self.ws.recv() 29 | return message 30 | 31 | async def start(self, token) -> None: 32 | logger.debug("start") 33 | try: 34 | uri = f"wss://ws.finnhub.io?token={token}" 35 | self.ws = await websockets.connect(uri) 36 | except Exception as e: 37 | logger.error("Failed to connect the server: {}".format(repr(e))) 38 | 39 | async def close(self) -> None: 40 | if self.ws: 41 | await self.ws.close() 42 | self.ws = None 43 | 44 | @property 45 | def closed(self): 46 | if self.ws: 47 | return self.ws.closed 48 | else: 49 | return True 50 | 51 | class FinnhubClient(): 52 | def __init__(self): 53 | self.conn = FinnhubWSConn() 54 | self.async_thread = None 55 | self.async_loop = None 56 | self.message_queue = None 57 | self.shutdown = False 58 | self.token = None 59 | self.connect_event = threading.Event() 60 | 61 | def connect(self, on_recv_msg, token): 62 | self.on_recv_msg = on_recv_msg 63 | self.token = token 64 | self.async_thread = threading.Thread(target=self._async_thread_handler) 65 | self.async_thread.start() 66 | self.connect_event.wait() 67 | 68 | def _async_thread_handler(self) -> None: 69 | logger.debug("_async_thread_handler") 70 | try: 71 | asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) 72 | loop = asyncio.new_event_loop() 73 | asyncio.set_event_loop(loop) 74 | self.async_loop = loop 75 | self.message_queue = asyncio.Queue() 76 | loop.run_until_complete(self.conn.start(self.token)) 77 | self.connect_event.set() 78 | 79 | send_messages_coro = self._send_message_async() 80 | recv_messages_coro = self._recv_message_async() 81 | loop.run_until_complete(asyncio.gather(send_messages_coro, recv_messages_coro)) 82 | loop.close() 83 | except Exception as e: 84 | logger.error("_async_thread_handler: {}".format(repr(e))) 85 | self.connect_event.set() 86 | 87 | async def _send_message_async(self) -> None: 88 | while not self.shutdown: 89 | message = await self.message_queue.get() 90 | if message: 91 | await self.conn.send(message) 92 | else: 93 | await self.conn.close() 94 | 95 | async def _recv_message_async(self) -> None: 96 | while not self.shutdown: 97 | msg = await self.conn.recv_message() 98 | self.on_recv_msg(msg) 99 | 100 | def send(self, msg): 101 | self.async_loop.call_soon_threadsafe(lambda: self.message_queue.put_nowait(msg)) 102 | 103 | def close(self): 104 | self.shutdown = True 105 | self.send(None) 106 | while not self.conn.closed: 107 | time.sleep(0.3) 108 | self.async_loop.stop() 109 | self.async_loop.close() 110 | if self.async_thread: 111 | self.async_thread.join() 112 | 113 | def signal_handler(sig, frame): 114 | print('You pressed Ctrl+C!') 115 | global finnhub_client 116 | finnhub_client.close() 117 | sys.exit(0) 118 | 119 | def on_recv_message(message) -> None: 120 | # e.g. {"data":[{"p":379.6,"s":"AAPL","t":1594228987324,"v":21}],"type":"trade"} 121 | response = json.loads(message) 122 | if response['type'] == 'trade': 123 | trades = response['data'] 124 | for trade in trades: 125 | dt = datetime.fromtimestamp(trade['t']/1000) 126 | tr_time = dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] 127 | tr_ticker = trade['s'] 128 | tr_price = trade['p'] 129 | tr_volume = trade['v'] 130 | print(f'{tr_time}: Ticker={tr_ticker}, Price={tr_price}, Volume={tr_volume}') 131 | else: 132 | # e.g. {"type":"ping"} 133 | print(message) 134 | 135 | def finnhub_test_main(): 136 | signal.signal(signal.SIGINT, signal_handler) 137 | 138 | global finnhub_client 139 | finnhub_client = FinnhubClient() 140 | finnhub_client.connect(on_recv_message, "YOUR_FINNHUB_TOKEN") 141 | finnhub_client.send('{"type":"subscribe","symbol":"MSFT"}') 142 | finnhub_client.send('{"type":"subscribe","symbol":"BINANCE:BTCUSDT"}') 143 | while True: 144 | time.sleep(1) 145 | 146 | ################################################################################ 147 | # __main__ 148 | ################################################################################ 149 | if __name__ == '__main__': 150 | finnhub_test_main() 151 | -------------------------------------------------------------------------------- /python/requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp >= 3.6.2 2 | asyncio >= 3.4.3 3 | websockets >= 8.1 4 | -------------------------------------------------------------------------------- /python/stockrow_rtd.py: -------------------------------------------------------------------------------- 1 | """Excel RTD (RealTimeData) Server sample for real-time stock quote. 2 | 3 | Periodically polling stock quote from the stockrow.com. 4 | """ 5 | 6 | import excel_rtd as rtd 7 | from datetime import datetime 8 | import threading 9 | import pythoncom 10 | import win32api 11 | import win32com.client 12 | from win32com.server.exception import COMException 13 | import logging 14 | import os 15 | import time 16 | import asyncio 17 | import json 18 | from aiohttp import ClientSession 19 | 20 | LOG_FILE_FOLDER = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'logs') 21 | LOG_FILENAME = os.path.join(LOG_FILE_FOLDER, 'Stockrow_{:%Y%m%d_%H%M%S}.log'.format(datetime.now())) 22 | 23 | if not os.path.exists(LOG_FILE_FOLDER): 24 | os.makedirs(LOG_FILE_FOLDER) 25 | 26 | logging.basicConfig( 27 | filename=LOG_FILENAME, 28 | level=logging.INFO, 29 | format="%(asctime)s:%(levelname)s:%(message)s" 30 | ) 31 | 32 | # Update frequency values 33 | _DEF_UPDATE_FREQ_SEC = 30 34 | _MIN_UPDATE_FREQ_SEC = 10 35 | _MAX_UPDATE_FREQ_SEC = 60 * 10 36 | 37 | # Max tickers in one request. 38 | _STOCKROW_TICKERS_IN_ONE_REQUESTS = 30 39 | 40 | class StockrowServer(rtd.RTDServer): 41 | _reg_clsid_ = '{C38E586E-C6B9-41B2-9C99-88180E2B9DB8}' 42 | _reg_progid_ = 'Stockrow' 43 | #_reg_progid_ = 'Stockrow.RTD' 44 | _reg_desc_ = "RTD server for realtime stock quote using stockrow.com" 45 | 46 | # other class attributes... 47 | 48 | def __init__(self): 49 | super(StockrowServer, self).__init__() 50 | self.topics_by_key = {} 51 | 52 | self.update_thread = threading.Thread(target = self.update_thread_handler) 53 | self.shutdown = False 54 | self.async_loop = None 55 | self.update_freq_sec = _DEF_UPDATE_FREQ_SEC 56 | 57 | def OnServerStart(self): 58 | logging.info("OnServerStart Begin") 59 | 60 | self.update_thread.start() 61 | while not self.async_loop: 62 | time.sleep(0.1) 63 | 64 | def OnServerTerminate(self): 65 | logging.info("OnServerTerminate Begin") 66 | self.shutdown = True 67 | 68 | #def OnRefreshData(self): 69 | # """Called when excel has requested refresh topic data.""" 70 | 71 | def update_thread_handler(self) -> None: 72 | logging.info("update_thread_handler start") 73 | try: 74 | pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED) 75 | 76 | asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) 77 | loop = asyncio.new_event_loop() 78 | asyncio.set_event_loop(loop) 79 | self.async_loop = loop 80 | self.update_message_queue = asyncio.Queue(loop=self.async_loop) 81 | self.stock_quote_queue = asyncio.Queue(loop=self.async_loop) 82 | 83 | # Following call can cause deadlock if mainthread is not pumping Windows message. 84 | self.SetCallbackThread() 85 | 86 | update_msg_coro = self._update_msg_handler() 87 | auto_update_coro = self._auto_update_worker() 88 | stock_quote_coro = self._get_stock_quote_worker() 89 | loop.run_until_complete(asyncio.gather(update_msg_coro, auto_update_coro, stock_quote_coro)) 90 | loop.close() 91 | except Exception as e: 92 | logging.error("update_thread_handler: {}".format(repr(e))) 93 | finally: 94 | pythoncom.CoUninitialize() 95 | 96 | # 97 | # _update_msg_handler coro 98 | # 99 | async def _update_msg_handler(self) -> None: 100 | logging.debug("_update_msg_handler: start") 101 | 102 | if self.shutdown: 103 | return 104 | 105 | while not self.shutdown: 106 | msgs = await self.update_message_queue.get() 107 | 108 | try: 109 | # Check if any of our topics have new info to pass on 110 | if not len(self.topics): 111 | pass 112 | 113 | # {"MSFT|last_price":166.79,"MSFT|volume":21266168,"MSFT|last_update_time":"09:48:54","CSCO|last_price":40.519,"CSCO|volume":11838414,"CSCO|last_update_time":"09:48:54","QCOM|volume":4211871,"HPQ|bid":21.12,"COF|volume":1096158,"DIS|volume":6656830} 114 | if msgs: 115 | for k, v in msgs.items(): 116 | logging.debug(f"dequeue: {k} {v}") 117 | ticker, field = k.split('|') 118 | if (ticker, field) in self.topics_by_key: 119 | topic = self.topics_by_key[(ticker, field)] 120 | topic.Update(v) 121 | 122 | if topic.HasChanged(): 123 | self.updatedTopics[topic.topicID] = topic.GetValue() 124 | 125 | if self.updatedTopics: 126 | # Retry when com_error occurs 127 | # e.g. RPC_E_SERVERCALL_RETRYLATER = com_error(-2147417846, 'The message filter indicated that the application is busy.', None, None) 128 | while True: 129 | try: 130 | self.SignalExcel() 131 | break 132 | except pythoncom.com_error as error: 133 | await asyncio.sleep(0.01) 134 | 135 | except Exception as e: 136 | logging.error("Update: {}".format(repr(e))) 137 | #raise COMException(desc=repr(e)) 138 | 139 | async def _auto_update_worker(self): 140 | while not self.shutdown: 141 | await asyncio.sleep(self.update_freq_sec) 142 | while not self.stock_quote_queue.empty(): 143 | await asyncio.sleep(0.5) 144 | 145 | for (ticker, field) in self.topics_by_key.keys(): 146 | if field: 147 | self.stock_quote_queue.put_nowait(ticker) 148 | 149 | @staticmethod 150 | async def _fetch(url, session) -> str: 151 | async with session.get(url) as response: 152 | return await response.read() 153 | 154 | async def _get_stock_quote_worker(self) -> None: 155 | async with ClientSession() as session: 156 | while not self.shutdown: 157 | tickers = [] 158 | tickers.append(await self.stock_quote_queue.get()) 159 | 160 | for _ in range(_STOCKROW_TICKERS_IN_ONE_REQUESTS - 1): 161 | if self.stock_quote_queue.empty(): 162 | break 163 | tickers.append(await self.stock_quote_queue.get()) 164 | 165 | url_params = str() 166 | for ticker in tickers: 167 | url_params += f"tickers[]={ticker}&" 168 | 169 | url = "https://stockrow.com/api/price_changes.json?" + url_params.rstrip("&") 170 | 171 | while True: 172 | logging.debug(f"fetch: {url}") 173 | response_str = await self._fetch(url, session) 174 | # [{"ticker":"AAPL","price":383.6800,"ohlc":[381.3400,383.8800,378.8310,383.6800,21827181.0000],"absolute_change":0.6700,"relative_change":0.0017,"date":"10 Jul"}, 175 | logging.debug(response_str) 176 | try: 177 | updates = {} 178 | price_changes = json.loads(response_str) 179 | for price_change in price_changes: 180 | #dt = datetime.fromtimestamp(response['t']) 181 | #sq_time = dt.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] 182 | ticker = price_change["ticker"] 183 | price = price_change["price"] 184 | ohlc = price_change["ohlc"] 185 | absolute_change = price_change["absolute_change"] 186 | relative_change = price_change["relative_change"] 187 | update_date = price_change["date"] 188 | 189 | # Generate updates 190 | updates[f"{ticker}|last_price"] = price 191 | updates[f"{ticker}|open"] = ohlc[0] 192 | updates[f"{ticker}|high"] = ohlc[1] 193 | updates[f"{ticker}|low"] = ohlc[2] 194 | updates[f"{ticker}|close"] = ohlc[3] 195 | updates[f"{ticker}|volume"] = ohlc[4] 196 | updates[f"{ticker}|absolute_change"] = absolute_change 197 | updates[f"{ticker}|relative_change"] = relative_change 198 | updates[f"{ticker}|last_update_date"] = update_date 199 | #updates[f"{ticker}|last_update_time"] = sq_time 200 | self.async_loop.call_soon_threadsafe(lambda: self.update_message_queue.put_nowait(updates)) 201 | break 202 | except Exception as e: 203 | logging.error(f"{repr(e)}: {response_str}") 204 | await asyncio.sleep(0.5) 205 | continue 206 | 207 | def CreateTopic(self, TopicId, TopicStrings=None): 208 | """Topic factory. Builds a StockTickTopic object out of the given TopicStrings.""" 209 | if len(TopicStrings) >= 2: 210 | ticker, field = TopicStrings 211 | logging.debug(f"CreateTopic {TopicId}, {ticker}|{field}") 212 | if not ticker: 213 | return None 214 | 215 | if ticker == "set_update_frequency": 216 | self.update_freq_sec = max(min(float(field), _MAX_UPDATE_FREQ_SEC), _MIN_UPDATE_FREQ_SEC) 217 | logging.info(f"set_update_frequency: {self.update_freq_sec}") 218 | 219 | new_topic = SimpeVarTopic(TopicId, TopicStrings) 220 | self.topics_by_key[(ticker, None)] = new_topic 221 | else: 222 | new_topic = StockTickTopic(TopicId, TopicStrings) 223 | ticker = ticker.upper() 224 | self.topics_by_key[(ticker, field)] = new_topic 225 | 226 | self.async_loop.call_soon_threadsafe(lambda: self.stock_quote_queue.put_nowait(ticker)) 227 | else: 228 | logging.error(f"Unknown param: CreateTopic {TopicId}, {TopicStrings}") 229 | return None 230 | return new_topic 231 | 232 | class SimpeVarTopic(rtd.RTDTopic): 233 | def __init__(self, topicID, TopicStrings): 234 | super(SimpeVarTopic, self).__init__(TopicStrings) 235 | try: 236 | cmd, var = self.TopicStrings 237 | self.topicID = topicID 238 | except Exception as e: 239 | raise ValueError("Invalid topic strings: %s" % str(TopicStrings)) 240 | 241 | # setup our initial value 242 | self.checkpoint = self.timestamp() 243 | self.SetValue(var) 244 | 245 | def timestamp(self): 246 | return datetime.now() 247 | 248 | def Update(self, value): 249 | self.SetValue(value) 250 | self.checkpoint = self.timestamp() 251 | 252 | class StockTickTopic(rtd.RTDTopic): 253 | """Stock quote topic 254 | """ 255 | def __init__(self, topicID, TopicStrings): 256 | super(StockTickTopic, self).__init__(TopicStrings) 257 | try: 258 | ticker, field = self.TopicStrings 259 | self.topicID = topicID 260 | self.ticker = ticker 261 | self.field = field 262 | except Exception as e: 263 | raise ValueError("Invalid topic strings: %s" % str(TopicStrings)) 264 | 265 | # setup our initial value 266 | self.checkpoint = self.timestamp() 267 | self.SetValue("N/A") 268 | 269 | def __key(self): 270 | return (self.ticker, self.field) 271 | 272 | def __hash__(self): 273 | return hash(self.__key()) 274 | 275 | def __eq__(self, other): 276 | if isinstance(other, StockTickTopic): 277 | return self.__key() == other.__key() 278 | return NotImplemented 279 | 280 | def timestamp(self): 281 | return datetime.now() 282 | 283 | def Update(self, value): 284 | self.SetValue(value) 285 | self.checkpoint = self.timestamp() 286 | 287 | if __name__ == "__main__": 288 | import win32com.server.register 289 | 290 | # Register/Unregister StockrowServer example 291 | # eg. at the command line: py stockrow_rtd.py --register 292 | # Then type in an excel cell something like: 293 | # =RTD("STOCKROW","","MSFT","last_price") 294 | 295 | win32com.server.register.UseCommandLine(StockrowServer) 296 | -------------------------------------------------------------------------------- /python/td_rtd.py: -------------------------------------------------------------------------------- 1 | """Excel RTD (RealTimeData) Server sample for real-time stock quote. 2 | """ 3 | import excel_rtd as rtd 4 | import tdapi as ta 5 | from datetime import datetime 6 | import threading 7 | import pythoncom 8 | import win32api 9 | import win32com.client 10 | from win32com.server.exception import COMException 11 | import logging 12 | import os 13 | import time 14 | import asyncio 15 | import json 16 | 17 | from typing import List 18 | 19 | LOG_FILE_FOLDER = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'logs') 20 | LOG_FILENAME = os.path.join(LOG_FILE_FOLDER, 'TD_{:%Y%m%d_%H%M%S}.log'.format(datetime.now())) 21 | 22 | if not os.path.exists(LOG_FILE_FOLDER): 23 | os.makedirs(LOG_FILE_FOLDER) 24 | 25 | logging.basicConfig( 26 | filename=LOG_FILENAME, 27 | level=logging.ERROR, 28 | format="%(asctime)s:%(levelname)s:%(message)s" 29 | ) 30 | 31 | class TDServer(rtd.RTDServer): 32 | _reg_clsid_ = '{E28CFA65-CC94-455E-BF49-DCBCEBD17154}' 33 | _reg_progid_ = 'TD.RTD' 34 | _reg_desc_ = "RTD server for realtime stock quote" 35 | 36 | # other class attributes... 37 | 38 | def __init__(self): 39 | super(TDServer, self).__init__() 40 | self.td_cli = ta.create_td_client() 41 | self.start_conn_event = threading.Event() 42 | self.async_loop = None 43 | 44 | self.topics_by_key = {} 45 | 46 | self.update_thread = threading.Thread(target=self.update_thread_handler) 47 | self.shutdown = False 48 | 49 | def OnServerStart(self): 50 | logging.info("OnServerStart Begin") 51 | 52 | self.update_thread.start() 53 | while not self.async_loop: 54 | time.sleep(0.1) 55 | 56 | def OnServerTerminate(self): 57 | logging.info("OnServerTerminate Begin") 58 | 59 | self.shutdown = True 60 | 61 | if self.td_cli: 62 | self.td_cli.close() 63 | self.td_cli = None 64 | 65 | if not self.start_conn_event.is_set(): 66 | self.start_conn_event.set() 67 | 68 | if not self.ready_to_send.is_set(): 69 | self.ready_to_send.set() 70 | 71 | self.start_conn_event.clear() 72 | self.ready_to_send.clear() 73 | 74 | def _on_quote_received(self, quotes: List[ta.TDQuote]) -> None: 75 | self.async_loop.call_soon_threadsafe(lambda: self.update_message_queue.put_nowait(quotes)) 76 | 77 | def update_thread_handler(self) -> None: 78 | logging.info("update_thread_handler start") 79 | try: 80 | pythoncom.CoInitializeEx(pythoncom.COINIT_MULTITHREADED) 81 | 82 | asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) 83 | loop = asyncio.new_event_loop() 84 | asyncio.set_event_loop(loop) 85 | self.async_loop = loop 86 | self.update_message_queue = asyncio.Queue(loop=self.async_loop) 87 | self.send_message_queue = asyncio.Queue(loop=self.async_loop) 88 | self.ready_to_send = asyncio.Event(loop=self.async_loop) 89 | 90 | # Following call can cause deadlock if mainthread is not pumping Windows message. 91 | self.SetCallbackThread() 92 | 93 | update_msg_coro = self._update_msg_handler() 94 | send_msg_coro = self._send_msg_handler() 95 | loop.run_until_complete(asyncio.gather(update_msg_coro, send_msg_coro)) 96 | loop.close() 97 | except Exception as e: 98 | logging.error("update_thread_handler: {}".format(repr(e))) 99 | finally: 100 | pythoncom.CoUninitialize() 101 | 102 | # 103 | # _update_msg_handler coro 104 | # 105 | async def _update_msg_handler(self) -> None: 106 | logging.debug("_update_msg_handler: start") 107 | 108 | self.start_conn_event.wait() 109 | if self.shutdown: 110 | return 111 | 112 | self.td_cli.connect(self._on_quote_received) 113 | self.ready_to_send.set() 114 | logging.debug("_update_msg_handler: ready_to_send.set()") 115 | 116 | while not self.shutdown: 117 | quotes = await self.update_message_queue.get() 118 | 119 | try: 120 | # Check if any of our topics have new info to pass on 121 | if not len(self.topics): 122 | pass 123 | 124 | for quote in quotes: 125 | ticker = quote.ticker 126 | 127 | for k, v in quote.fields.items(): 128 | if (ticker, k) in self.topics_by_key: 129 | topic = self.topics_by_key[(ticker, k)] 130 | topic.Update(v) 131 | 132 | if topic.HasChanged(): 133 | self.updatedTopics[topic.topicID] = topic.GetValue() 134 | 135 | if self.updatedTopics: 136 | # Retry when com_error occurs 137 | # e.g. RPC_E_SERVERCALL_RETRYLATER = com_error(-2147417846, 'The message filter indicated that the application is busy.', None, None) 138 | while True: 139 | try: 140 | self.SignalExcel() 141 | break 142 | except pythoncom.com_error as error: 143 | await asyncio.sleep(0.01) 144 | 145 | except Exception as e: 146 | logging.error("Update: {}".format(repr(e))) 147 | #raise COMException(desc=repr(e)) 148 | 149 | async def _send_msg_handler(self) -> None: 150 | self.ready_to_send.wait() 151 | logging.debug(f"_send_msg_handler: ready_to_send signalled") 152 | if self.shutdown: 153 | return 154 | 155 | while not self.shutdown: 156 | msg = await self.send_message_queue.get() 157 | if msg: 158 | self.td_cli.send(msg) 159 | 160 | def CreateTopic(self, TopicId, TopicStrings=None): 161 | """Topic factory. Builds a StockTickTopic object out of the given TopicStrings.""" 162 | if len(TopicStrings) >= 2: 163 | ticker, field = TopicStrings 164 | logging.info(f"CreateTopic {TopicId}, {ticker}|{field}") 165 | if not ticker: 166 | return None 167 | 168 | if not self.start_conn_event.is_set(): 169 | self.start_conn_event.set() 170 | 171 | new_topic = StockTickTopic(TopicId, TopicStrings) 172 | ticker = ticker.upper() 173 | self.topics_by_key[(ticker, field)] = new_topic 174 | 175 | subscribe_msg = { 176 | "type": "subscribe", 177 | "symbol": ticker, 178 | "field": field 179 | } 180 | 181 | logging.debug(subscribe_msg) 182 | try: 183 | self.async_loop.call_soon_threadsafe(lambda: self.send_message_queue.put_nowait(subscribe_msg)) 184 | except Exception as e: 185 | logging.error("CreateTopic: {}".format(repr(e))) 186 | else: 187 | logging.error(f"Unknown param: CreateTopic {TopicId}, {TopicStrings}") 188 | return None 189 | return new_topic 190 | 191 | class SimpeVarTopic(rtd.RTDTopic): 192 | def __init__(self, topicID, TopicStrings): 193 | super(SimpeVarTopic, self).__init__(TopicStrings) 194 | try: 195 | cmd, var = self.TopicStrings 196 | self.topicID = topicID 197 | except Exception as e: 198 | raise ValueError("Invalid topic strings: %s" % str(TopicStrings)) 199 | 200 | # setup our initial value 201 | self.checkpoint = self.timestamp() 202 | self.SetValue(var) 203 | 204 | def timestamp(self): 205 | return datetime.now() 206 | 207 | def Update(self, value): 208 | self.SetValue(value) 209 | self.checkpoint = self.timestamp() 210 | 211 | class StockTickTopic(rtd.RTDTopic): 212 | def __init__(self, topicID, TopicStrings): 213 | super(StockTickTopic, self).__init__(TopicStrings) 214 | try: 215 | ticker, field = self.TopicStrings 216 | self.topicID = topicID 217 | self.ticker = ticker 218 | self.field = field 219 | except Exception as e: 220 | raise ValueError("Invalid topic strings: %s" % str(TopicStrings)) 221 | 222 | # setup our initial value 223 | self.checkpoint = self.timestamp() 224 | self.SetValue("#WatingDataForData") 225 | 226 | def __key(self): 227 | return (self.ticker, self.field) 228 | 229 | def __hash__(self): 230 | return hash(self.__key()) 231 | 232 | def __eq__(self, other): 233 | if isinstance(other, StockTickTopic): 234 | return self.__key() == other.__key() 235 | return NotImplemented 236 | 237 | def timestamp(self): 238 | return datetime.now() 239 | 240 | def Update(self, value): 241 | self.SetValue(value) 242 | self.checkpoint = self.timestamp() 243 | 244 | if __name__ == "__main__": 245 | import win32com.server.register 246 | 247 | # Register/Unregister TDServer example 248 | # eg. at the command line: td_rtd.py --register 249 | # Then type in an excel cell something like: 250 | # =RTD("TD.RTD","","MSFT","last-price") 251 | 252 | win32com.server.register.UseCommandLine(TDServer) 253 | -------------------------------------------------------------------------------- /python/tdapi/LICENSE: -------------------------------------------------------------------------------- 1 | Because significant portion of field.py, td_client.py and td_oauth.py borrowed from 2 | the td-ameritrade-python-api (https://github.com/areed1192/td-ameritrade-python-api), 3 | copied the License from the repository. 4 | 5 | MIT License 6 | 7 | Copyright (c) 2020 Alex Reed 8 | 9 | Permission is hereby granted, free of charge, to any person obtaining a copy 10 | of this software and associated documentation files (the "Software"), to deal 11 | in the Software without restriction, including without limitation the rights 12 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 13 | copies of the Software, and to permit persons to whom the Software is 14 | furnished to do so, subject to the following conditions: 15 | 16 | The above copyright notice and this permission notice shall be included in all 17 | copies or substantial portions of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 20 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 21 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 22 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 23 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 24 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 25 | SOFTWARE. 26 | -------------------------------------------------------------------------------- /python/tdapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .td_client import create_td_client, TDClient, TDQuote 2 | from .td_oauth import silent_sso, run_full_oauth_subprocess 3 | 4 | __all__ = [ 5 | 'create_td_client', 6 | 'silent_sso', 7 | 'run_full_oauth_subprocess', 8 | 'TDClient', 9 | 'TDQuote' 10 | ] 11 | -------------------------------------------------------------------------------- /python/tdapi/fields.py: -------------------------------------------------------------------------------- 1 | ENDPOINT_ARGUMENTS = { 2 | 'search_instruments': { 3 | 'projection': ['symbol-search', 'symbol-regex', 'desc-search', 'desc-regex', 'fundamental'] 4 | }, 5 | 'get_market_hours': { 6 | 'markets': ['EQUITY', 'OPTION', 'FUTURE', 'BOND', 'FOREX'] 7 | }, 8 | 'get_movers': { 9 | 'market': ['$DJI', '$COMPX', '$SPX.X'], 10 | 'direction': ['up', 'down'], 11 | 'change': ['value', 'percent'] 12 | }, 13 | 'get_user_principals': { 14 | 'fields': ['streamerSubscriptionKeys', 'streamerConnectionInfo', 'preferences', 'surrogateIds'] 15 | } 16 | } 17 | 18 | VALID_CHART_VALUES = { 19 | 'minute': { 20 | 'day': [1, 2, 3, 4, 5, 10] 21 | }, 22 | 'daily': { 23 | 'month': [1, 2, 3, 6], 24 | 'year': [1, 2, 3, 5, 10, 15, 20], 25 | 'ytd': [1] 26 | }, 27 | 'weekly': { 28 | 'month': [1, 2, 3, 6], 29 | 'year': [1, 2, 3, 5, 10, 15, 20], 30 | 'ytd': [1] 31 | }, 32 | 'monthly': { 33 | 'year': [1, 2, 3, 5, 10, 15, 20] 34 | } 35 | } 36 | 37 | STREAM_FIELD_IDS = { 38 | "account_activity": { 39 | "0": "subscription-key", 40 | "1": "account-id", 41 | "2": "message-type", 42 | "3": "message-data" 43 | }, 44 | "level_one_forex": { 45 | "0": "symbol", 46 | "1": "bid-price", 47 | "2": "ask-price", 48 | "3": "last-price", 49 | "4": "bid-size", 50 | "5": "ask-size", 51 | "6": "total-volume", 52 | "7": "last-size", 53 | "8": "quote-time", 54 | "9": "trade-time", 55 | "10": "high-price", 56 | "11": "low-price", 57 | "12": "close-price", 58 | "13": "exchange-id", 59 | "14": "description", 60 | "15": "open-price", 61 | "16": "net-change", 62 | "17": "percent-change", 63 | "18": "exchange-name", 64 | "19": "digits", 65 | "20": "security-status", 66 | "21": "tick", 67 | "22": "tick-amount", 68 | "23": "product", 69 | "24": "trading-hours", 70 | "25": "is-tradable", 71 | "26": "market-maker", 72 | "27": "52-week-high", 73 | "28": "52-week-low", 74 | "29": "mark" 75 | }, 76 | "level_one_futures": { 77 | "0": "symbol", 78 | "1": "bid-price", 79 | "2": "ask-price", 80 | "3": "last-price", 81 | "4": "bid-size", 82 | "5": "ask-size", 83 | "6": "ask-id", 84 | "7": "bid-id", 85 | "8": "total-volume", 86 | "9": "last-size", 87 | "10": "quote-time", 88 | "11": "trade-time", 89 | "12": "high-price", 90 | "13": "low-price", 91 | "14": "close-price", 92 | "15": "exchange-id", 93 | "16": "description", 94 | "17": "last-id", 95 | "18": "open-price", 96 | "19": "net-change", 97 | "20": "future-percent-change", 98 | "21": "exhange-name", 99 | "22": "security-status", 100 | "23": "open-interest", 101 | "24": "mark", 102 | "25": "tick", 103 | "26": "tick-amount", 104 | "27": "product", 105 | "28": "future-price-format", 106 | "29": "future-trading-hours", 107 | "30": "future-is-tradable", 108 | "31": "future-multiplier", 109 | "32": "future-is-active", 110 | "33": "future-settlement-price", 111 | "34": "future-active-symbol", 112 | "35": "future-expiration-date" 113 | }, 114 | "level_one_futures_options": { 115 | "0": "symbol", 116 | "1": "bid-price", 117 | "2": "ask-price", 118 | "3": "last-price", 119 | "4": "bid-size", 120 | "5": "ask-size", 121 | "6": "ask-id", 122 | "7": "bid-id", 123 | "8": "total-volume", 124 | "9": "last-size", 125 | "10": "quote-time", 126 | "11": "trade-time", 127 | "12": "high-price", 128 | "13": "low-price", 129 | "14": "close-price", 130 | "15": "exchange-id", 131 | "16": "description", 132 | "17": "last-id", 133 | "18": "open-price", 134 | "19": "net-change", 135 | "20": "future-percent-change", 136 | "21": "exhange-name", 137 | "22": "security-status", 138 | "23": "open-interest", 139 | "24": "mark", 140 | "25": "tick", 141 | "26": "tick-amount", 142 | "27": "product", 143 | "28": "future-price-format", 144 | "29": "future-trading-hours", 145 | "30": "future-is-tradable", 146 | "31": "future-multiplier", 147 | "32": "future-is-active", 148 | "33": "future-settlement-price", 149 | "34": "future-active-symbol", 150 | "35": "future-expiration-date" 151 | }, 152 | "level_one_option": { 153 | "0": "symbol", 154 | "1": "description", 155 | "2": "bid-price", 156 | "3": "ask-price", 157 | "4": "last-price", 158 | "5": "high-price", 159 | "6": "low-price", 160 | "7": "close-price", 161 | "8": "total-volume", 162 | "9": "open-interest", 163 | "10": "volatility", 164 | "11": "quote-time", 165 | "12": "trade-time", 166 | "13": "money-intrinsic-value", 167 | "14": "quote-day", 168 | "15": "trade-day", 169 | "16": "expiration-year", 170 | "17": "multiplier", 171 | "18": "digits", 172 | "19": "open-price", 173 | "20": "bid-size", 174 | "21": "ask-size", 175 | "22": "last-size", 176 | "23": "net-change", 177 | "24": "strike-price", 178 | "25": "contract-type", 179 | "26": "underlying", 180 | "27": "expiration-month", 181 | "28": "deliverables", 182 | "29": "time-value", 183 | "30": "expiration-day", 184 | "31": "days-to-expiration", 185 | "32": "delta", 186 | "33": "gamma", 187 | "34": "theta", 188 | "35": "vega", 189 | "36": "rho", 190 | "37": "security-status", 191 | "38": "theoretical-option-value", 192 | "39": "underlying-price", 193 | "40": "uv-expiration-type", 194 | "41": "mark" 195 | }, 196 | "level_one_quote": { 197 | "0": "symbol", 198 | "1": "bid-price", 199 | "2": "ask-price", 200 | "3": "last-price", 201 | "4": "bid-size", 202 | "5": "ask-size", 203 | "6": "ask-id", 204 | "7": "bid-id", 205 | "8": "total-volume", 206 | "9": "last-size", 207 | "10": "trade-time", 208 | "11": "quote-time", 209 | "12": "high-price", 210 | "13": "low-price", 211 | "14": "bid-tick", 212 | "15": "close-price", 213 | "16": "exchange-id", 214 | "17": "marginable", 215 | "18": "shortable", 216 | "19": "island-bid", 217 | "20": "island-ask", 218 | "21": "island-volume", 219 | "22": "quote-day", 220 | "23": "trade-day", 221 | "24": "volatility", 222 | "25": "description", 223 | "26": "last-id", 224 | "27": "digits", 225 | "28": "open-price", 226 | "29": "net-change", 227 | "30": "52-week-high", 228 | "31": "52-week-low", 229 | "32": "pe-ratio", 230 | "33": "dividend-amount", 231 | "34": "dividend-yield", 232 | "35": "island-bid-size", 233 | "36": "island-ask-size", 234 | "37": "nav", 235 | "38": "fund-price", 236 | "39": "exchange-name", 237 | "40": "dividend-date", 238 | "41": "regular-market-quote", 239 | "42": "regular-market-trade", 240 | "43": "regular-market-last-price", 241 | "44": "regular-market-last-size", 242 | "45": "regular-market-trade-time", 243 | "46": "regular-market-trade-day", 244 | "47": "regular-market-net-change", 245 | "48": "security-status", 246 | "49": "mark", 247 | "50": "quote-time-in-long", 248 | "51": "trade-time-in-long", 249 | "52": "regular-market-trade-time-in-long" 250 | }, 251 | "news_headline": { 252 | "0": "symbol", 253 | "1": "error-code", 254 | "2": "story-datetime", 255 | "3": "headline-id", 256 | "4": "status", 257 | "5": "headline", 258 | "6": "story-id", 259 | "7": "count-for-keyword", 260 | "8": "keyword-array", 261 | "9": "is-hot", 262 | "10": "story-source" 263 | }, 264 | "qos_request": { 265 | "0": "express", 266 | "1": "real-time", 267 | "2": "fast", 268 | "3": "moderate", 269 | "4": "slow", 270 | "5": "delayed" 271 | }, 272 | "timesale": { 273 | "0": "symbol", 274 | "1": "trade-time", 275 | "2": "last-price", 276 | "3": "last-size", 277 | "4": "last-sequence" 278 | }, 279 | "chart_equity": { 280 | "seq": "chart-sequence", 281 | "key": "symbol", 282 | "1": "open-price", 283 | "2": "high-price", 284 | "3": "low-price", 285 | "4": "close_price", 286 | "5": "volume", 287 | "6": "sequence", 288 | "7": "chart_time", 289 | "8": "chart_day" 290 | }, 291 | "chart_options": { 292 | "seq": "chart-sequence", 293 | "key": "key", 294 | "1": "open-price", 295 | "2": "high-price", 296 | "3": "low-price", 297 | "4": "close_price", 298 | "5": "volume", 299 | "6": "sequence", 300 | "7": "chart_time", 301 | "8": "chart_day" 302 | }, 303 | "chart_futures": { 304 | "seq": "chart-sequence", 305 | "key": "key", 306 | "1": "open-price", 307 | "2": "high-price", 308 | "3": "low-price", 309 | "4": "close_price", 310 | "5": "volume", 311 | "6": "sequence", 312 | "7": "chart_time", 313 | "8": "chart_day" 314 | }, 315 | "level_two_quotes": { 316 | "0": "key", 317 | "1": "time", 318 | "2": "data" 319 | }, 320 | "level_two_nyse": { 321 | "0": "key", 322 | "1": "time", 323 | "2": "data" 324 | }, 325 | "level_two_options": { 326 | "0": "key", 327 | "1": "time", 328 | "2": "data" 329 | }, 330 | "level_two_forex": { 331 | "0": "key", 332 | "1": "time", 333 | "2": "data" 334 | }, 335 | "level_two_nasdaq": { 336 | "0": "key", 337 | "1": "time", 338 | "2": "data" 339 | }, 340 | "level_two_futures": { 341 | "0": "key", 342 | "1": "time", 343 | "2": "data" 344 | } 345 | } 346 | 347 | LEVEL_ONE_QUOTE_KEY_LIST = list(STREAM_FIELD_IDS['level_one_quote'].keys()) 348 | LEVEL_ONE_QUOTE_VALUE_LIST = list(STREAM_FIELD_IDS['level_one_quote'].values()) 349 | 350 | CSV_FIELD_KEYS = { 351 | "ACTIVES_NASDAQ": { 352 | "key": "key", 353 | "1": "data" 354 | }, 355 | "ACTIVES_OTCBB": { 356 | "key": "key", 357 | "1": "data" 358 | }, 359 | "ACTIVES_NYSE": { 360 | "key": "key", 361 | "1": "data" 362 | }, 363 | "ACTIVES_OPTIONS": { 364 | "key": "key", 365 | "1": "data" 366 | }, 367 | "CHART_EQUITY": { 368 | "seq": "chart-sequence", 369 | "key": "symbol", 370 | "1": "chart-time", 371 | "2": "open-price", 372 | "3": "high-price", 373 | "4": "low-price", 374 | "5": "close-price", 375 | "6": "volume", 376 | "7": "chart-time", 377 | "8": "chart-day" 378 | }, 379 | "CHART_FUTURES": { 380 | "seq": "chart-sequence", 381 | "key": "symbol", 382 | "1": "chart-time", 383 | "2": "open-price", 384 | "3": "high-price", 385 | "4": "low-price", 386 | "5": "close-price", 387 | "6": "volume" 388 | }, 389 | "CHART_OPTIONS": { 390 | "seq": "chart-sequence", 391 | "key": "symbol", 392 | "1": "chart-time", 393 | "2": "open-price", 394 | "3": "high-price", 395 | "4": "low-price", 396 | "5": "close-price", 397 | "6": "volume" 398 | }, 399 | "CHART_HISTORY": { 400 | "seq": "chart-sequence", 401 | "key": "symbol", 402 | "1": "chart-time", 403 | "2": "open-price", 404 | "3": "high-price", 405 | "4": "low-price", 406 | "5": "close-price", 407 | "6": "volume", 408 | "7": "chart-time", 409 | "8": "chart-day" 410 | }, 411 | "CHART_HISTORY_FUTURES": { 412 | "seq": "chart-sequence", 413 | "key": "symbol", 414 | "0": "key", 415 | "1": "chart-time", 416 | "2": "open-price", 417 | "3": "high-price", 418 | "4": "low-price", 419 | "5": "close-price", 420 | "6": "volume", 421 | "7": "chart-time", 422 | "8": "chart-day" 423 | }, 424 | "LEVELONE_FOREX": { 425 | "1": "bid-price", 426 | "10": "high-price", 427 | "11": "low-price", 428 | "12": "close-price", 429 | "13": "exchange-id", 430 | "14": "description", 431 | "15": "open-price", 432 | "16": "net-change", 433 | "17": "percent-change", 434 | "18": "exchange-name", 435 | "19": "digits", 436 | "2": "ask-price", 437 | "20": "security-status", 438 | "21": "tick", 439 | "22": "tick-amount", 440 | "23": "product", 441 | "24": "trading-hours", 442 | "25": "is-tradable", 443 | "26": "market-maker", 444 | "27": "52-week-high", 445 | "28": "52-week-low", 446 | "29": "mark", 447 | "3": "last-price", 448 | "4": "bid-size", 449 | "5": "ask-size", 450 | "6": "total-volume", 451 | "7": "last-size", 452 | "8": "quote-time", 453 | "9": "trade-time", 454 | "assetMainType": "asset-main-type", 455 | "assetSubType": "asset-sub-type", 456 | "cusip": "cusip", 457 | "delayed": "delayed", 458 | "key": "symbol", 459 | }, 460 | "LEVELONE_FUTURES": { 461 | "1": "bid-price", 462 | "10": "quote-time", 463 | "11": "trade-time", 464 | "12": "high-price", 465 | "13": "low-price", 466 | "14": "close-price", 467 | "15": "exchange-id", 468 | "16": "description", 469 | "17": "last-id", 470 | "18": "open-price", 471 | "19": "net-change", 472 | "2": "ask-price", 473 | "20": "future-percent-change", 474 | "21": "exhange-name", 475 | "22": "security-status", 476 | "23": "open-interest", 477 | "24": "mark", 478 | "25": "tick", 479 | "26": "tick-amount", 480 | "27": "product", 481 | "28": "future-price-format", 482 | "29": "future-trading-hours", 483 | "3": "last-price", 484 | "30": "future-is-tradable", 485 | "31": "future-multiplier", 486 | "32": "future-is-active", 487 | "33": "future-settlement-price", 488 | "34": "future-active-symbol", 489 | "35": "future-expiration-date", 490 | "4": "bid-size", 491 | "5": "ask-size", 492 | "6": "ask-id", 493 | "7": "bid-id", 494 | "8": "total-volume", 495 | "9": "last-size", 496 | "assetMainType": "asset-main-type", 497 | "assetSubType": "asset-sub-type", 498 | "cusip": "cusip", 499 | "delayed": "delayed", 500 | "key": "symbol", 501 | }, 502 | "LEVELONE_FUTURES_OPTIONS": { 503 | "1": "bid-price", 504 | "10": "quote-time", 505 | "11": "trade-time", 506 | "12": "high-price", 507 | "13": "low-price", 508 | "14": "close-price", 509 | "15": "exchange-id", 510 | "16": "description", 511 | "17": "last-id", 512 | "18": "open-price", 513 | "19": "net-change", 514 | "2": "ask-price", 515 | "20": "future-percent-change", 516 | "21": "exhange-name", 517 | "22": "security-status", 518 | "23": "open-interest", 519 | "24": "mark", 520 | "25": "tick", 521 | "26": "tick-amount", 522 | "27": "product", 523 | "28": "future-price-format", 524 | "29": "future-trading-hours", 525 | "3": "last-price", 526 | "30": "future-is-tradable", 527 | "31": "future-multiplier", 528 | "32": "future-is-active", 529 | "33": "future-settlement-price", 530 | "34": "future-active-symbol", 531 | "35": "future-expiration-date", 532 | "4": "bid-size", 533 | "5": "ask-size", 534 | "6": "ask-id", 535 | "7": "bid-id", 536 | "8": "total-volume", 537 | "9": "last-size", 538 | "assetMainType": "asset-main-type", 539 | "assetSubType": "asset-sub-type", 540 | "cusip": "cusip", 541 | "delayed": "delayed", 542 | "key": "symbol", 543 | }, 544 | "OPTION": { 545 | "1": "description", 546 | "10": "volatility", 547 | "11": "quote-time", 548 | "12": "trade-time", 549 | "13": "money-intrinsic-value", 550 | "14": "quote-day", 551 | "15": "trade-day", 552 | "16": "expiration-year", 553 | "17": "multiplier", 554 | "18": "digits", 555 | "19": "open-price", 556 | "2": "bid-price", 557 | "20": "bid-size", 558 | "21": "ask-size", 559 | "22": "last-size", 560 | "23": "net-change", 561 | "24": "strike-price", 562 | "25": "contract-type", 563 | "26": "underlying", 564 | "27": "expiration-month", 565 | "28": "deliverables", 566 | "29": "time-value", 567 | "3": "ask-price", 568 | "30": "expiration-day", 569 | "31": "days-to-expiration", 570 | "32": "delta", 571 | "33": "gamma", 572 | "34": "theta", 573 | "35": "vega", 574 | "36": "rho", 575 | "37": "security-status", 576 | "38": "theoretical-option-value", 577 | "39": "underlying-price", 578 | "4": "last-price", 579 | "40": "uv-expiration-type", 580 | "41": "mark", 581 | "5": "high-price", 582 | "6": "low-price", 583 | "7": "close-price", 584 | "8": "total-volume", 585 | "9": "open-interest", 586 | "assetMainType": "asset-main-type", 587 | "assetSubType": "asset-sub-type", 588 | "cusip": "cusip", 589 | "delayed": "delayed", 590 | "key": "symbol", 591 | }, 592 | "QUOTE": { 593 | "10": "trade-time", 594 | "11": "quote-time", 595 | "12": "high-price", 596 | "13": "low-price", 597 | "14": "bid-tick", 598 | "15": "close-price", 599 | "16": "exchange-id", 600 | "17": "marginable", 601 | "18": "shortable", 602 | "1": "bid-price", 603 | "19": "island-bid", 604 | "20": "island-ask", 605 | "21": "island-volume", 606 | "22": "quote-day", 607 | "23": "trade-day", 608 | "24": "volatility", 609 | "25": "description", 610 | "26": "last-id", 611 | "27": "digits", 612 | "28": "open-price", 613 | "2": "ask-price", 614 | "29": "net-change", 615 | "30": "52-week-high", 616 | "31": "52-week-low", 617 | "32": "pe-ratio", 618 | "33": "dividend-amount", 619 | "34": "dividend-yield", 620 | "35": "island-bid-size", 621 | "36": "island-ask-size", 622 | "37": "nav", 623 | "38": "fund-price", 624 | "3": "last-price", 625 | "39": "exchange-name", 626 | "40": "dividend-date", 627 | "41": "regular-market-quote", 628 | "42": "regular-market-trade", 629 | "43": "regular-market-last-price", 630 | "44": "regular-market-last-size", 631 | "45": "regular-market-trade-time", 632 | "46": "regular-market-trade-day", 633 | "47": "regular-market-net-change", 634 | "48": "security-status", 635 | "4": "bid-size", 636 | "49": "mark", 637 | "50": "quote-time-in-long", 638 | "51": "trade-time-in-long", 639 | "5": "ask-size", 640 | "6": "ask-id", 641 | "7": "bid-id", 642 | "8": "total-volume", 643 | "9": "last-size", 644 | "assetMainType": "asset-main-type", 645 | "assetSubType": "asset-sub-type", 646 | "cusip": "cusip", 647 | "delayed": "delayed", 648 | "key": "symbol" 649 | }, 650 | "NEWS_HEADLINE": { 651 | "1": "error-code", 652 | "10": "story-source", 653 | "2": "story-datetime", 654 | "3": "headline-id", 655 | "4": "status", 656 | "5": "headline", 657 | "6": "story-id", 658 | "7": "count-for-keyword", 659 | "8": "keyword-array", 660 | "9": "is-hot", 661 | "key": "symbol", 662 | "seq": "sequence" 663 | }, 664 | "TIMESALE_EQUITY": { 665 | "1": "trade-time", 666 | "2": "last-price", 667 | "3": "last-size", 668 | "4": "last-sequence", 669 | "key": "symbol", 670 | "seq": "sequence" 671 | }, 672 | "TIMESALE_FUTURES": { 673 | "1": "trade-time", 674 | "2": "last-price", 675 | "3": "last-size", 676 | "4": "last-sequence", 677 | "key": "symbol", 678 | "seq": "sequence" 679 | }, 680 | "TIMESALE_FOREX": { 681 | "1": "trade-time", 682 | "2": "last-price", 683 | "3": "last-size", 684 | "4": "last-sequence", 685 | "key": "symbol", 686 | "seq": "sequence" 687 | }, 688 | "TIMESALE_OPTIONS": { 689 | "1": "trade-time", 690 | "2": "last-price", 691 | "3": "last-size", 692 | "4": "last-sequence", 693 | "key": "symbol", 694 | "seq": "sequence" 695 | }, 696 | } 697 | 698 | CSV_FIELD_KEYS_LEVEL_2 = { 699 | "NASDAQ_BOOK": "nested", 700 | "OPTIONS_BOOK": "nested", 701 | "LISTED_BOOK": "nested", 702 | "FUTURES_BOOK": "nested" 703 | } 704 | 705 | #LEVEL_ONE_QUOTE_NAME_TO_ID = { 706 | # 'symbol': '0', 707 | # 'bid-price': '1', 708 | # 'ask-price': '2', 'last-price': '3', 'bid-size': '4', 'ask-size': '5', 'ask-id': '6', 'bid-id': '7', 'total-volume': '8', 'last-size': '9', 'trade-time': '10', 'quote-time': '11', 'high-price': '12', 'low-price': '13', 'bid-tick': '14', 'close-price': '15', 'exchange-id': '16', 'marginable': '17', 'shortable': '18', 'island-bid': '19', 'island-ask': '20', 'island-volume': '21', 'quote-day': '22', 'trade-day': '23', 'volatility': '24', 'description': '25', 'last-id': '26', 'digits': '27', 'open-price': '28', 'net-change': '29', '52-week-high': '30', '52-week-low': '31', 'pe-ratio': '32', 'dividend-amount': '33', 'dividend-yield': '34', 'island-bid-size': '35', 'island-ask-size': '36', 'nav': '37', 'fund-price': '38', 'exchange-name': '39', 'dividend-date': '40', 'regular-market-quote': '41', 'regular-market-trade': '42', 'regular-market-last-price': '43', 'regular-market-last-size': '44', 'regular-market-trade-time': '45', 'regular-market-trade-day': '46', 'regular-market-net-change': '47', 'security-status': '48', 'mark': '49', 'quote-time-in-long': '50', 'trade-time-in-long': '51', 'regular-market-trade-time-in-long': '52'} -------------------------------------------------------------------------------- /python/tdapi/stream.json: -------------------------------------------------------------------------------- 1 | {'data': [{'service': 'NASDAQ_BOOK', 'timestamp': 1597079583463, 'command': 'SUBS', 'content': [{'key': 'MSFT', '1': 1597079583385, '2': [{'0': 208.38, '1': 300, '2': 1, '3': [{'0': 'NSDQ', '1': 300, '2': 43983297}]}, {'0': 208.37, '1': 500, '2': 2, '3': [{'0': 'edgx', '1': 300, '2': 43983306}, {'0': 'batx', '1': 200, '2': 43983307}]}, {'0': 208.36, '1': 200, '2': 1, '3': [{'0': 'arcx', '1': 200, '2': 43983307}]}, {'0': 208.34, '1': 100, '2': 1, '3': [{'0': 'bosx', '1': 100, '2': 43957638}]}, {'0': 208.23, '1': 100, '2': 1, '3': [{'0': 'phlx', '1': 100, '2': 43970607}]}, {'0': 208.22, '1': 100, '2': 1, '3': [{'0': 'baty', '1': 100, '2': 43983297}]}, {'0': 208.19, '1': 100, '2': 1, '3': [{'0': 'edga', '1': 100, '2': 43953408}]}, {'0': 208.08, '1': 500, '2': 1, '3': [{'0': 'nyse', '1': 500, '2': 43953165}]}, {'0': 207.28, '1': 6900, '2': 1, '3': [{'0': 'mwse', '1': 6900, '2': 43940135}]}, {'0': 207.0, '1': 200, '2': 2, '3': [{'0': 'amex', '1': 100, '2': 43873565}, {'0': 'iexg', '1': 100, '2': 43895960}]}, {'0': 205.0, '1': 200, '2': 1, '3': [{'0': 'cinn', '1': 200, '2': 43884775}]}, {'0': 204.77, '1': 200, '2': 1, '3': [{'0': 'FLTG', '1': 200, '2': 43800074}]}, {'0': 204.27, '1': 300, '2': 1, '3': [{'0': 'GSCO', '1': 300, '2': 35530675}]}], '3': [{'0': 208.4, '1': 1000, '2': 2, '3': [{'0': 'NSDQ', '1': 900, '2': 43949244}, {'0': 'edgx', '1': 100, '2': 43983207}]}, {'0': 208.41, '1': 100, '2': 1, '3': [{'0': 'batx', '1': 100, '2': 43983312}]}, {'0': 208.42, '1': 100, '2': 1, '3': [{'0': 'arcx', '1': 100, '2': 43983306}]}, {'0': 208.5, '1': 100, '2': 1, '3': [{'0': 'nyse', '1': 100, '2': 43944071}]}, {'0': 208.52, '1': 100, '2': 1, '3': [{'0': 'baty', '1': 100, '2': 43968606}]}, {'0': 208.57, '1': 200, '2': 2, '3': [{'0': 'phlx', '1': 100, '2': 43939448}, {'0': 'edga', '1': 100, '2': 43939548}]}, {'0': 208.63, '1': 100, '2': 1, '3': [{'0': 'bosx', '1': 100, '2': 43939604}]}, {'0': 210.0, '1': 100, '2': 1, '3': [{'0': 'amex', '1': 100, '2': 43630096}]}, {'0': 210.57, '1': 100, '2': 1, '3': [{'0': 'FLTG', '1': 100, '2': 35530197}]}, {'0': 212.0, '1': 100, '2': 1, '3': [{'0': 'iexg', '1': 100, '2': 43879561}]}, {'0': 212.43, '1': 300, '2': 1, '3': [{'0': 'GSCO', '1': 300, '2': 35159121}]}, {'0': 215.97, '1': 200, '2': 2, '3': [{'0': 'ETMM', '1': 100, '2': 35228391}, {'0': 'SUFI', '1': 100, '2': 35228391}]}]}]}, {'service': 'TOTAL_VIEW', 'timestamp': 1597079583463, 'command': 'SUBS', 'content': [{'key': 'AAPL>L2', '1': 47583371, '2': '15;14;451.09;300;2;cinn;200;43981934;batx;100;43981921;451.08;100;1;NSDQ;100;43983306;451.07;100;1;edgx;100;43983285;451.02;100;1;bosx;100;43981705;450.98;100;1;arcx;100;43981933;450.43;100;1;nyse;100;43980189;450.31;100;1;baty;100;43954915;443.77;100;1;FLTG;100;43767747;443.0;100;1;phlx;100;43978440;440.2;100;1;GSCO;100;42315900;440.0;100;1;amex;100;43796670;435.02;100;1;edga;100;43982421;432.62;100;1;XGWD;100;42364054;432.43;100;1;SUFI;100;42317130;'}]}, {'service': 'LISTED_BOOK', 'timestamp': 1597079583463, 'command': 'SUBS', 'content': [{'key': 'IBM', '1': 1597079583059, '2': [{'0': 126.28, '1': 200, '2': 1, '3': [{'0': 'NSDQ', '1': 200, '2': 43975064}]}, {'0': 126.27, '1': 900, '2': 2, '3': [{'0': 'BATX', '1': 500, '2': 43982059}, {'0': 'EDGX', '1': 400, '2': 43982059}]}, {'0': 126.26, '1': 200, '2': 2, '3': [{'0': 'CINN', '1': 100, '2': 43892820}, {'0': 'BATY', '1': 100, '2': 43904691}]}, {'0': 126.23, '1': 200, '2': 1, '3': [{'0': 'NYSE', '1': 200, '2': 43967761}]}, {'0': 126.22, '1': 100, '2': 1, '3': [{'0': 'ARCX', '1': 100, '2': 43983039}]}, {'0': 126.18, '1': 100, '2': 1, '3': [{'0': 'EDGA', '1': 100, '2': 43919632}]}, {'0': 126.14, '1': 400, '2': 1, '3': [{'0': 'PHLX', '1': 400, '2': 43893822}]}, {'0': 126.09, '1': 100, '2': 1, '3': [{'0': 'BOSX', '1': 100, '2': 43715538}]}], '3': [{'0': 126.3, '1': 500, '2': 3, '3': [{'0': 'BATX', '1': 200, '2': 43968557}, {'0': 'NSDQ', '1': 200, '2': 43968558}, {'0': 'NYSE', '1': 100, '2': 43968928}]}, {'0': 126.31, '1': 400, '2': 1, '3': [{'0': 'EDGX', '1': 400, '2': 43968558}]}, {'0': 126.32, '1': 200, '2': 2, '3': [{'0': 'ARCX', '1': 100, '2': 43966997}, {'0': 'BATY', '1': 100, '2': 43980010}]}, {'0': 126.43, '1': 100, '2': 1, '3': [{'0': 'EDGA', '1': 100, '2': 43966997}]}, {'0': 126.46, '1': 400, '2': 1, '3': [{'0': 'PHLX', '1': 400, '2': 43932812}]}, {'0': 126.5, '1': 100, '2': 1, '3': [{'0': 'BOSX', '1': 100, '2': 43959406}]}, {'0': 127.24, '1': 100, '2': 1, '3': [{'0': 'IEXG', '1': 100, '2': 43390772}]}, {'0': 130.88, '1': 100, '2': 1, '3': [{'0': 'CINN', '1': 100, '2': 43931812}]}]}]}, {'service': 'QUOTE', 'timestamp': 1597079583463, 'command': 'SUBS', 'content': [{'key': 'TSLA', '1': 1435.58, '2': 1437.6899, '3': 1437, '6': 'P', '7': 'X', '8': 5268991, '10': 47583, '11': 47583, '24': 0.0408, '26': 'P', '29': -15.71, '43': 1437, '45': 47583, '47': -15.71, '49': 1437}, {'key': 'MSFT', '1': 208.37, '2': 208.39, '4': 4, '5': 4, '6': 'Z', '8': 20095297, '11': 47583}, {'key': 'SPY', '2': 335.26, '4': 3, '5': 2, '6': 'T', '7': 'A', '8': 20028624, '11': 47583, '26': 'T'}, {'key': 'SDS', '11': 47583}, {'key': 'IVV', '2': 336.63, '4': 3, '6': 'T', '7': 'P', '11': 47583}]}]} -------------------------------------------------------------------------------- /python/tdapi/td_client.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | import threading 4 | import json 5 | import requests 6 | import time 7 | import urllib 8 | 9 | from datetime import datetime 10 | from typing import List 11 | from typing import Dict 12 | from typing import Set 13 | from typing import Union 14 | from collections.abc import Iterable 15 | 16 | import websockets 17 | 18 | from .td_oauth import silent_sso, run_full_oauth_subprocess 19 | from .td_oauth import load_token 20 | from .fields import CSV_FIELD_KEYS 21 | from .fields import CSV_FIELD_KEYS_LEVEL_2 22 | from .fields import STREAM_FIELD_IDS 23 | from .fields import LEVEL_ONE_QUOTE_KEY_LIST 24 | from .fields import LEVEL_ONE_QUOTE_VALUE_LIST 25 | 26 | from .td_config import API_ENDPOINT 27 | from .td_config import API_VERSION 28 | from .td_config import TOKEN_FILE_NAME 29 | 30 | logger = logging.getLogger(__name__) 31 | logger.setLevel(logging.ERROR) 32 | logger.addHandler(logging.StreamHandler()) 33 | 34 | class TDWSConn(object): 35 | def __init__(self, websocket_url): 36 | """Constructor""" 37 | super(TDWSConn, self).__init__() 38 | self.ws = None 39 | 40 | async def send(self, message) -> None: 41 | if self.ws: 42 | await self.ws.send(message) 43 | 44 | async def recv_message(self) -> str: 45 | message = await self.ws.recv() 46 | return message 47 | 48 | async def start(self, url, initial_request) -> None: 49 | try: 50 | self.ws = await websockets.connect(url) 51 | await self.send(initial_request) 52 | await asyncio.sleep(0.5) 53 | except Exception as e: 54 | logger.error("Failed to connect the server: {}".format(repr(e))) 55 | 56 | async def close(self) -> None: 57 | if self.ws: 58 | await self.ws.close() 59 | self.ws = None 60 | 61 | @property 62 | def closed(self): 63 | if self.ws: 64 | return self.ws.closed 65 | else: 66 | return True 67 | 68 | class TDQuote(): 69 | def __init__(self, ticker: str, fields: dict, msg_timestamp: int) -> None: 70 | self._ticker = ticker 71 | self._fields = fields 72 | self._timestamp = msg_timestamp 73 | 74 | @property 75 | def time_recieved(self, as_datetime: bool = False) -> Union[datetime, int]: 76 | if as_datetime: 77 | return datetime.fromtimestamp(t=self._timestamp) 78 | else: 79 | return self._timestamp 80 | 81 | @property 82 | def ticker(self) -> str: 83 | return self._ticker 84 | 85 | @property 86 | def fields(self) -> Dict: 87 | return self._fields 88 | 89 | def _create_quotes_from_content(msg_content: List[dict], msg_timestamp: int) -> List[TDQuote]: 90 | tdquotes = [] 91 | for c in msg_content: 92 | ticker = None 93 | fields_dict = {} 94 | 95 | for k, v in c.items(): 96 | if k == 'key': 97 | ticker = v 98 | continue 99 | 100 | if k in LEVEL_ONE_QUOTE_KEY_LIST: 101 | field_key = LEVEL_ONE_QUOTE_VALUE_LIST[LEVEL_ONE_QUOTE_KEY_LIST.index(k)] 102 | else: 103 | field_key = k 104 | 105 | fields_dict[field_key] = v 106 | 107 | tdquotes.append(TDQuote(ticker, fields_dict, msg_timestamp)) 108 | 109 | return tdquotes 110 | 111 | 112 | class TDClient(): 113 | """ 114 | TD Ameritrade Streaming API Client Class. 115 | 116 | Implements a Websocket object that connects to the TD Streaming API, submits requests, 117 | handles messages, and streams data back to the user. 118 | """ 119 | 120 | def __init__(self, websocket_url: str, user_principal_data: dict, credentials: dict) -> None: 121 | """Initalizes the Streaming Client. 122 | 123 | Initalizes the Client Object and defines different components that will be needed to 124 | make a connection with the TD Streaming API. 125 | 126 | Arguments: 127 | ---- 128 | websocket_url {str} -- The websocket URL that is returned from a Get_User_Prinicpals Request. 129 | 130 | user_principal_data {dict} -- The data that was returned from the "Get_User_Principals" request. 131 | Contains the info need for the account info. 132 | 133 | credentials {dict} -- A credentials dictionary that is created from the "create_streaming_session" 134 | method. 135 | 136 | Usage: 137 | ---- 138 | 139 | >>> td_session = TDClient( 140 | client_id='', 141 | redirect_uri='', 142 | credentials_path='' 143 | ) 144 | >>> td_session.login() 145 | >>> td_stream_session = td_session.create_streaming_session() 146 | 147 | """ 148 | 149 | self.websocket_url = "wss://{}/ws".format(websocket_url) 150 | self.credentials = credentials 151 | self.user_principal_data = user_principal_data 152 | 153 | self.conn = TDWSConn(self.websocket_url) 154 | self.async_thread = None 155 | self.async_loop = None 156 | self.message_queue = None 157 | self.sub_queue = None 158 | self.shutdown = False 159 | self.connect_event = threading.Event() 160 | self.on_quote_received = None 161 | 162 | # this will hold all of our requests 163 | self.data_requests = {"requests": []} 164 | self.fields_ids_dictionary = STREAM_FIELD_IDS 165 | 166 | self._requestid = 0 167 | self.subscriptions = {} 168 | self._outstanding_requests = set() 169 | 170 | def _async_thread_handler(self) -> None: 171 | logger.debug("_async_thread_handler") 172 | try: 173 | asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) 174 | loop = asyncio.new_event_loop() 175 | asyncio.set_event_loop(loop) 176 | self.async_loop = loop 177 | self.message_queue = asyncio.Queue() 178 | self.sub_queue = asyncio.Queue() 179 | login_request = self._build_login_request() 180 | loop.run_until_complete(self.conn.start(self.websocket_url, login_request)) 181 | self.connect_event.set() 182 | 183 | send_messages_coro = self._send_message_async() 184 | send_sub_coro = self._send_sub_message_async() 185 | recv_messages_coro = self._recv_message_async() 186 | loop.run_until_complete(asyncio.gather(send_messages_coro, send_sub_coro, recv_messages_coro)) 187 | loop.close() 188 | except websockets.exceptions.ConnectionClosedOK: 189 | pass 190 | except Exception as e: 191 | logger.error("_async_thread_handler: {}".format(repr(e)), exc_info=True) 192 | self.connect_event.set() 193 | 194 | async def _send_message_async(self) -> None: 195 | while not self.shutdown: 196 | message = await self.message_queue.get() 197 | if message: 198 | await self.conn.send(message) 199 | else: 200 | await self.conn.close() 201 | 202 | async def _send_sub_message_async(self) -> None: 203 | await asyncio.sleep(1) 204 | while not self.shutdown: 205 | while len(self._outstanding_requests) > 0: 206 | await asyncio.sleep(0.5) 207 | message = await self.sub_queue.get() 208 | if not message: 209 | await self.conn.close() 210 | return 211 | 212 | symbols = set() 213 | while True: 214 | if message['type'] == 'subscribe': 215 | symbol = message['symbol'] 216 | field = message['field'] 217 | 218 | if symbol in self.subscriptions: 219 | self.subscriptions[symbol].add(field) 220 | else: 221 | self.subscriptions[symbol] = set(['0', field]) 222 | 223 | symbols.add(symbol) 224 | if len(symbols) == 100: 225 | break 226 | if self.sub_queue.empty(): 227 | break 228 | 229 | message = await self.sub_queue.get() 230 | 231 | payload = self._level_one_quotes(symbols) 232 | 233 | if payload: 234 | await self.conn.send(payload) 235 | 236 | async def _recv_message_async(self) -> None: 237 | # message types: "notify", "response", "data" 238 | # {"response":[{"service":"ADMIN","requestid":"0","command":"LOGIN","timestamp":1597561577408,"content":{"code":0,"msg":"08-4"}}]} 239 | while not self.shutdown: 240 | message = await self.conn.recv_message() 241 | decoded_message = message.encode( 242 | 'utf-8').replace( 243 | b'\xef\xbf\xbd', 244 | bytes('"None"', 'utf-8') 245 | ).decode('utf-8') 246 | decoded_message = json.loads(decoded_message) 247 | 248 | if 'data' in decoded_message: 249 | self._handle_data(decoded_message['data']) 250 | elif 'response' in decoded_message: 251 | for r in decoded_message['response']: 252 | self._outstanding_requests.remove(int(r['requestid'])) 253 | logging.debug(f'MsgRecv: response - {decoded_message}') 254 | elif 'notify' in decoded_message: 255 | logging.debug(f'MsgRecv: notify - {decoded_message}') 256 | else: 257 | logging.warn(f'MsgRecv: unknown - {decoded_message}') 258 | 259 | def _new_request_template(self) -> dict: 260 | """Serves as a template to build new service requests. 261 | 262 | This takes the Request template and populates the required fields 263 | for a subscription request. 264 | 265 | Returns: 266 | ---- 267 | {dict} -- The service request with the standard fields filled out. 268 | """ 269 | 270 | # first get the current service request count 271 | #service_count = len(self.data_requests['requests']) + 1 272 | self._requestid += 1 273 | self._outstanding_requests.add(self._requestid) 274 | 275 | request = { 276 | "service": None, 277 | "requestid": str(self._requestid), 278 | "command": None, 279 | "account": self.user_principal_data['accounts'][0]['accountId'], 280 | "source": self.user_principal_data['streamerInfo']['appId'], 281 | "parameters": { 282 | "keys": None, 283 | "fields": None 284 | } 285 | } 286 | 287 | return request 288 | 289 | def _validate_argument(self, argument, endpoint: str) -> Union[List[str], str]: 290 | """Validate field arguments before submitting request. 291 | 292 | Arguments: 293 | --- 294 | argument {Union[str, int]} -- Either a single argument or a list of arguments that are 295 | fields to be requested. 296 | 297 | endpoint {str} -- The subscription service the request will be sent to. For example, 298 | "level_one_quote". 299 | 300 | Returns: 301 | ---- 302 | Union[List[str], str] -- The field or fields that have been validated. 303 | """ 304 | 305 | # initalize a new list. 306 | arg_list = [] 307 | 308 | # see if the argument is a list or not. 309 | if isinstance(argument, Iterable): 310 | 311 | for arg in argument: 312 | 313 | arg_str = str(arg) 314 | 315 | if arg_str in LEVEL_ONE_QUOTE_KEY_LIST: 316 | arg_list.append(arg_str) 317 | elif arg_str in LEVEL_ONE_QUOTE_VALUE_LIST: 318 | key_value = LEVEL_ONE_QUOTE_KEY_LIST[LEVEL_ONE_QUOTE_VALUE_LIST.index(arg_str)] 319 | arg_list.append(key_value) 320 | 321 | return arg_list 322 | 323 | else: 324 | 325 | arg_str = str(argument) 326 | key_list = list(self.fields_ids_dictionary[endpoint].keys()) 327 | val_list = list(self.fields_ids_dictionary[endpoint].values()) 328 | 329 | if arg_str in key_list: 330 | return arg_str 331 | elif arg_str in val_list: 332 | key_value = key_list[val_list.index(arg_str)] 333 | return key_value 334 | 335 | def _build_login_request(self) -> str: 336 | """Builds the Login request for the streamer. 337 | 338 | Builds the login request dictionary that will 339 | be used as the first service request with the 340 | streaming API. 341 | 342 | Returns: 343 | ---- 344 | [str] -- A JSON string with the login details. 345 | 346 | """ 347 | 348 | self._outstanding_requests.add(0) 349 | 350 | # define a request 351 | login_request = { 352 | "requests": [ 353 | { 354 | "service": "ADMIN", 355 | "requestid": "0", 356 | "command": "LOGIN", 357 | "account": self.user_principal_data['accounts'][0]['accountId'], 358 | "source": self.user_principal_data['streamerInfo']['appId'], 359 | "parameters": { 360 | "credential": urllib.parse.urlencode(self.credentials), 361 | "token": self.user_principal_data['streamerInfo']['token'], 362 | "version": "1.0", 363 | "qoslevel": "0" # 0 = Express (500 ms) 364 | } 365 | }, 366 | ] 367 | } 368 | 369 | return json.dumps(login_request, separators=(',', ':')) 370 | 371 | def _handle_data(self, data_components: List) -> None: 372 | try: 373 | for component in data_components: 374 | if component['service'] == 'QUOTE' and component['command'] == 'SUBS': 375 | quotes = _create_quotes_from_content(component['content'], component['timestamp']) 376 | self.on_quote_received(quotes) 377 | except Exception as e: 378 | logging.error(repr(e)) 379 | 380 | def connect(self, on_quote_received): 381 | logging.debug('connect') 382 | self.on_quote_received = on_quote_received 383 | self.async_thread = threading.Thread(target=self._async_thread_handler) 384 | self.async_thread.start() 385 | self.connect_event.wait() 386 | 387 | def quality_of_service(self, qos_level: str) -> None: 388 | """Quality of Service Subscription. 389 | 390 | Allows the user to set the speed at which they recieve messages 391 | from the TD Server. 392 | 393 | Arguments: 394 | ---- 395 | qos_level {str} -- The Quality of Service level that you wish to set. 396 | Ranges from 0 to 5 where 0 is the fastest and 5 is the slowest. 397 | 398 | Raises: 399 | ---- 400 | ValueError: Error if no field is passed through. 401 | 402 | Usage: 403 | ---- 404 | >>> td_session = TDClient( 405 | client_id='', 406 | redirect_uri='', 407 | credentials_path='' 408 | ) 409 | 410 | >>> td_session.login() 411 | >>> td_stream_session = td_session.create_streaming_session() 412 | >>> td_stream_session.quality_of_service(qos_level='express') 413 | >>> td_stream_session.stream() 414 | """ 415 | # valdiate argument. 416 | qos_level = self._validate_argument(argument=qos_level, endpoint='qos_request') 417 | 418 | if qos_level is not None: 419 | 420 | # Build the request 421 | request = self._new_request_template() 422 | request['service'] = 'ADMIN' 423 | request['command'] = 'QOS' 424 | request['parameters']['qoslevel'] = qos_level 425 | self.async_loop.call_soon_threadsafe(lambda: self.message_queue.put_nowait(json.dumps({"requests":[request]}, separators=(',', ':')))) 426 | 427 | else: 428 | raise ValueError('No Quality of Service Level provided.') 429 | 430 | def _level_one_quote(self, symbol: str, fields: Union[List[str], List[int]]) -> str: 431 | """ 432 | Represents the LEVEL ONE QUOTES endpoint for the TD Streaming API. This 433 | will return quotes for a given list of symbols along with specified field information. 434 | 435 | NAME: symbols 436 | DESC: A List of symbols you wish to stream quotes for. 437 | TYPE: List 438 | 439 | NAME: fields 440 | DESC: The fields you want returned from the Endpoint, can either be the numeric representation 441 | or the key value representation. For more info on fields, refer to the documentation. 442 | TYPE: List | List 443 | """ 444 | 445 | # valdiate argument. 446 | fields = self._validate_argument( 447 | argument=fields, endpoint='level_one_quote') 448 | 449 | # Build the request 450 | request = self._new_request_template() 451 | request['service'] = 'QUOTE' 452 | request['command'] = 'SUBS' 453 | request['parameters']['keys'] = symbol 454 | request['parameters']['fields'] = ','.join(fields) 455 | 456 | return json.dumps({"requests":[request]}, separators=(',', ':')) 457 | 458 | def _level_one_quotes(self, symbols: Set) -> None: 459 | #quote_requests = [] 460 | all_fields = set() 461 | for symbol in symbols: 462 | all_fields.update(self.subscriptions[symbol]) 463 | 464 | fields = self._validate_argument( 465 | argument=all_fields, endpoint='level_one_quote') 466 | fields.sort(key=int) 467 | 468 | # Build the request 469 | request = self._new_request_template() 470 | request['service'] = 'QUOTE' 471 | request['command'] = 'SUBS' 472 | request['parameters']['keys'] = ','.join(symbols) 473 | request['parameters']['fields'] = ','.join(fields) 474 | 475 | return json.dumps({"requests":[request]}, separators=(',', ':')) 476 | #return json.dumps({"requests":quote_requests}, separators=(',', ':')) 477 | 478 | def send(self, msg): 479 | if not msg: 480 | self.async_loop.call_soon_threadsafe(lambda: self.message_queue.put_nowait(None)) 481 | return 482 | 483 | if msg['type'] == 'subscribe': 484 | self.async_loop.call_soon_threadsafe(lambda: self.sub_queue.put_nowait(msg)) 485 | 486 | def close(self): 487 | self.shutdown = True 488 | self.send(None) 489 | while not self.conn.closed: 490 | time.sleep(0.3) 491 | self.async_loop.stop() 492 | self.async_loop.close() 493 | if self.async_thread: 494 | self.async_thread.join() 495 | 496 | def get_user_principals(token, fields: List[str]) -> Dict: 497 | """Returns User Principal details. 498 | 499 | Documentation: 500 | ---- 501 | https://developer.tdameritrade.com/user-principal/apis/get/userprincipals-0 502 | 503 | Arguments: 504 | ---- 505 | 506 | fields: A comma separated String which allows one to specify additional fields to return. None of 507 | these fields are returned by default. Possible values in this String can be: 508 | 509 | 1. streamerSubscriptionKeys 510 | 2. streamerConnectionInfo 511 | 3. preferences 512 | 4. surrogateIds 513 | 514 | Usage: 515 | ---- 516 | >>> td_client.get_user_principals(fields=['preferences']) 517 | >>> td_client.get_user_principals(fields=['preferences','streamerConnectionInfo']) 518 | """ 519 | 520 | # define the endpoint 521 | endpoint = 'userprincipals' 522 | 523 | # build the params dictionary 524 | params = { 525 | 'fields': ','.join(fields) 526 | } 527 | 528 | parts = [API_ENDPOINT, API_VERSION, endpoint] 529 | url = '/'.join(parts) 530 | 531 | headers = { 532 | 'Authorization': 'Bearer {token}'.format(token=token['access_token']) 533 | } 534 | 535 | # Define a new session. 536 | request_session = requests.Session() 537 | request_session.verify = True 538 | 539 | # Define a new request. 540 | request_request = requests.Request( 541 | method='GET', 542 | headers=headers, 543 | url=url, 544 | params=params, 545 | ).prepare() 546 | 547 | # Send the request. 548 | response: requests.Response = request_session.send(request=request_request) 549 | 550 | request_session.close() 551 | 552 | # grab the status code 553 | status_code = response.status_code 554 | 555 | # grab the response headers. 556 | response_headers = response.headers 557 | 558 | if response.ok: 559 | return response.json() 560 | else: 561 | return None 562 | 563 | 564 | def _create_token_timestamp(token_timestamp: str) -> int: 565 | """Parses the token and converts it to a timestamp. 566 | 567 | Arguments: 568 | ---- 569 | token_timestamp {str} -- The timestamp returned from the get_user_principals endpoint. 570 | 571 | Returns: 572 | ---- 573 | int -- the token timestamp as an integer. 574 | """ 575 | 576 | token_timestamp = datetime.strptime(token_timestamp, "%Y-%m-%dT%H:%M:%S%z") 577 | token_timestamp = int(token_timestamp.timestamp()) * 1000 578 | 579 | return token_timestamp 580 | 581 | def create_td_client() -> TDClient: 582 | try: 583 | if not silent_sso(): 584 | logging.info('silent_sso failed. Running full oauth') 585 | run_full_oauth_subprocess() 586 | 587 | token_data = load_token() 588 | 589 | # Grab the Streamer Info. 590 | userPrincipalsResponse = get_user_principals( 591 | token_data, 592 | fields=['streamerConnectionInfo', 'streamerSubscriptionKeys', 'preferences', 'surrogateIds']) 593 | 594 | # Grab the timestampe. 595 | tokenTimeStamp = userPrincipalsResponse['streamerInfo']['tokenTimestamp'] 596 | 597 | # Grab socket 598 | socket_url = userPrincipalsResponse['streamerInfo']['streamerSocketUrl'] 599 | 600 | # Parse the token timestamp. 601 | tokenTimeStampAsMs = _create_token_timestamp( 602 | token_timestamp=tokenTimeStamp) 603 | 604 | # Define our Credentials Dictionary used for authentication. 605 | credentials = { 606 | "userid": userPrincipalsResponse['accounts'][0]['accountId'], 607 | "token": userPrincipalsResponse['streamerInfo']['token'], 608 | "company": userPrincipalsResponse['accounts'][0]['company'], 609 | "segment": userPrincipalsResponse['accounts'][0]['segment'], 610 | "cddomain": userPrincipalsResponse['accounts'][0]['accountCdDomainId'], 611 | "usergroup": userPrincipalsResponse['streamerInfo']['userGroup'], 612 | "accesslevel": userPrincipalsResponse['streamerInfo']['accessLevel'], 613 | "authorized": "Y", 614 | "timestamp": tokenTimeStampAsMs, 615 | "appid": userPrincipalsResponse['streamerInfo']['appId'], 616 | "acl": userPrincipalsResponse['streamerInfo']['acl'] 617 | } 618 | 619 | # Create the session 620 | streaming_session = TDClient( 621 | websocket_url=socket_url, 622 | user_principal_data=userPrincipalsResponse, 623 | credentials=credentials 624 | ) 625 | except Exception as e: 626 | logging.error(e, exc_info=True) 627 | 628 | return streaming_session 629 | -------------------------------------------------------------------------------- /python/tdapi/td_config.py: -------------------------------------------------------------------------------- 1 | # This information is obtained upon registration of app (https://developer.tdameritrade.com/user/me/apps) 2 | # Consumer Key of ExcelRTD app 3 | import os 4 | 5 | # This information is obtained upon registration of app (https://developer.tdameritrade.com/user/me/apps) 6 | # Consumer Key of ExcelRTD app 7 | CLIENT_ID = "W5LOW5PIXKAIOFJ6VPC62DADFPGJJZ60" 8 | CLIENT_ID_AUTH: str = CLIENT_ID + '@AMER.OAUTHAP' 9 | 10 | REDIRECT_URI = "https://localhost:8080/callback" 11 | 12 | APPDATA_PATH = os.path.join(os.getenv('APPDATA'), 'TD_ExcelRTD') 13 | 14 | TOKEN_URL = 'https://api.tdameritrade.com/v1/oauth2/token' 15 | TOKEN_FILE_NAME = os.path.join(APPDATA_PATH, 'token.enc') 16 | USERPRINCIPALS_FILE_NAME = os.path.join(APPDATA_PATH, 'user_principals.json') 17 | CREDENTIALS_FILE_NAME = os.path.join(APPDATA_PATH, 'credentials.json') 18 | 19 | AUTHORIZATION_BASE_URL = 'https://auth.tdameritrade.com/auth' 20 | TOKEN_ENDPOINT = 'oauth2/token' 21 | API_ENDPOINT = 'https://api.tdameritrade.com' 22 | API_VERSION = 'v1' 23 | -------------------------------------------------------------------------------- /python/tdapi/td_oauth.py: -------------------------------------------------------------------------------- 1 | from requests_oauthlib import OAuth2Session 2 | from flask import Flask, request, redirect, session, url_for 3 | from flask.json import jsonify 4 | import logging 5 | import datetime 6 | import json 7 | import os 8 | import pickle 9 | import requests 10 | import time 11 | import win32crypt 12 | 13 | from typing import Dict 14 | from typing import List 15 | 16 | if __package__: 17 | from .td_config import APPDATA_PATH, CLIENT_ID, CLIENT_ID_AUTH, REDIRECT_URI, AUTHORIZATION_BASE_URL, TOKEN_URL, TOKEN_FILE_NAME 18 | from .td_config import USERPRINCIPALS_FILE_NAME, CREDENTIALS_FILE_NAME, API_ENDPOINT, API_VERSION, TOKEN_ENDPOINT 19 | else: 20 | from td_config import APPDATA_PATH, CLIENT_ID, CLIENT_ID_AUTH, REDIRECT_URI, AUTHORIZATION_BASE_URL, TOKEN_URL, TOKEN_FILE_NAME 21 | from td_config import USERPRINCIPALS_FILE_NAME, CREDENTIALS_FILE_NAME, API_ENDPOINT, API_VERSION, TOKEN_ENDPOINT 22 | 23 | logger = logging.getLogger(__name__) 24 | logger.setLevel(logging.ERROR) 25 | logger.addHandler(logging.StreamHandler()) 26 | 27 | app = Flask(__name__) 28 | 29 | if not os.path.exists(APPDATA_PATH): 30 | os.makedirs(APPDATA_PATH) 31 | 32 | @app.route("/") 33 | def demo(): 34 | """Step 1: User Authorization. 35 | 36 | Redirect the user/resource owner to the OAuth provider (i.e. Github) 37 | using an URL with a few key OAuth parameters. 38 | """ 39 | td_session = OAuth2Session( 40 | client_id=CLIENT_ID_AUTH, 41 | redirect_uri=REDIRECT_URI 42 | ) 43 | 44 | authorization_url, state = td_session.authorization_url(AUTHORIZATION_BASE_URL) 45 | 46 | # State is used to prevent CSRF, keep this for later. 47 | session['oauth_state'] = state 48 | return redirect(authorization_url) 49 | 50 | 51 | # Step 2: User authorization, this happens on the provider. 52 | 53 | @app.route("/callback", methods=["GET"]) 54 | def callback(): 55 | """ Step 3: Retrieving an access token. 56 | 57 | The user has been redirected back from the provider to your registered 58 | callback URL. With this redirection comes an authorization code included 59 | in the redirect URL. We will use that to obtain an access token. 60 | """ 61 | td_session = OAuth2Session( 62 | client_id=CLIENT_ID_AUTH, 63 | redirect_uri=REDIRECT_URI, 64 | state=session['oauth_state'] 65 | ) 66 | token = td_session.fetch_token( 67 | TOKEN_URL, 68 | access_type='offline', 69 | authorization_response=request.url, 70 | include_client_id=True 71 | ) 72 | 73 | # At this point you can fetch protected resources but lets save 74 | # the token and show how this is done from a persisted token 75 | # in /profile. 76 | session['oauth_token'] = token 77 | 78 | save_token(token) 79 | 80 | # Grab the Streamer Info. 81 | #userPrincipalsResponse = get_user_principals( 82 | # token, 83 | # fields=['streamerConnectionInfo', 'streamerSubscriptionKeys', 'preferences', 'surrogateIds']) 84 | 85 | #if userPrincipalsResponse: 86 | # save_credentials(userPrincipalsResponse) 87 | 88 | return redirect(url_for('shutdown')) 89 | 90 | def shutdown_server(): 91 | func = request.environ.get('werkzeug.server.shutdown') 92 | if func is None: 93 | raise RuntimeError('Not running with the Werkzeug Server') 94 | func() 95 | 96 | @app.route('/shutdown', methods=['GET']) 97 | def shutdown(): 98 | shutdown_server() 99 | return 'Server shutting down...Now you can close this and go back to Excel' 100 | 101 | @app.route("/profile", methods=["GET"]) 102 | def profile(): 103 | """Fetching a protected resource using an OAuth 2 token. 104 | """ 105 | td_session = OAuth2Session(CLIENT_ID, token=session['oauth_token']) 106 | return jsonify(td_session.get('https://api.td_session.com/user').json()) 107 | 108 | def get_token(): 109 | pass 110 | 111 | def get_user_principals(token, fields: List[str]) -> Dict: 112 | """Returns User Principal details. 113 | 114 | Documentation: 115 | ---- 116 | https://developer.tdameritrade.com/user-principal/apis/get/userprincipals-0 117 | 118 | Arguments: 119 | ---- 120 | 121 | fields: A comma separated String which allows one to specify additional fields to return. None of 122 | these fields are returned by default. Possible values in this String can be: 123 | 124 | 1. streamerSubscriptionKeys 125 | 2. streamerConnectionInfo 126 | 3. preferences 127 | 4. surrogateIds 128 | 129 | Usage: 130 | ---- 131 | >>> td_client.get_user_principals(fields=['preferences']) 132 | >>> td_client.get_user_principals(fields=['preferences','streamerConnectionInfo']) 133 | """ 134 | 135 | # define the endpoint 136 | endpoint = 'userprincipals' 137 | 138 | # build the params dictionary 139 | params = { 140 | 'fields': ','.join(fields) 141 | } 142 | 143 | parts = [self.API_ENDPOINT, self.API_VERSION, endpoint] 144 | url = '/'.join(parts) 145 | 146 | headers = { 147 | 'Authorization': 'Bearer {token}'.format(token=token['access_token']) 148 | } 149 | 150 | # Define a new session. 151 | request_session = requests.Session() 152 | request_session.verify = True 153 | 154 | # Define a new request. 155 | request_request = requests.Request( 156 | method='GET', 157 | headers=headers, 158 | url=url, 159 | params=params, 160 | ).prepare() 161 | 162 | # Send the request. 163 | response: requests.Response = request_session.send(request=request_request) 164 | 165 | request_session.close() 166 | 167 | # grab the status code 168 | status_code = response.status_code 169 | 170 | # grab the response headers. 171 | response_headers = response.headers 172 | 173 | if response.ok: 174 | return response.json() 175 | else: 176 | return None 177 | 178 | def load_token(): 179 | try: 180 | with open(TOKEN_FILE_NAME, 'rb') as encoded_file: 181 | encoded_data = encoded_file.read() 182 | token_data = json.loads(win32crypt.CryptUnprotectData(encoded_data)[1].decode()) 183 | 184 | return token_data 185 | except Exception as e: 186 | return None 187 | 188 | def save_token(token_dict: dict) -> bool: 189 | # make sure there is an access token before proceeding. 190 | if 'access_token' not in token_dict: 191 | return False 192 | 193 | token_data = {} 194 | 195 | # save the access token and refresh token 196 | token_data['access_token'] = token_dict['access_token'] 197 | token_data['refresh_token'] = token_dict['refresh_token'] 198 | 199 | # store token expiration time 200 | access_token_expire = time.time() + int(token_dict['expires_in']) 201 | refresh_token_expire = time.time() + int(token_dict['refresh_token_expires_in']) 202 | token_data['access_token_expires_at'] = access_token_expire 203 | token_data['refresh_token_expires_at'] = refresh_token_expire 204 | token_data['access_token_expires_at_date'] = datetime.datetime.fromtimestamp(access_token_expire).isoformat() 205 | token_data['refresh_token_expires_at_date'] = datetime.datetime.fromtimestamp(refresh_token_expire).isoformat() 206 | token_data['logged_in'] = True 207 | 208 | token_json = json.dumps(token_data) 209 | try: 210 | with open(TOKEN_FILE_NAME, 'wb') as encoded_file: 211 | enc = win32crypt.CryptProtectData(token_json.encode()) 212 | encoded_file.write(enc) 213 | except Exception as e: 214 | return False 215 | 216 | return True 217 | 218 | def save_credentials(userPrincipalsResponse): 219 | # Grab the timestampe. 220 | tokenTimeStamp = userPrincipalsResponse['streamerInfo']['tokenTimestamp'] 221 | 222 | # Grab socket 223 | socket_url = userPrincipalsResponse['streamerInfo']['streamerSocketUrl'] 224 | 225 | # Parse the token timestamp. 226 | token_timestamp = datetime.datetime.strptime(tokenTimeStamp, "%Y-%m-%dT%H:%M:%S%z") 227 | tokenTimeStampAsMs = int(token_timestamp.timestamp()) * 1000 228 | 229 | 230 | # Define our Credentials Dictionary used for authentication. 231 | credentials = { 232 | "userid": userPrincipalsResponse['accounts'][0]['accountId'], 233 | "token": userPrincipalsResponse['streamerInfo']['token'], 234 | "company": userPrincipalsResponse['accounts'][0]['company'], 235 | "segment": userPrincipalsResponse['accounts'][0]['segment'], 236 | "cddomain": userPrincipalsResponse['accounts'][0]['accountCdDomainId'], 237 | "usergroup": userPrincipalsResponse['streamerInfo']['userGroup'], 238 | "accesslevel": userPrincipalsResponse['streamerInfo']['accessLevel'], 239 | "authorized": "Y", 240 | "timestamp": tokenTimeStampAsMs, 241 | "appid": userPrincipalsResponse['streamerInfo']['appId'], 242 | "acl": userPrincipalsResponse['streamerInfo']['acl'] 243 | } 244 | 245 | with open(file=USERPRINCIPALS_FILE_NAME, mode='w+') as json_file: 246 | json.dump(obj=userPrincipalsResponse, fp=json_file, indent=4) 247 | 248 | with open(file=CREDENTIALS_FILE_NAME, mode='w+') as json_file: 249 | json.dump(obj=credentials, fp=json_file, indent=4) 250 | 251 | def _token_seconds(token_data, token_type: str = 'access_token') -> int: 252 | """Determines time till expiration for a token. 253 | 254 | Return the number of seconds until the current access token or refresh token 255 | will expire. The default value is access token because this is the most commonly used 256 | token during requests. 257 | 258 | Arguments: 259 | ---- 260 | token_type {str} -- The type of token you would like to determine lifespan for. 261 | Possible values are ['access_token', 'refresh_token'] (default: {access_token}) 262 | 263 | Returns: 264 | ---- 265 | {int} -- The number of seconds till expiration. 266 | """ 267 | 268 | # if needed check the access token. 269 | if token_type == 'access_token': 270 | 271 | # if the time to expiration is less than or equal to 0, return 0. 272 | if not token_data['access_token'] or time.time() + 60 >= token_data['access_token_expires_at']: 273 | return 0 274 | 275 | # else return the number of seconds until expiration. 276 | token_exp = int(token_data['access_token_expires_at'] - time.time() - 60) 277 | 278 | # if needed check the refresh token. 279 | elif token_type == 'refresh_token': 280 | 281 | # if the time to expiration is less than or equal to 0, return 0. 282 | if not token_data['refresh_token'] or time.time() + 60 >= token_data['refresh_token_expires_at']: 283 | return 0 284 | 285 | # else return the number of seconds until expiration. 286 | token_exp = int(token_data['refresh_token_expires_at'] - time.time() - 60) 287 | 288 | return token_exp 289 | 290 | def grab_refresh_token(access_token, refresh_token) -> bool: 291 | """Refreshes the current access token. 292 | 293 | This takes a valid refresh token and refreshes 294 | an expired access token. 295 | 296 | Returns: 297 | ---- 298 | {bool} -- `True` if successful, `False` otherwise. 299 | """ 300 | 301 | # build the parameters of our request 302 | data = { 303 | 'client_id': CLIENT_ID_AUTH, 304 | 'grant_type': 'refresh_token', 305 | 'access_type': 'offline', 306 | 'refresh_token': refresh_token 307 | } 308 | 309 | # build url: https://api.tdameritrade.com/v1/oauth2/token 310 | parts = [API_ENDPOINT, API_VERSION, TOKEN_ENDPOINT] 311 | url = '/'.join(parts) 312 | 313 | # Define a new session. 314 | request_session = requests.Session() 315 | request_session.verify = True 316 | 317 | headers = { 'Content-Type': 'application/x-www-form-urlencoded' } 318 | 319 | # Define a new request. 320 | request_request = requests.Request( 321 | method='POST', 322 | headers=headers, 323 | url=url, 324 | data=data 325 | ).prepare() 326 | 327 | # Send the request. 328 | response: requests.Response = request_session.send(request=request_request) 329 | 330 | request_session.close() 331 | 332 | if response.ok: 333 | save_token(response.json()) 334 | return True 335 | 336 | return False 337 | 338 | def silent_sso() -> bool: 339 | try: 340 | token_data = load_token() 341 | 342 | # if the current access token is not expired then we are still authenticated. 343 | if _token_seconds(token_data, token_type='access_token') > 0: 344 | return True 345 | 346 | # if the refresh token is expired then you have to do a full login. 347 | elif _token_seconds(token_data, token_type='refresh_token') <= 0: 348 | return False 349 | 350 | # if the current access token is expired then try and refresh access token. 351 | elif token_data['refresh_token'] and grab_refresh_token(token_data['access_token'], token_data['refresh_token']): 352 | return True 353 | except Exception as e: 354 | print(repr(e)) 355 | return False 356 | 357 | return True 358 | 359 | def _run_full_oauth() -> None: 360 | import webbrowser 361 | webbrowser.open_new_tab('https://localhost:8080/') 362 | 363 | app.secret_key = os.urandom(24) 364 | app.run(ssl_context='adhoc', host="localhost", port=8080, debug=False) 365 | 366 | def run_full_oauth_subprocess() -> None: 367 | from subprocess import run 368 | run(["python", os.path.realpath(__file__)], cwd= os.path.dirname(os.path.realpath(__file__))) 369 | 370 | if __name__ == "__main__": 371 | import sys 372 | 373 | # Check if current token is valid 374 | if silent_sso(): 375 | sys.exit(0) 376 | else: 377 | _run_full_oauth() --------------------------------------------------------------------------------