├── .env.example ├── .gitignore ├── LICENSE.txt ├── README.md ├── changelog.MD ├── examples ├── first.py └── random_bot.py ├── poetry.lock ├── pyproject.toml ├── src └── tradelocker │ ├── __about__.py │ ├── __init__.py │ ├── exceptions.py │ ├── py.typed │ ├── tradelocker_api.py │ ├── types.py │ └── utils.py └── tests └── test_tl_api.py /.env.example: -------------------------------------------------------------------------------- 1 | tl_email = name.surname@email.com 2 | tl_password = examplePassword 3 | tl_server = exampleServer 4 | tl_environment = https://demo.tradelocker.com 5 | tl_log_level = debug 6 | tl_developer_api_key = tl-JOIN_TL_DEV_PROGRAM_TO_GET_ONE 7 | tl_acc_num = 2 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *# 2 | *.egg-info 3 | *.spec 4 | *~ 5 | .DS_Store 6 | .coverage 7 | .env 8 | .env.* 9 | .env-test-* 10 | .env-tl-studio* 11 | .idea/ 12 | .ipynb_checkpoints/ 13 | .next/ 14 | .temp.py 15 | .vscode/ 16 | .yarn/* 17 | /.idea 18 | /.pytest_cache 19 | /.tox 20 | /build 21 | /dist 22 | /tests/.pytest_cache 23 | /venv 24 | results.xml 25 | __pycache__ 26 | _prompt-* 27 | _response-* 28 | _tmp_source_py 29 | backend/data/ 30 | backtesting/data/ 31 | bots-env/ 32 | bt_bokeh_plot* 33 | build/ 34 | deactivate/ 35 | dist/ 36 | node_modules/ 37 | notebooks/ 38 | tlenv/ 39 | frontend/public/bt_*.html 40 | frontend/public/tl-studio.zip 41 | log.txt 42 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 TradeLocker 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TradeLocker Python API Wrapper 2 | 3 | This project provides a Python wrapper for TradeLocker's public API. It simplifies the process of making requests to the API by providing Pythonic interfaces. 4 | 5 | A full description of the TradeLocker's public API can be found at [TradeLocker API Documentation](https://tradelocker.com/api) 6 | 7 | --- 8 | 9 | ## Table of Contents 10 | 11 | 1. [Getting Started](#getting-started) 12 | 2. [Installation](#installation) 13 | 3. [Usage](#usage) 14 | 4. [Contributing](#contributing) 15 | 5. [License](#license) 16 | 17 | ## Getting Started 18 | 19 | To use this Python wrapper, you'll need the username, password and the id of the server that you use to access TradeLocker. 20 | If you don't already have access, you need to find a broker that supports TradeLocker and create an account. 21 | 22 | ## Installation 23 | 24 | This package requires Python 3.11 or later. 25 | The easiest way to install this package is using pip: 26 | 27 | ```shell 28 | pip install tradelocker 29 | ``` 30 | 31 | ## Usage 32 | 33 | Here's a simple example on how to use the TradeLocker Python API wrapper. 34 | The code below initializes a TLAPI object with authentication data. 35 | It then: fetches price history and latest price, creates an order that converts into a position, waits for 2 seconds, and finally closes the same position. 36 | 37 | ```python 38 | from tradelocker import TLAPI 39 | import time, random 40 | 41 | # Initialize the API client with the information you use to login 42 | tl = TLAPI(environment = "https://demo.tradelocker.com", username = "user@email.com", password = "YOUR_PASS", server = "SERVER_NAME") 43 | 44 | symbol_name = "BTCUSD" # "RANDOM" 45 | all_instruments = tl.get_all_instruments() 46 | if symbol_name == "RANDOM": 47 | instrument_id = int(random.choice(all_instruments['tradableInstrumentId'])) 48 | else: 49 | instrument_id = tl.get_instrument_id_from_symbol_name(symbol_name) 50 | price_history = tl.get_price_history(instrument_id, resolution="1D", start_timestamp=0, end_timestamp=0,lookback_period="5D") 51 | latest_price = tl.get_latest_asking_price(instrument_id) 52 | order_id = tl.create_order(instrument_id, quantity=0.01, side="buy", type_="market") 53 | if order_id: 54 | print(f"Placed order with id {order_id}, sleeping for 2 seconds.") 55 | time.sleep(2) 56 | tl.close_position(order_id) 57 | print(f"Closed order with id {order_id}.") 58 | else: 59 | print("Failed to place order.") 60 | ``` 61 | 62 | For more detailed examples, see the `examples` directory. 63 | 64 | ## Contributing 65 | 66 | To contribute to the development of this project, please create an issue, or a pull request. 67 | 68 | Steps to create a pull request: 69 | 70 | 1. Clone the project. 71 | 2. Create your feature branch (`git checkout -b feature/YourFeature`). 72 | 3. Commit your changes (`git commit -am 'Add some feature'`). 73 | 4. Push to the branch (`git push origin feature/YourFeature`). 74 | 5. Create a new Merge Request. 75 | 76 | ## License 77 | 78 | This project is licensed under the terms of the MIT license. See [LICENSE](https://github.com/ivosluganovic/tl/blob/main/LICENSE.txt) for more details. 79 | -------------------------------------------------------------------------------- /changelog.MD: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.39.0 (2024-01-29) 4 | - Released the first public version of the TradeLocker Python client 5 | -------------------------------------------------------------------------------- /examples/first.py: -------------------------------------------------------------------------------- 1 | import time, random 2 | from tradelocker import TLAPI 3 | from tradelocker.utils import load_env_config 4 | 5 | if __name__ == "__main__": 6 | config = load_env_config(__file__, backup_env_file="../.env") 7 | 8 | # Initialize the API client with the information you use to login 9 | tl = TLAPI( 10 | environment=config["tl_environment"], 11 | username=config["tl_email"], 12 | password=config["tl_password"], 13 | server=config["tl_server"], 14 | log_level=config["tl_log_level"], 15 | acc_num=int(config["tl_acc_num"]), 16 | ) 17 | 18 | symbol_name = "BTCUSD" # "RANDOM" 19 | all_instruments = tl.get_all_instruments() 20 | if symbol_name == "RANDOM": 21 | instrument_id = int(random.choice(all_instruments["tradableInstrumentId"])) 22 | else: 23 | instrument_id = tl.get_instrument_id_from_symbol_name(symbol_name) 24 | price_history = tl.get_price_history( 25 | instrument_id, 26 | resolution="1D", 27 | start_timestamp=0, 28 | end_timestamp=0, 29 | lookback_period="5D", 30 | ) 31 | latest_price = tl.get_latest_asking_price(instrument_id) 32 | order_id = tl.create_order(instrument_id, quantity=0.01, side="buy", type_="market") 33 | if order_id: 34 | print(f"Placed order with id {order_id}, sleeping for 2 seconds.") 35 | time.sleep(2) 36 | tl.close_position(order_id) 37 | print(f"Closed order with id {order_id}.") 38 | else: 39 | print("Failed to place order.") 40 | -------------------------------------------------------------------------------- /examples/random_bot.py: -------------------------------------------------------------------------------- 1 | import time 2 | import sys 3 | import os 4 | import random 5 | import traceback 6 | from tradelocker import TLAPI 7 | from tradelocker.utils import load_env_config 8 | 9 | 10 | # Yolo Trade function that succeeds in 30% of cases 11 | def yolo_trade(): 12 | return random.random() < 0.3 13 | 14 | 15 | class RandomTradingBot: 16 | def __init__(self, tlAPI, trade_probability, sleep_time, instrument_id=0): 17 | self.trade_probability = trade_probability 18 | self.tlAPI = tlAPI 19 | self.sleep_time = sleep_time 20 | self.instrument_id = instrument_id 21 | 22 | def calculate_position_size(self): 23 | """Calculate position size based on a random number.""" 24 | return round(random.uniform(0.01, 0.1), 2) # for example 25 | 26 | def run(self): 27 | """Run the trading bot.""" 28 | 29 | all_instruments = self.tlAPI.get_all_instruments() 30 | 31 | while True: 32 | sys.stdout.flush() # This is to make sure all output happens immediately. 33 | 34 | try: 35 | # Fetch the latest prices 36 | latest_prices = self.tlAPI.get_price_history( 37 | instrument_id=277, resolution="1D", lookback_period="3D" 38 | ) 39 | latest_close = latest_prices["c"].iloc[-1] 40 | print("latest_close: \n", latest_close) 41 | 42 | # Decide to buy or sell 43 | if random.random() < self.trade_probability: 44 | position_size = self.calculate_position_size() 45 | 46 | instrument_id = ( 47 | self.instrument_id 48 | if self.instrument_id 49 | else random.choice(all_instruments["tradableInstrumentId"]) 50 | ) 51 | 52 | # Randomly decide to buy or sell 53 | if random.choice([True, False]): 54 | print("Buy decision.") 55 | order_id = self.tlAPI.create_order(instrument_id, position_size, "buy") 56 | else: 57 | print("Sell decision.") 58 | order_id = self.tlAPI.create_order(instrument_id, position_size, "sell") 59 | 60 | holding_time = self.sleep_time 61 | 62 | # Sleep for the chosen delay 63 | print(f"Keeping the position for the next {holding_time} seconds...") 64 | time.sleep(holding_time) 65 | 66 | # Close the position 67 | print("Closing the position...") 68 | self.tlAPI.close_position(order_id) 69 | else: 70 | print("--> Decided not to make any orders in this iteration.") 71 | 72 | print(f"Sleeping for {self.sleep_time} seconds before next trade...") 73 | time.sleep(self.sleep_time) 74 | 75 | except Exception as e: 76 | print(f"An error occurred: {e}") 77 | print(e) 78 | traceback.print_exc() 79 | sys.exit(1) 80 | 81 | 82 | if __name__ == "__main__": 83 | config = load_env_config(__file__, backup_env_file="../.env") 84 | 85 | tlAPI = TLAPI( 86 | environment=config["tl_environment"], 87 | username=config["tl_email"], 88 | password=config["tl_password"], 89 | server=config["tl_server"], 90 | log_level="debug", 91 | ) 92 | 93 | bot = RandomTradingBot(tlAPI, 0.5, 5, tlAPI.get_instrument_id_from_symbol_name("BTCUSD")) 94 | 95 | # Run the bot 96 | bot.run() 97 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["poetry-core"] 3 | build-backend = "poetry.core.masonry.api" 4 | 5 | [tool.poetry] 6 | name = "tradelocker" 7 | version = "0.56.2" 8 | description = "Python client for TradeLocker's Trading API" 9 | authors = ["TradeLocker "] 10 | license = "MIT" 11 | readme = "README.md" 12 | keywords=["tradelocker","api", "rest", "trading", "algotrading", "algo", "bots", "strategies"] 13 | urls.Source = "https://github.com/tradelocker/tradelocker-python/" 14 | urls.Issues = "https://github.com/tradelocker/tradelocker-python/issues" 15 | 16 | 17 | [tool.poetry.dependencies] 18 | python = ">=3.11" 19 | 20 | pandas = ">=2.1.2" 21 | PyJWT = "2.8.0" 22 | requests = "2.32.2" 23 | python-dotenv = "1.0.0" 24 | # typeguard is removed as being a dependency since it causes problems with pyinstaller and nuitka -- add it manually if you prefer to use it or if you want to run tests 25 | # typeguard = "4.1.5" 26 | joblib = "1.4.2" 27 | 28 | [tool.poetry.dev-dependencies] 29 | poethepoet = "0.26.1" 30 | pytest = "7.4.2" 31 | pytest-cov = "4.1.0" 32 | mypy = "1.5.1" 33 | mypy-extensions = "1.0.0" 34 | types-requests = "2.32.0.*" 35 | pandas-stubs = "2.0.3.*" 36 | pylint = "2.17.7" 37 | black = "24.4.2" 38 | 39 | 40 | [tool.poe.tasks] 41 | test_typing = "poetry run mypy src/tradelocker --strict" 42 | test_pylint = "poetry run pylint tradelocker" 43 | test_all = ["test", "test_typing", "test_pylint"] 44 | test = [ 45 | { cmd = "poetry run pip install typeguard" }, 46 | { cmd = 'pytest tests --junitxml=results.xml -v --disable-warnings "$PYTEST_FLAGS"' }, 47 | ] 48 | 49 | 50 | [tool.poetry_bumpversion.file."src/tradelocker/__about__.py"] 51 | search = '__version__ = "{current_version}"' 52 | replace = '__version__ = "{new_version}"' 53 | -------------------------------------------------------------------------------- /src/tradelocker/__about__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.56.1" 2 | -------------------------------------------------------------------------------- /src/tradelocker/__init__.py: -------------------------------------------------------------------------------- 1 | from .tradelocker_api import TLAPI 2 | from .exceptions import TLAPIException, TLAPIOrderException 3 | from . import utils 4 | 5 | __all__ = ["TLAPI", "TLAPIException", "TLAPIOrderException", "utils"] 6 | -------------------------------------------------------------------------------- /src/tradelocker/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | from tradelocker.tradelocker_api import JSONType 3 | 4 | 5 | class TLAPIException(Exception): 6 | """Generic API exception""" 7 | 8 | 9 | class TLAPIOrderException(TLAPIException): 10 | """Exception for order-related errors""" 11 | 12 | def __init__(self, request_body: dict[str, Any], response_json: JSONType) -> None: 13 | self.request_body: dict = request_body 14 | self.response_json: JSONType = response_json 15 | 16 | def __str__(self) -> str: 17 | # return the errmsg which contains a reason for a rejection 18 | if "errmsg" in self.response_json and self.response_json["errmsg"]: 19 | return self.response_json["errmsg"] 20 | # otherwise, use the whole response 21 | return f"Response: {self.response_json}" 22 | -------------------------------------------------------------------------------- /src/tradelocker/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TradeLocker/tradelocker-python/f0be7ff528e6c443faa618faea3e57ed0df67eb6/src/tradelocker/py.typed -------------------------------------------------------------------------------- /src/tradelocker/tradelocker_api.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | import datetime 3 | import json 4 | from functools import lru_cache 5 | from typing import Callable, cast, Literal, Optional, DefaultDict, Any 6 | import logging 7 | import requests 8 | 9 | import jwt 10 | 11 | import pandas as pd 12 | 13 | from requests.exceptions import HTTPError 14 | from tradelocker.utils import ( 15 | get_logger, 16 | setup_utils_logging, 17 | log_func, 18 | get_nested_key, 19 | resolve_lookback_and_timestamps, 20 | retry, 21 | tl_typechecked, 22 | tl_check_type, 23 | estimate_history_size, 24 | time_to_token_expiry, 25 | disk_or_memory_cache, 26 | always_return_true, 27 | ) 28 | 29 | from .__about__ import __version__ 30 | from .types import ( 31 | DailyBarType, 32 | ColumnConfigKeysType, 33 | ColumnConfigValuesType, 34 | ConfigColumnType, 35 | ConfigType, 36 | InstrumentDetailsType, 37 | LimitsType, 38 | LocaleType, 39 | LogLevelType, 40 | MarketDepthlistType, 41 | ModificationParamsType, 42 | OrderTypeType, 43 | RouteNamesType, 44 | RateLimitType, 45 | StopLossType, 46 | TakeProfitType, 47 | RequestsMappingType, 48 | ResolutionType, 49 | RouteType, 50 | RouteTypeType, 51 | SessionDetailsType, 52 | SessionStatusDetailsType, 53 | TradeAccountsType, 54 | ValidityType, 55 | DictValuesType, 56 | CredentialsType, 57 | QuotesType, 58 | JSONType, 59 | SideType, 60 | AccountsColumns, 61 | ExecutionsColumns, 62 | OrdersColumns, 63 | PositionsColumns, 64 | PriceHistoryColumns, 65 | InstrumentsColumns, 66 | int64, 67 | ) 68 | from .exceptions import TLAPIException, TLAPIOrderException 69 | 70 | # Monkey-patch the original Joblib's method to not check code equality 71 | from joblib.memory import MemorizedFunc 72 | 73 | # We are "always returning true" to avoid checking the code of the function. 74 | # Instead, we will be constructing _cache_key to include the release version 75 | MemorizedFunc._check_previous_func_code = always_return_true 76 | 77 | from joblib import Memory, expires_after 78 | 79 | # More information about the API: https://tradelocker.com/api 80 | 81 | 82 | class TLAPI: 83 | """TradeLocker API Client 84 | 85 | Implements a REST connection to the TradeLocker REST API. 86 | 87 | See https://tradelocker.com/api/ for more information. 88 | """ 89 | 90 | # Constants 91 | _TIMEOUT: tuple[int, int] = (10, 30) # (connection_timeout, read_timeout 92 | _EPS: float = 0.00001 93 | _MIN_LOT_SIZE: float = 0.01 ## TODO: this should probably be fetched per-instrument from BE 94 | _LOGGING_FORMAT = "[%(levelname)s %(asctime)s %(module)s.%(funcName)s:%(lineno)d]: %(message)s" 95 | _MAX_STRATEGY_ID_LEN = 32 96 | _SIZE_PRECISION = 6 97 | _MAX_DISK_CACHE_SIZE = "100M" # To ensure that disk_cache uses at most 100 * 10^6 bytes 98 | 99 | _instances = {} 100 | 101 | # This is here to ensure that users don't accidentally create multiple instances 102 | # of the same TLAPI object, which would lead to multiple connections to the API for no reason. 103 | # However, different instances can be created with different parameters, 104 | # or a new instance will be created in case the access token has expired. 105 | def __new__(cls, *args, **kwargs): 106 | multiton_key = (cls, args, frozenset(kwargs.items())) 107 | # Generate a new instance only if the key is not in the instances dict or the access token has expired 108 | 109 | if ( 110 | multiton_key in cls._instances 111 | and hasattr(cls._instances[multiton_key], "_access_token") 112 | and time_to_token_expiry(cls._instances[multiton_key]._access_token) > 0 113 | ): 114 | # Just warn the user, and reuse the existing instance 115 | logging.warning(f"Reusing existing TLAPI instance for {cls.__name__}") 116 | else: 117 | # Create a new instance 118 | cls._instances[multiton_key] = super(TLAPI, cls).__new__(cls) 119 | return cls._instances[multiton_key] 120 | 121 | @tl_typechecked 122 | def __init__( 123 | self, 124 | environment: str, 125 | username: Optional[str] = None, 126 | password: Optional[str] = None, 127 | server: Optional[str] = None, 128 | access_token: Optional[str] = None, 129 | refresh_token: Optional[str] = None, 130 | account_id: int = 0, 131 | acc_num: int = 0, 132 | developer_api_key: Optional[str] = None, 133 | log_level: LogLevelType = "debug", 134 | # If this is set, we will be using disk cache for several cachable requests 135 | disk_cache_location: Optional[str] = None, 136 | release: Optional[str] = None, 137 | ) -> None: 138 | # Those object with _initialized tag have already been initialized, 139 | # so there is no need to re-initialize anything. 140 | if hasattr(self, "_initialized") and self._initialized: 141 | return 142 | 143 | if disk_cache_location: 144 | verbosity_level = 100 if log_level == "debug" else 0 145 | self._disk_cache = Memory(location=disk_cache_location, verbose=verbosity_level) 146 | self._disk_cache.reduce_size(bytes_limit=self._MAX_DISK_CACHE_SIZE) 147 | 148 | """Initializes the TradeLocker API client.""" 149 | self._base_url: str = f"{environment}/backend-api" 150 | self._credentials: Optional[CredentialsType] = None 151 | 152 | self._access_token: str = "" 153 | self._refresh_token: str = "" 154 | self.acc_num: int = 0 155 | self.account_id: int = 0 156 | self.environment: str = environment 157 | 158 | self.developer_api_key = developer_api_key 159 | self.release = release or str(__version__) 160 | 161 | if username and password and server: 162 | self._credentials = { 163 | "username": username, 164 | "password": password, 165 | "server": server, 166 | } 167 | 168 | self.log = get_logger(__name__, log_level=log_level, format=self._LOGGING_FORMAT) 169 | setup_utils_logging(self.log) 170 | 171 | self.log.debug(f"Initialized TLAPI with {log_level=} {self._LOGGING_FORMAT=} ") 172 | 173 | if self._credentials: 174 | self._auth_with_password( 175 | username=self._credentials["username"], 176 | password=self._credentials["password"], 177 | server=self._credentials["server"], 178 | ) 179 | user_and_server_cache_key = ( 180 | self._credentials["username"] + "|" + self._credentials["server"] 181 | ) 182 | elif access_token and refresh_token: 183 | self._auth_with_tokens(access_token, refresh_token) 184 | 185 | token_payload = jwt.decode(access_token, options={"verify_signature": False}) 186 | # token['sub'] is essentially user_id + server 187 | user_and_server_cache_key = token_payload.get("sub") 188 | else: 189 | error_msg = ( 190 | "Either username/pass/server, or access_token/refresh_token must be provided!" 191 | ) 192 | raise ValueError(error_msg) 193 | 194 | # Cache_key is user + server + environment + (account_id when it becomes available) 195 | self._cache_key = user_and_server_cache_key + "|" + self.environment + "|" + self.release 196 | 197 | self._set_account_id_and_acc_num(account_id, acc_num) 198 | 199 | # This redefines the cache key to also include the chosen account_id (different for each acc_num) 200 | self._cache_key = self._cache_key + "|" + str(self.account_id) 201 | 202 | self._initialized = True 203 | 204 | def get_base_url(self) -> str: 205 | """Returns the base URL of the API.""" 206 | return self._base_url 207 | 208 | ############################## AUTH ROUTES ########################## 209 | 210 | def get_access_token(self) -> str: 211 | """Returns the access token. If the token is about to expire, it will be refreshed.""" 212 | 213 | # If auth token is not set, or refresh token has expired, try fetching a completely new one 214 | if not self._access_token or time_to_token_expiry(self._refresh_token) < 0: 215 | if not self._credentials: 216 | error_msg = "Cannot fetch or refresh authentication tokens" 217 | self.log.critical(error_msg) 218 | raise TLAPIException(error_msg) 219 | else: 220 | self._auth_with_password( 221 | self._credentials["username"], 222 | self._credentials["password"], 223 | self._credentials["server"], 224 | ) 225 | 226 | # If there is less than 30 minutes to token expiry, refresh the token 227 | if ( 228 | time_to_token_expiry(self._access_token) 229 | < datetime.timedelta(minutes=30).total_seconds() 230 | ): 231 | self.refresh_access_tokens() 232 | 233 | return self._access_token 234 | 235 | def get_refresh_token(self) -> str: 236 | """Returns the refresh token.""" 237 | if not self._refresh_token or time_to_token_expiry(self._refresh_token) < 0: 238 | if self._credentials: 239 | self._auth_with_password( 240 | self._credentials["username"], 241 | self._credentials["password"], 242 | self._credentials["server"], 243 | ) 244 | else: 245 | error_msg = "No refresh token found or token expired" 246 | self.log.critical(error_msg) 247 | raise TLAPIException(error_msg) 248 | return self._refresh_token 249 | 250 | def _set_account_id_and_acc_num(self, account_id: int, acc_num: int) -> None: 251 | all_accounts: pd.DataFrame = self.get_all_accounts() 252 | 253 | if all_accounts.empty: 254 | self.log.critical("No accounts found") 255 | raise TLAPIException("No accounts found") 256 | 257 | # Pick the correct account, either by having account_id, or acc_num specified 258 | if account_id != 0: 259 | if account_id not in all_accounts["id"].values: 260 | raise ValueError( 261 | f"account_id '{account_id}' not found in all_accounts:\n{all_accounts} " 262 | ) 263 | 264 | self.account_id = account_id 265 | # Find the acc_num for the specified account_id 266 | self.acc_num = int(all_accounts[all_accounts["id"] == account_id]["accNum"].iloc[0]) 267 | self.account_name = all_accounts[all_accounts["id"] == account_id]["name"].iloc[0] 268 | 269 | self.log.debug( 270 | f"Logging in using the specified account_id: {account_id}, using acc_num: {self.acc_num}" 271 | ) 272 | 273 | elif acc_num != 0: 274 | if acc_num not in all_accounts["accNum"].values: 275 | raise ValueError(f"acc_num '{acc_num}' not found in all_accounts:\n{all_accounts}") 276 | 277 | self.acc_num = acc_num 278 | # Find the account_id for the specified acc_num 279 | self.account_id = int(all_accounts[all_accounts["accNum"] == acc_num]["id"].iloc[0]) 280 | self.account_name = all_accounts[all_accounts["accNum"] == acc_num]["name"].iloc[0] 281 | 282 | self.log.debug( 283 | f"Logging in using the specified acc_num: {acc_num}, using account_id: {self.account_id}" 284 | ) 285 | else: 286 | self.log.debug("Neither account_id nor acc_num specified, using the first account") 287 | # use the last account in the list 288 | self.account_id = int(all_accounts["id"].iloc[0]) 289 | self.acc_num = int(all_accounts["accNum"].iloc[0]) 290 | self.account_name = all_accounts["name"].iloc[0] 291 | 292 | self.log.debug( 293 | f"Logging in using the first account, account_id: {self.account_id}, acc_num: {self.acc_num}" 294 | ) 295 | 296 | def _auth_with_tokens(self, access_token: str, refresh_token: str) -> None: 297 | """Stores the access and refresh tokens.""" 298 | self._access_token = access_token 299 | self._refresh_token = refresh_token 300 | 301 | ############################## PRIVATE UTILS ####################### 302 | 303 | @lru_cache 304 | @tl_typechecked 305 | def _get_column_names(self, object_name: ColumnConfigKeysType) -> list[str]: 306 | """Returns the column names of values in orders/positions, etc. from the /config endpoint 307 | 308 | Args: 309 | object_name (ColumnConfigKeysType): The name of the object to get the column names for 310 | Returns: 311 | list[str]: The column names 312 | """ 313 | config_dict: ConfigType = self.get_config() 314 | 315 | # Using cast because /config is really ugly and irregular, so mypy needs help. 316 | config_for_object: ColumnConfigValuesType = cast( 317 | dict[ColumnConfigKeysType, ColumnConfigValuesType], config_dict 318 | )[object_name] 319 | 320 | config_columns: ConfigColumnType = cast( 321 | dict[Literal["columns"], ConfigColumnType], config_for_object 322 | )["columns"] 323 | 324 | tl_check_type(config_columns, ConfigColumnType) 325 | 326 | object_columns: list[str] = [column["id"] for column in config_columns] 327 | return object_columns 328 | 329 | @lru_cache 330 | def get_trade_route_id(self, instrument_id: int) -> str: 331 | """Returns the "TRADE" route_id for the specified instrument_id""" 332 | all_trade_routes = self._get_route_ids(instrument_id, "TRADE") 333 | return all_trade_routes[0] 334 | 335 | @lru_cache 336 | @log_func 337 | def _info_route_valid(self, route_id: str, instrument_id: int) -> bool: 338 | """Checks if the INFO route is valid for the specified instrument_id by making a simple /trade/quotes request""" 339 | route_url = f"{self.get_base_url()}/trade/quotes" 340 | 341 | additional_params: DictValuesType = { 342 | "tradableInstrumentId": instrument_id, 343 | "routeId": route_id, 344 | } 345 | try: 346 | response_json = self._request("get", route_url, additional_params=additional_params) 347 | assert "s" in response_json and response_json["s"] == "ok" 348 | except: 349 | return False 350 | 351 | return True 352 | 353 | @lru_cache 354 | @log_func 355 | def get_info_route_id(self, instrument_id: int) -> str: 356 | """Returns the "INFO" route_id for the specified instrument_id""" 357 | all_info_routes = self._get_route_ids(instrument_id, "INFO") 358 | 359 | # We avoid the need to check if the route is valid by returning the first one 360 | # If this one is not OK, everything else will fail anyways 361 | if len(all_info_routes) == 1: 362 | return all_info_routes[0] 363 | 364 | # Multiple routes found -- we need to find which one works 365 | # These checks exist because some users had multiple info routes, and the first one was invalid 366 | # Testing them in reverse reduces the number of calls to make 367 | all_info_routes_reverted = all_info_routes[::-1] 368 | for route_id in all_info_routes_reverted: 369 | if self._info_route_valid(route_id, instrument_id): 370 | return route_id 371 | 372 | raise TLAPIException("No valid INFO route found for instrument_id") 373 | 374 | @lru_cache 375 | def max_price_history_rows(self) -> int: 376 | config_dict: ConfigType = self.get_config() 377 | limits: list[LimitsType] = get_nested_key(config_dict, ["limits"], list[LimitsType]) 378 | for limit in limits: 379 | if limit["limitType"] == "QUOTES_HISTORY_BARS": 380 | return limit["limit"] 381 | raise TLAPIException("Failed to fetch max history rows") 382 | 383 | @lru_cache 384 | def get_route_rate_limit(self, route_name: RouteNamesType) -> RateLimitType: 385 | config_dict: ConfigType = self.get_config() 386 | limits: list[LimitsType] = get_nested_key(config_dict, ["rateLimits"], list[RateLimitType]) 387 | 388 | for limit in limits: 389 | if limit["rateLimitType"] == route_name: 390 | return limit 391 | 392 | raise TLAPIException("Failed to fetch trade rate limit") 393 | 394 | def get_price_history_rate_limit(self) -> RateLimitType: 395 | return self.get_route_rate_limit("QUOTES_HISTORY") 396 | 397 | @lru_cache 398 | @log_func 399 | @tl_typechecked 400 | def _get_route_ids(self, instrument_id: int, route_type: RouteTypeType) -> list[str]: 401 | """Returns the route_id for the specified instrument_id and route_type (TRADE/INFO)""" 402 | all_instruments: pd.DataFrame = self.get_all_instruments() 403 | matching_instruments: pd.DataFrame = all_instruments[ 404 | all_instruments["tradableInstrumentId"] == instrument_id 405 | ] 406 | try: 407 | routes: list[RouteType] = matching_instruments["routes"].iloc[0] 408 | # filter routes by type 409 | matching_routes: list[str] = [ 410 | str(route["id"]) for route in routes if route["type"] == route_type 411 | ] 412 | return matching_routes 413 | except IndexError: 414 | raise TLAPIException(f"No {route_type} route found for {instrument_id=}") 415 | 416 | @tl_typechecked 417 | def _get_headers( 418 | self, 419 | include_access_token: bool = True, 420 | include_acc_num: bool = True, 421 | additional_headers: Optional[RequestsMappingType] = None, 422 | ) -> RequestsMappingType: 423 | """Returns a header with a fresh JWT token, additional_headers and (potentially) accNum 424 | 425 | Args: 426 | additional_headers: Additional headers to include in the request 427 | include_acc_num: Whether to include the accNum header in the request 428 | 429 | Returns: 430 | The final headers 431 | """ 432 | headers: RequestsMappingType = {} 433 | 434 | if include_access_token: 435 | headers["Authorization"] = f"Bearer {self.get_access_token()}" 436 | 437 | if include_acc_num: 438 | headers["accNum"] = str(self.acc_num) 439 | 440 | # If available, Developer API key is attached to all requests 441 | if self.developer_api_key: 442 | headers["developer-api-key"] = self.developer_api_key 443 | 444 | if additional_headers is not None: 445 | headers.update(cast(RequestsMappingType, additional_headers)) 446 | 447 | return headers 448 | 449 | @tl_typechecked 450 | def _get_params( 451 | self, additional_params: Optional[DictValuesType] = None 452 | ) -> RequestsMappingType: 453 | """Converts all params values to strings and adds the referral values 454 | 455 | Args: 456 | additional_params: Additional parameters to include in the request 457 | 458 | Returns: 459 | The final parameters 460 | """ 461 | final_params: RequestsMappingType = {"ref": "py_c", "v": self.release} 462 | if additional_params is not None: 463 | for key, value in cast(RequestsMappingType, additional_params).items(): 464 | final_params[key] = str(value) 465 | 466 | return final_params 467 | 468 | def _raise_from_response_status(self, response: requests.Response) -> None: 469 | """Raises an exception if the response status is not OK 470 | 471 | Args: 472 | response: The response to check 473 | """ 474 | try: 475 | response.raise_for_status() 476 | except requests.exceptions.HTTPError as err: 477 | error_msg = f"Received response: '{response.text}' from {response.url}: '{err}'" 478 | self.log.error(error_msg) 479 | raise requests.exceptions.HTTPError(error_msg) 480 | 481 | @tl_typechecked 482 | # Raises HTTP related exceptions and tries extracting a JSON from the response 483 | def _get_response_json(self, response: requests.Response) -> JSONType: 484 | """Returns the JSON from a requests response. 485 | 486 | Args: 487 | response: The response to extract the JSON from 488 | 489 | Returns: 490 | The response JSON 491 | 492 | Raises: 493 | HTTPError: Will be raised if the request fails 494 | ValueError: Will be raised if the response is empty or invalid 495 | """ 496 | self._raise_from_response_status(response) 497 | 498 | if response.text == "": 499 | raise ValueError(f"Empty response received from the API for {response.url}") 500 | 501 | try: 502 | response_json: JSONType = response.json() 503 | return response_json 504 | except json.decoder.JSONDecodeError as err: 505 | error_msg = f"Failed to decode JSON response from {response.url}. Received response:\n'{response.text}'\n{err}" 506 | raise ValueError(error_msg) from err 507 | 508 | @tl_typechecked 509 | def _request( 510 | self, 511 | request_type: Literal["get", "post", "delete", "patch"], 512 | url: str, 513 | additional_headers: Optional[RequestsMappingType] = None, 514 | additional_params: Optional[DictValuesType] = None, 515 | include_acc_num: bool = True, 516 | include_access_token: bool = True, 517 | json_data: Optional[JSONType] = None, 518 | retry_request: bool = True, 519 | ) -> JSONType: 520 | """Performs a request to the specified URL. 521 | 522 | Args: 523 | request_type: The type of request to perform (post, delete, patch, get) 524 | url: The URL to send the request to 525 | additional_headers: Additional headers to include in the request 526 | additional_params: Additional parameters to include in the request 527 | include_acc_num: Whether to include the accNum header in the request 528 | include_access_token: Whether to include the access token in the request 529 | json_data: The JSON to send in the request body 530 | retry_request: Whether to retry the request if it fails 531 | 532 | Returns: 533 | The response JSON 534 | 535 | Raises: 536 | HTTPError: Will be raised if the request fails 537 | """ 538 | 539 | headers = self._get_headers( 540 | additional_headers=additional_headers, 541 | include_acc_num=include_acc_num, 542 | include_access_token=include_access_token, 543 | ) 544 | params = self._get_params(additional_params) 545 | request_method = getattr(requests, request_type) 546 | kwargs = { 547 | "url": url, 548 | "headers": headers, 549 | "params": params, 550 | "json": json_data, 551 | "timeout": self._TIMEOUT, 552 | } 553 | self.log.debug(f"=> REST REQUEST: {request_type.upper()} {url} {kwargs}") 554 | 555 | if retry_request: 556 | response = self._retry_request(request_method, **kwargs) 557 | else: 558 | response = request_method(**kwargs) 559 | response_json = self._get_response_json(response) 560 | 561 | return response_json 562 | 563 | @retry 564 | def _retry_request(self, method: Callable, *args, **kwargs) -> Any: 565 | """Retries to execute the given method using a decorator 566 | 567 | This method is used by _request to retry execution of a request. If the 568 | request raises any RequestException, the request will be re-run. 569 | """ 570 | return method(*args, **kwargs) 571 | 572 | def _apply_typing(self, df: pd.DataFrame, column_types: dict[str, type]) -> pd.DataFrame: 573 | """Converts columns of int and float type from str to numeric values. 574 | 575 | Args: 576 | columns_types (dict[str, type]): The column types to apply 577 | 578 | Returns: 579 | pd.DataFrame: The DataFrame with the types applied 580 | """ 581 | for column in df.columns: 582 | if column not in column_types: 583 | self.log.error(f"Missing type specification for column {column} in {column_types}") 584 | else: 585 | try: 586 | # Only convert the ints and floats after replacing "None" values with 0 587 | if column_types[column] in [int64, float]: 588 | df[column] = df[column].fillna(0).astype(column_types[column]) 589 | 590 | except TLAPIException as err: 591 | self.log.warning( 592 | f"Failed to apply type {column_types[column]} to column {column}: {err}" 593 | ) 594 | 595 | ############################## PUBLIC UTILS ####################### 596 | 597 | @lru_cache 598 | @log_func 599 | @tl_typechecked 600 | def get_instrument_id_from_symbol_name(self, symbol_name: str) -> int: 601 | """Returns the instrument Id from the given symol's name. 602 | 603 | Args: 604 | symbol_name (str): Name of the symbol, for example `BTCUSD` 605 | 606 | Raises: 607 | ValueError: Will be raised if instrument was with given symbol name was not found 608 | 609 | Returns: 610 | int: On success the instrument Id will be returned 611 | """ 612 | all_instruments: pd.DataFrame = self.get_all_instruments() 613 | matching_instruments = all_instruments[all_instruments["name"] == symbol_name] 614 | if len(matching_instruments) == 0: 615 | raise ValueError(f"No instrument found with {symbol_name=}") 616 | if len(matching_instruments) > 1: 617 | self.log.warning( 618 | f"Multiple instruments found with {symbol_name=}. Using the first one." 619 | ) 620 | 621 | return int(matching_instruments["tradableInstrumentId"].iloc[0]) 622 | 623 | @log_func 624 | @tl_typechecked 625 | def get_instrument_id_from_symbol_id(self, symbol_id: int) -> int: 626 | """Returns the instrument Id from the given symbol's id. 627 | 628 | Args: 629 | symbol_id (int): Id the symbol 630 | 631 | Raises: 632 | ValueError: Will be raised if instrument was with given symbol id was not found 633 | 634 | Returns: 635 | int: On success the instrument Id will be returned 636 | """ 637 | all_instruments: pd.DataFrame = self.get_all_instruments() 638 | matching_instruments = all_instruments[all_instruments["id"] == symbol_id] 639 | if len(matching_instruments) == 0: 640 | raise ValueError(f"No instrument found with {symbol_id=}") 641 | if len(matching_instruments) > 1: 642 | self.log.warning(f"Multiple instruments found with {symbol_id=}. Using the first one.") 643 | 644 | return int(matching_instruments["tradableInstrumentId"].iloc[0]) 645 | 646 | @log_func 647 | @tl_typechecked 648 | def get_symbol_name_from_instrument_id(self, instrument_id: int) -> str: 649 | """Returns the symbol name from the given instrument Id. 650 | 651 | Args: 652 | instrument_id (int): The instrument Id 653 | Returns: 654 | str: On success the symbol name will be returned 655 | """ 656 | 657 | all_instruments: pd.DataFrame = self.get_all_instruments() 658 | matching_instruments = all_instruments[ 659 | all_instruments["tradableInstrumentId"] == instrument_id 660 | ] 661 | if len(matching_instruments) == 0: 662 | raise ValueError(f"No instrument found with id = {instrument_id}") 663 | 664 | self.log.debug(f"(get_symbol_name_from_instrument_id) instrument_id: {instrument_id}") 665 | self.log.debug(f"matching_instruments:\n{matching_instruments}") 666 | return matching_instruments["name"].iloc[0] 667 | 668 | @log_func 669 | @tl_typechecked 670 | def close_all_positions(self, instrument_id_filter: int = 0) -> bool: 671 | """Places an order to close all open positions. 672 | 673 | If instrument_id is provied, only positions in this instrument will be closed. 674 | 675 | IMPORTANT: Isn't guaranteed to close all positions, or close them immediately. 676 | Will attempt to place an IOC, then GTC closing order, so the execution might be delayed. 677 | 678 | Args: 679 | instrument_id_filter (int, optional): _description_. Defaults to 0. 680 | 681 | Returns: 682 | bool: True if executed successfully False otherwise 683 | """ 684 | route_url = f"{self.get_base_url()}/trade/accounts/{self.account_id}/positions" 685 | 686 | additional_params: DictValuesType = {} 687 | if instrument_id_filter != 0: 688 | additional_params["tradableInstrumentId"] = str(instrument_id_filter) 689 | 690 | response_json = self._request("delete", route_url, additional_params=additional_params) 691 | response_status: str = get_nested_key(response_json, ["s"], str) 692 | return response_status == "ok" 693 | 694 | @log_func 695 | @tl_typechecked 696 | def delete_all_orders_manual(self, instrument_id_filter: int = 0) -> bool: 697 | """DEPRECATED -- Use delete_all_orders instead -- Deletes all pending orders, one by one. 698 | 699 | If instrument_id is provided, only pending orders in this instrument will be closed 700 | 701 | Args: 702 | instrument_id_filter (int, optional): The instrument id to use. Defaults to 0. 703 | 704 | Returns: 705 | bool: True if executed successfully False otherwise 706 | """ 707 | self.log.warning( 708 | f"delete_all_orders_manual is deprecated and will be removed in the future. Use delete_all_orders instead." 709 | ) 710 | 711 | orders = self.get_all_orders(history=False, instrument_id_filter=instrument_id_filter) 712 | # iterate over all rows of the orders dataframe 713 | for index, row in orders.iterrows(): 714 | order_id = row["id"] 715 | self.delete_order(order_id) 716 | 717 | return True 718 | 719 | @log_func 720 | @tl_typechecked 721 | def delete_all_orders(self, instrument_id_filter: int = 0) -> bool: 722 | """Deletes all pending orders. 723 | 724 | If instrument_id is provided, only pending orders in this instrument will be closed 725 | 726 | Args: 727 | instrument_id_filter (int, optional): The instrument id to use. Defaults to 0. 728 | 729 | Returns: 730 | bool: True if executed successfully False otherwise 731 | """ 732 | route_url = f"{self.get_base_url()}/trade/accounts/{self.account_id}/orders" 733 | 734 | additional_params: DictValuesType = {} 735 | if instrument_id_filter != 0: 736 | additional_params["tradableInstrumentId"] = str(instrument_id_filter) 737 | 738 | response_json = self._request("delete", route_url, additional_params=additional_params) 739 | response_status: str = get_nested_key(response_json, ["s"], str) 740 | return response_status == "ok" 741 | 742 | @log_func 743 | @tl_typechecked 744 | def _place_close_position_order(self, position_id: int, quantity: float = 0) -> bool: 745 | route_url = f"{self.get_base_url()}/trade/positions/{position_id}" 746 | 747 | data = {"qty": str(quantity)} 748 | 749 | response_json = self._request("delete", route_url, json_data=data) 750 | response_status: str = get_nested_key(response_json, ["s"], str) 751 | 752 | return response_status == "ok" 753 | 754 | @log_func 755 | @tl_typechecked 756 | def close_position( 757 | self, order_id: int = 0, position_id: int = 0, close_quantity: float = 0 758 | ) -> bool: 759 | """Places an order to close a position. 760 | 761 | Either the order_id or the position_id needs to be provided. If both values are provided 762 | then a ValueError will be raised. 763 | 764 | IMPORTANT: Isn't guaranteed to close the position, or close it immediately. 765 | Will attempt to place an IOC, then GTC closing order, so the execution might be delayed. 766 | 767 | Args: 768 | order_id (int, optional): The order id. Defaults to 0. 769 | position_id (int, optional): The position id. Defaults to 0. 770 | close_quantity (float, optional): If a value larger than 0 is provided the size of the position will be reduced by the given amount. Defaults to 0 (which closes the position completely). 771 | 772 | Returns: 773 | bool: True if executed successfully False otherwise 774 | 775 | Raises: 776 | ValueError: Will be raised if no order_id or position_id was provided or both ids were provided 777 | """ 778 | if order_id == 0 and position_id == 0: 779 | raise ValueError("Either order_id or position_id must be provided!") 780 | if order_id != 0 and position_id != 0: 781 | raise ValueError("Both order_id and position_id provided!") 782 | 783 | if position_id != 0: 784 | return self._place_close_position_order( 785 | position_id=position_id, quantity=close_quantity 786 | ) 787 | 788 | # Important: make sure to use ordersHistory since some orders might have been from previous sessions 789 | all_orders = self.get_all_orders(history=True) 790 | 791 | selection_criteria: str = "" 792 | if order_id != 0: 793 | matching_orders = all_orders[all_orders["id"] == order_id] 794 | selection_criteria = f"order_id: {order_id}" 795 | else: 796 | matching_orders = all_orders[all_orders["positionId"] == position_id] 797 | selection_criteria = f"position_id: {position_id}" 798 | 799 | rejected_matching_orders = matching_orders[matching_orders["status"] == "Rejected"] 800 | if len(rejected_matching_orders.index) > 0: 801 | self.log.warning(f"Rejected orders found for {selection_criteria}!") 802 | 803 | # leave only filled orders 804 | matching_orders = matching_orders[matching_orders["status"] == "Filled"] 805 | 806 | if len(matching_orders.index) == 0: 807 | self.log.error(f"No matching position found for {selection_criteria}!") 808 | return False 809 | 810 | # get the total size of found orders and all related positions 811 | position_sizes: DefaultDict[int, float] = defaultdict(float) 812 | for _, row in matching_orders.iterrows(): 813 | position_id = int(row["positionId"]) 814 | qty_order = -row["qty"] if row["side"] == "sell" else row["qty"] 815 | position_sizes[position_id] += qty_order 816 | for position_id in position_sizes: 817 | # make sure all position sizes are positive 818 | position_sizes[position_id] = abs(position_sizes[position_id]) 819 | 820 | # check positions found, only one position can be closed at a time 821 | if len(position_sizes) > 1: 822 | # This code and execption below is only theoretical. In practice there will never 823 | # be more than one position available. 824 | for position_id in position_sizes.keys(): 825 | quantity_to_close: float = position_sizes[position_id] 826 | self._place_close_position_order( 827 | position_id=position_id, quantity=quantity_to_close 828 | ) 829 | position_ids = ",".join(list(position_sizes.keys())) 830 | raise TLAPIException( 831 | f"CRITICAL ERROR: found multiple positions ({position_ids}) for {selection_criteria}! Closing all matching positions. Reach out to TL Support if this happens again." 832 | ) 833 | 834 | # prepare closing details 835 | position_id: int = list(position_sizes.keys())[0] 836 | # since the size is calculated using floats make sure that no float artefacts are submitted 837 | # by rounding the size to a specific precision which cuts of the artefacts 838 | quantity_to_close: float = round(position_sizes[position_id], self._SIZE_PRECISION) 839 | if close_quantity: 840 | quantity_to_close = min(quantity_to_close, close_quantity) 841 | if quantity_to_close < self._MIN_LOT_SIZE: 842 | self.log.error( 843 | f"Quantity to close ({quantity_to_close}) is less than minimum lot size ({self._MIN_LOT_SIZE}). Not executing close_position." 844 | ) 845 | return False 846 | # close position 847 | return self._place_close_position_order(position_id=position_id, quantity=quantity_to_close) 848 | 849 | ############################## AUTH ROUTES ########################## 850 | 851 | @tl_typechecked 852 | def _auth_with_password(self, username: str, password: str, server: str) -> None: 853 | """Fetches and sets access tokens for api access. 854 | 855 | Args: 856 | username (str): Username 857 | password (str): Password 858 | server (str): Server name 859 | 860 | Raises: 861 | ValueError: Will be raised on authentication errors 862 | """ 863 | route_url = f"{self.get_base_url()}/auth/jwt/token" 864 | 865 | data = {"email": username, "password": password, "server": server} 866 | 867 | try: 868 | response_json = self._request( 869 | "post", route_url, json_data=data, include_access_token=False, include_acc_num=False 870 | ) 871 | self._access_token = get_nested_key(response_json, ["accessToken"], str) 872 | self._refresh_token = get_nested_key(response_json, ["refreshToken"], str) 873 | assert self._access_token and self._refresh_token 874 | self.log.info("Successfully fetched authentication tokens") 875 | except Exception as err: 876 | self.log.critical(f"Failed to fetch authentication tokens: {err}") 877 | # Explicitly re-raise from err 878 | raise ValueError(f"Failed to fetch authentication tokens: {err}") from err 879 | 880 | @tl_typechecked 881 | def refresh_access_tokens(self) -> None: 882 | """Refreshes authentication tokens.""" 883 | route_url = f"{self.get_base_url()}/auth/jwt/refresh" 884 | 885 | data = {"refreshToken": self._refresh_token} 886 | 887 | response_json = self._request( 888 | "post", route_url, json_data=data, include_access_token=False, include_acc_num=False 889 | ) 890 | 891 | self.log.info("Successfully refreshed authentication tokens") 892 | 893 | self._access_token = get_nested_key(response_json, ["accessToken"], str) 894 | self._refresh_token = get_nested_key(response_json, ["refreshToken"], str) 895 | 896 | @disk_or_memory_cache(cache_validation_callback=expires_after(days=1)) 897 | @log_func 898 | @tl_typechecked 899 | def get_all_accounts(self) -> pd.DataFrame: 900 | """Returns all accounts associated with the account used for authentication. 901 | 902 | Raises: 903 | TLAPIException: Will be raised if account information could not be fetched 904 | 905 | Returns: 906 | pd.DataFrame[AccountsColumnsTypes]: DataFrame with user's accounts 907 | """ 908 | route_url = f"{self.get_base_url()}/auth/jwt/all-accounts" 909 | 910 | # Make sure we don't try including accNum into the header, as it is not chosen yet 911 | response_json = self._request("get", route_url, include_acc_num=False) 912 | accounts_json = get_nested_key(response_json, ["accounts"]) 913 | 914 | accounts = pd.DataFrame(accounts_json) 915 | self._apply_typing(accounts, AccountsColumns) 916 | 917 | if not accounts_json or accounts.empty: 918 | self.log.critical("Failed to fetch user's accounts") 919 | raise TLAPIException("Failed to fetch user's accounts") 920 | 921 | return accounts 922 | 923 | ############################## CONFIG ROUTES ########################## 924 | 925 | @disk_or_memory_cache(cache_validation_callback=expires_after(days=1)) 926 | @log_func 927 | @tl_typechecked 928 | def get_config(self) -> ConfigType: 929 | """Returns the user's configuration. 930 | 931 | Route Name: GET_CONFIG 932 | 933 | Returns: 934 | ConfigType: The configuration 935 | """ 936 | route_url = f"{self.get_base_url()}/trade/config" 937 | response_json = self._request("get", route_url) 938 | config_dict: ConfigType = get_nested_key(response_json, ["d"], ConfigType) 939 | return config_dict 940 | 941 | ############################## ACCOUNT ROUTES ########################## 942 | 943 | @log_func 944 | @tl_typechecked 945 | def get_trade_accounts(self) -> TradeAccountsType: 946 | """Returns the account information. 947 | 948 | The account is defined by the acc_num used in constructor. 949 | 950 | Route Name: GET_ACCOUNTS 951 | 952 | Returns: 953 | TradeAccountsType: The account details 954 | """ 955 | route_url = f"{self.get_base_url()}/trade/accounts" 956 | 957 | response_json = self._request("get", route_url) 958 | 959 | trade_accounts: TradeAccountsType = get_nested_key(response_json, ["d"], TradeAccountsType) 960 | return trade_accounts 961 | 962 | @log_func 963 | @tl_typechecked 964 | def get_all_executions(self) -> pd.DataFrame: 965 | """Returns a list of orders executed in account in current session. 966 | 967 | Route Name: GET_EXECUTIONS 968 | 969 | Returns: 970 | pd.DataFrame[ExecutionsColumnTypes]: DataFrame containing all executed orders 971 | """ 972 | route_url = f"{self.get_base_url()}/trade/accounts/{self.account_id}/executions" 973 | 974 | response_json = self._request("get", route_url) 975 | 976 | column_names = self._get_column_names("filledOrdersConfig") 977 | 978 | all_executions = pd.DataFrame( 979 | get_nested_key(response_json, ["d", "executions"]), columns=column_names 980 | ) 981 | self._apply_typing(all_executions, ExecutionsColumns) 982 | 983 | return all_executions 984 | 985 | @disk_or_memory_cache(cache_validation_callback=expires_after(days=1)) 986 | @log_func 987 | @tl_typechecked 988 | def get_all_instruments(self) -> pd.DataFrame: 989 | """Returns all available instruments for account. 990 | 991 | route_name = GET_INSTRUMENTS 992 | 993 | Returns: 994 | pd.DataFrame[InstrumentsColumnsTypes]: DataFrame with all available instruments 995 | """ 996 | route_url = f"{self.get_base_url()}/trade/accounts/{self.account_id}/instruments" 997 | 998 | response_json = self._request("get", route_url) 999 | 1000 | all_instruments = pd.DataFrame.from_dict( 1001 | get_nested_key(response_json, ["d", "instruments"]) 1002 | ) 1003 | self._apply_typing(all_instruments, InstrumentsColumns) 1004 | return all_instruments 1005 | 1006 | # This function handles both the /orders and the /ordersHistory endpoint 1007 | @log_func 1008 | @tl_typechecked 1009 | def get_all_orders( 1010 | self, 1011 | lookback_period: str = "", 1012 | start_timestamp: int = 0, 1013 | end_timestamp: int = 0, 1014 | instrument_id_filter: int = 0, 1015 | history: bool = False, 1016 | ) -> pd.DataFrame: 1017 | """Returns all orders associated with the account. 1018 | If history is set to True, it will return all orders from the beginning of the session. 1019 | If history is set to False, it will return only orders that have not been executed yet. 1020 | The default value is False. 1021 | If the account has no orders, an empty DataFrame is returned. 1022 | 1023 | Route Name: GET_ORDERS 1024 | Route Name: GET_ORDERS_HISTORY 1025 | 1026 | Args: 1027 | lookback_period (str, optional): This will set the start and end timestamp based on the 1028 | given lookback period. The lookback_period needs to be in 1029 | the format of 1Y, 1M, 1D, 1H, 1m, 1s. Defaults to "". 1030 | start_timestamp (int, optional): Minimal timestamp of returned orders. Defaults to 0. 1031 | end_timestamp (int, optional): Maximal timestamp of returned orders. Defaults to 0. 1032 | instrument_id_filter (int, optional): Filter for instrument id, returns only orders that 1033 | use the given instrument. Defaults to 0. 1034 | history (bool, optional): Should historical orders be returned. Defaults to False. 1035 | 1036 | Returns: 1037 | pd.DataFrame[OrdersColumnsTypes]: DataFrame containing all orders 1038 | """ 1039 | endpoint = "orders" + ("History" if history else "") 1040 | route_url = f"{self.get_base_url()}/trade/accounts/{self.account_id}/{endpoint}" 1041 | 1042 | if lookback_period != "": 1043 | start_timestamp, end_timestamp = resolve_lookback_and_timestamps( 1044 | lookback_period=lookback_period, 1045 | start_timestamp=start_timestamp, 1046 | end_timestamp=end_timestamp, 1047 | ) 1048 | 1049 | additional_params: DictValuesType = {} 1050 | if instrument_id_filter != 0: 1051 | additional_params["tradableInstrumentId"] = instrument_id_filter 1052 | if start_timestamp != 0: 1053 | additional_params["from"] = start_timestamp 1054 | if end_timestamp != 0: 1055 | additional_params["to"] = end_timestamp 1056 | 1057 | response_json = self._request("get", route_url, additional_params=additional_params) 1058 | all_orders_raw = get_nested_key(response_json, ["d", endpoint]) 1059 | 1060 | column_names = self._get_column_names(endpoint + "Config") 1061 | all_orders = pd.DataFrame(all_orders_raw, columns=column_names) 1062 | self._apply_typing(all_orders, OrdersColumns) 1063 | 1064 | return all_orders 1065 | 1066 | @log_func 1067 | @tl_typechecked 1068 | def get_all_positions(self) -> pd.DataFrame: 1069 | """Returns all open positions for account. 1070 | 1071 | Route Name: GET_POSITIONS 1072 | 1073 | Returns: 1074 | pd.DataFrame[PositionsColumnsTypes]: DataFrame containing all positions 1075 | """ 1076 | route_url = f"{self.get_base_url()}/trade/accounts/{self.account_id}/positions" 1077 | 1078 | response_json = self._request("get", route_url) 1079 | all_positions_raw = get_nested_key(response_json, ["d", "positions"]) 1080 | 1081 | all_positions_columns = self._get_column_names("positionsConfig") 1082 | all_positions = pd.DataFrame(all_positions_raw, columns=all_positions_columns) 1083 | self._apply_typing(all_positions, PositionsColumns) 1084 | 1085 | return all_positions 1086 | 1087 | @log_func 1088 | @tl_typechecked 1089 | def get_account_state(self) -> DictValuesType: 1090 | """Returns the account state. 1091 | 1092 | Route Name: GET_ACCOUNT_STATE 1093 | 1094 | Returns: 1095 | DictValuesType: The account state 1096 | """ 1097 | route_url = f"{self.get_base_url()}/trade/accounts/{self.account_id}/state" 1098 | 1099 | response_json = self._request("get", route_url) 1100 | account_state_values = get_nested_key(response_json, ["d", "accountDetailsData"]) 1101 | account_state = dict( 1102 | zip(self._get_column_names("accountDetailsConfig"), account_state_values) 1103 | ) 1104 | return account_state 1105 | 1106 | ############################## INSTRUMENT ROUTES ####################### 1107 | 1108 | @disk_or_memory_cache(cache_validation_callback=expires_after(days=1)) 1109 | @log_func 1110 | @tl_typechecked 1111 | def get_instrument_details( 1112 | self, instrument_id: int, locale: LocaleType = "en" 1113 | ) -> InstrumentDetailsType: 1114 | """Returns instrument details for a given instrument Id. 1115 | 1116 | Route Name: GET_INSTRUMENT_DETAILS 1117 | 1118 | Args: 1119 | instrument_id (int): The instrument Id 1120 | locale (LocaleType, optional): Locale (language) id. Defaults to "en". 1121 | 1122 | Returns: 1123 | InstrumentDetailsType: The instrument details 1124 | """ 1125 | route_url = f"{self.get_base_url()}/trade/instruments/{instrument_id}" 1126 | 1127 | additional_params: DictValuesType = { 1128 | "routeId": self.get_info_route_id(instrument_id), 1129 | "locale": locale, 1130 | } 1131 | 1132 | response_json = self._request("get", route_url, additional_params=additional_params) 1133 | instrument_details: InstrumentDetailsType = get_nested_key( 1134 | response_json, ["d"], InstrumentDetailsType 1135 | ) 1136 | return instrument_details 1137 | 1138 | @log_func 1139 | @tl_typechecked 1140 | def get_session_details(self, session_id: int) -> SessionDetailsType: 1141 | """Returns details about the session defined by session_id. 1142 | 1143 | Route Name: GET_SESSION_DETAILS 1144 | 1145 | Args: 1146 | session_id (int): Session id 1147 | 1148 | Returns: 1149 | SessionDetailsType: Session details 1150 | """ 1151 | route_url = f"{self.get_base_url()}/trade/sessions/{session_id}" 1152 | 1153 | response_json = self._request("get", route_url) 1154 | session_details: SessionDetailsType = get_nested_key( 1155 | response_json, ["d"], SessionDetailsType 1156 | ) 1157 | return session_details 1158 | 1159 | @log_func 1160 | @tl_typechecked 1161 | def get_session_status_details(self, session_status_id: int) -> SessionStatusDetailsType: 1162 | """Returns details about the session status. 1163 | 1164 | Route Name: GET_SESSION_STATUSES 1165 | 1166 | Args: 1167 | session_status_id (int): Session id 1168 | 1169 | Returns: 1170 | SessionStatusDetailsType: Session details 1171 | """ 1172 | route_url = f"{self.get_base_url()}/trade/sessionStatuses/{session_status_id}" 1173 | 1174 | response_json = self._request("get", route_url) 1175 | session_status_details: SessionStatusDetailsType = get_nested_key( 1176 | response_json, ["d"], SessionStatusDetailsType 1177 | ) 1178 | return session_status_details 1179 | 1180 | ############################## MARKET DATA ROUTES ###################### 1181 | 1182 | @log_func 1183 | @tl_typechecked 1184 | def get_daily_bar( 1185 | self, instrument_id: int, bar_type: Literal["BID", "ASK", "TRADE"] = "ASK" 1186 | ) -> DailyBarType: 1187 | """Returns daily candle data for requested instrument. 1188 | 1189 | Route Name: DAILY_BAR 1190 | 1191 | Args: 1192 | instrument_id (int): Instrument Id 1193 | bar_type (Literal[BID, ASK, TRADE], optional): The type of candle data to return. Defaults to "ASK". 1194 | 1195 | Returns: 1196 | DailyBarType: Daily candle data 1197 | """ 1198 | route_url = f"{self.get_base_url()}/trade/dailyBar" 1199 | additional_params: DictValuesType = { 1200 | "tradableInstrumentId": instrument_id, 1201 | "routeId": self.get_info_route_id(instrument_id), 1202 | "barType": bar_type, 1203 | } 1204 | response_json = self._request("get", route_url, additional_params=additional_params) 1205 | daily_bar: DailyBarType = get_nested_key(response_json, ["d"], DailyBarType) 1206 | return daily_bar 1207 | 1208 | # Returns asks and bids 1209 | @log_func 1210 | @tl_typechecked 1211 | def get_market_depth(self, instrument_id: int) -> MarketDepthlistType: 1212 | """Returns market depth information for the requested instrument. 1213 | 1214 | Route Name: DEPTH 1215 | 1216 | Args: 1217 | instrument_id (int): Instrument Id 1218 | 1219 | Returns: 1220 | MarketDepthlistType: Market depth data 1221 | """ 1222 | route_url = f"{self.get_base_url()}/trade/depth" 1223 | 1224 | additional_params: DictValuesType = { 1225 | "tradableInstrumentId": instrument_id, 1226 | "routeId": self.get_info_route_id(instrument_id), 1227 | } 1228 | response_json = self._request("get", route_url, additional_params=additional_params) 1229 | market_depth: MarketDepthlistType = get_nested_key( 1230 | response_json, ["d"], MarketDepthlistType 1231 | ) 1232 | return market_depth 1233 | 1234 | @disk_or_memory_cache() 1235 | @log_func 1236 | @tl_typechecked 1237 | def _request_history_cacheable( 1238 | self, instrument_id: int, route_id: str, resolution: ResolutionType, _from: int, to: int 1239 | ) -> JSONType: 1240 | """Performs a (cacheable) request to the specified URL and handles the response. 1241 | 1242 | The get_price_history is not cacheable based on its parameters due to the lookback_period and end_timestamp, 1243 | which can cause the same function params to require a different answer. 1244 | This is a helper function for get_price_history, which does not have these params and is thus cacheable. 1245 | """ 1246 | route_url = f"{self.get_base_url()}/trade/history" 1247 | 1248 | additional_params = { 1249 | "tradableInstrumentId": instrument_id, 1250 | "routeId": route_id, 1251 | "resolution": resolution, 1252 | "from": _from, 1253 | "to": to, 1254 | } 1255 | 1256 | response_json = self._request("get", route_url, additional_params=additional_params) 1257 | return response_json 1258 | 1259 | @log_func 1260 | @tl_typechecked 1261 | def get_price_history( 1262 | self, 1263 | instrument_id: int, 1264 | resolution: ResolutionType = "15m", 1265 | lookback_period: str = "", 1266 | start_timestamp: int = 0, # timestamps are in miliseconds! 1267 | end_timestamp: int = 0, 1268 | ) -> pd.DataFrame: 1269 | """Returns price history data for the requested instrument. 1270 | 1271 | Route Name: QUOTES_HISTORY 1272 | 1273 | Args: 1274 | instrument_id (int): Instrument Id 1275 | resolution (ResolutionType, optional): Data resolution. Defaults to "15m". 1276 | lookback_period (str, optional): Lookback period (for example "5m"). Defaults to "". 1277 | start_timestamp (int, optional): Start timestamp (in ms). Defaults to 0. 1278 | end_timestamp: (int, optional): End timestamp (in ms). Defaults to 0. 1279 | 1280 | Raises: 1281 | ValueError: Will be raised on a invalid response 1282 | 1283 | Returns: 1284 | pd.DataFrame[PriceHistoryColumnsTypes]: DataFrame containing instrument's historical data 1285 | """ 1286 | route_url = f"{self.get_base_url()}/trade/history" 1287 | 1288 | start_timestamp, end_timestamp = resolve_lookback_and_timestamps( 1289 | lookback_period, start_timestamp, end_timestamp 1290 | ) 1291 | 1292 | history_size = estimate_history_size(start_timestamp, end_timestamp, resolution) 1293 | if history_size > self.max_price_history_rows(): 1294 | raise ValueError( 1295 | f"No. of requested rows ({history_size}) larger than max allowed ({self.max_price_history_rows()})." 1296 | "Try splitting your request in smaller chunks." 1297 | ) 1298 | 1299 | response_json = self._request_history_cacheable( 1300 | instrument_id=instrument_id, 1301 | route_id=self.get_info_route_id(instrument_id), 1302 | resolution=resolution, 1303 | _from=start_timestamp, 1304 | to=end_timestamp, 1305 | ) 1306 | 1307 | try: 1308 | bar_details = pd.DataFrame(get_nested_key(response_json, ["d", "barDetails"])) 1309 | except KeyError as err: 1310 | if response_json["s"] == "no_data": 1311 | self.log.warning("No data returned from the API for the given period") 1312 | # Specify column names to make sure they exist even for empty returns 1313 | bar_details = pd.DataFrame(columns=["t", "o", "h", "l", "c", "v"]) 1314 | else: 1315 | raise err 1316 | 1317 | self._apply_typing(bar_details, PriceHistoryColumns) 1318 | 1319 | return bar_details 1320 | 1321 | @log_func 1322 | @tl_typechecked 1323 | def get_latest_asking_price(self, instrument_id: int) -> float: 1324 | """Returns latest asking price for requested instrument. 1325 | 1326 | Args: 1327 | instrument_id (int): Instrument Id 1328 | 1329 | Returns: 1330 | float: Latest asking price of the instrument 1331 | """ 1332 | current_quotes: dict[str, float] = cast(dict[str, float], self.get_quotes(instrument_id)) 1333 | current_ap: float = get_nested_key(current_quotes, ["ap"], float) 1334 | return current_ap 1335 | 1336 | @log_func 1337 | @tl_typechecked 1338 | def get_latest_bid_price(self, instrument_id: int) -> float: 1339 | """Returns latest bid price for requested instrument. 1340 | 1341 | Args: 1342 | instrument_id (int): Instrument Id 1343 | 1344 | Returns: 1345 | float: Latest bid price of the instrument 1346 | """ 1347 | current_quotes: dict[str, float] = cast(dict[str, float], self.get_quotes(instrument_id)) 1348 | current_bp: float = get_nested_key(current_quotes, ["bp"], float) 1349 | return current_bp 1350 | 1351 | @log_func 1352 | @tl_typechecked 1353 | def get_quotes(self, instrument_id: int) -> QuotesType: 1354 | """Returns price quotes for requested instrument. 1355 | 1356 | Route Name: QUOTES 1357 | 1358 | Args: 1359 | instrument_id (int): Instrument Id 1360 | 1361 | Returns: 1362 | QuotesType: Price quotes for instrument 1363 | """ 1364 | route_url = f"{self.get_base_url()}/trade/quotes" 1365 | 1366 | additional_params: DictValuesType = { 1367 | "tradableInstrumentId": instrument_id, 1368 | "routeId": self.get_info_route_id(instrument_id), 1369 | } 1370 | response_json = self._request("get", route_url, additional_params=additional_params) 1371 | latest_price: QuotesType = get_nested_key(response_json, ["d"], QuotesType) 1372 | return latest_price 1373 | 1374 | @log_func 1375 | @tl_typechecked 1376 | def _perform_order_netting( 1377 | self, instrument_id: int, new_position_side: SideType, quantity: float 1378 | ) -> float: 1379 | """Closes opposite orders (smallest first) to net against the new order. 1380 | 1381 | Sorts the opposite orders by quantity (ascending) and closes them one by one until 1382 | the total quantity of the new order is netted. 1383 | 1384 | Args: 1385 | instrument_id (int): Instrument Id 1386 | new_position_side (SideType): Side to which we want to increase the position 1387 | quantity (float): Order size 1388 | 1389 | Returns: 1390 | float: Total amount that was netted 1391 | 1392 | """ 1393 | opposite_side: str = "sell" if (new_position_side == "buy") else "buy" 1394 | 1395 | all_positions = self.get_all_positions() 1396 | opposite_positions = all_positions.loc[ 1397 | ( 1398 | (all_positions["tradableInstrumentId"] == instrument_id) 1399 | & (all_positions["side"] == opposite_side) 1400 | ) 1401 | ] 1402 | 1403 | # Sort opposite positions by qty (ascending) 1404 | opposite_positions = opposite_positions.sort_values(by="qty") 1405 | 1406 | total_netted: float = 0 1407 | for _, position in opposite_positions.iterrows(): 1408 | if not position["stopLossId"] and not position["takeProfitId"]: 1409 | # Compute how much to close in case a partial close would be needed 1410 | quantity_to_close = min(position["qty"], float(quantity) - total_netted) 1411 | 1412 | self.log.info( 1413 | "Closing position {position_id}, {quantity_to_close} due to position_netting order {order}" 1414 | ) 1415 | self.close_position(position_id=position["id"], close_quantity=quantity_to_close) 1416 | total_netted += quantity_to_close 1417 | 1418 | # If sufficient orders have been placed, return 1419 | if abs(total_netted - float(quantity)) < self._EPS: 1420 | self.log.debug("New position completely netted from opposite positions.") 1421 | break 1422 | 1423 | return total_netted 1424 | 1425 | # TODO(2): add tests for sl/tp 1426 | @log_func 1427 | @tl_typechecked 1428 | def create_order( 1429 | self, 1430 | instrument_id: int, 1431 | quantity: float, 1432 | side: SideType, 1433 | price: Optional[float] = None, 1434 | type_: OrderTypeType = "market", 1435 | validity: Optional[ValidityType] = None, 1436 | position_netting: bool = False, 1437 | take_profit: Optional[float] = None, 1438 | take_profit_type: Optional[TakeProfitType] = None, 1439 | stop_loss: Optional[float] = None, 1440 | stop_loss_type: Optional[StopLossType] = None, 1441 | stop_price: Optional[float] = None, 1442 | strategy_id: Optional[str] = None, 1443 | _ignore_len_check: bool = False, # Temporary value that allows us to better test the function 1444 | ) -> Optional[int]: 1445 | """Creates an order. 1446 | 1447 | Route Name: PLACE_ORDER 1448 | 1449 | Args: 1450 | instrument_id (int): Instrument Id 1451 | quantity (float): Order size 1452 | side (SideType): Order side 1453 | price (float, optional): Price for non-market orders. Defaults to 0. 1454 | type_ (OrderTypeType, optional): Order type. Defaults to "market". 1455 | validity (ValidityType, optional): Validity type of order. Defaults to "IOC". 1456 | position_netting (bool, optional): Should position netting be used. Defaults to False. 1457 | take_profit (float, optional): Take profit value. Defaults to None. 1458 | take_profit_type (_TakeProfitType, optional): Take profit type. Defaults to None. 1459 | stop_loss (float, optional): Stop loss value. Defaults to None. 1460 | stop_loss_type (_StopLossType, optional): Stop loss type. Defaults to None. 1461 | 1462 | Returns: 1463 | Optional[int]: Order Id if order created, otherwise None 1464 | 1465 | Raises: 1466 | ValueError: Will be raised if any of the parameters are invalid 1467 | TLAPIException: Will be raised if the request failed or no valid json received. 1468 | TLAPIOrderException: Will be raised if broker rejected the order. 1469 | """ 1470 | route_url = f"{self.get_base_url()}/trade/accounts/{self.account_id}/orders" 1471 | 1472 | if type_ == "market" and price: 1473 | self.log.warning("Price specified for a market order. Ignoring the price.") 1474 | price = None 1475 | 1476 | if type_ == "market": 1477 | if validity and validity != "IOC": 1478 | error_msg = f"Market orders must use IOC as validity. Not placing the order." 1479 | self.log.error(error_msg) 1480 | raise ValueError(error_msg) 1481 | else: 1482 | validity = "IOC" 1483 | elif not validity: 1484 | error_msg = ( 1485 | "Validity not specified for a non-market order. You must specify validity='GTC'" 1486 | ) 1487 | raise ValueError(error_msg) 1488 | elif validity != "GTC": 1489 | error_msg = f"{type_} orders must use GTC as validity. Not placing the order." 1490 | self.log.error(error_msg) 1491 | raise ValueError(error_msg) 1492 | 1493 | if stop_loss and not stop_loss_type: 1494 | error_msg = "Stop loss value specified, but no stop_loss_type specified. Please set stop_loss_type to 'absolute' or 'offset'" 1495 | self.log.error(error_msg) 1496 | raise ValueError(error_msg) 1497 | 1498 | if take_profit and not take_profit_type: 1499 | error_msg = "Take profit value specified, but no take_profit_type specified. Please set take_profit_type to 'absolute' or 'offset'" 1500 | self.log.error(error_msg) 1501 | raise ValueError(error_msg) 1502 | 1503 | if type_ == "stop" and stop_price is None: 1504 | if not price: 1505 | error_msg = "Stop orders must have a stop price set. Not placing the order." 1506 | else: 1507 | error_msg = f"Order of {type_ = } specified with a price, instead of stop_price. Please set the stop_price instead" 1508 | 1509 | self.log.error(error_msg) 1510 | raise ValueError(error_msg) 1511 | 1512 | if not _ignore_len_check and strategy_id and len(strategy_id) > self._MAX_STRATEGY_ID_LEN: 1513 | error_msg = ( 1514 | f"Strategy ID {strategy_id} is too long. Max length is {self._MAX_STRATEGY_ID_LEN}" 1515 | ) 1516 | self.log.error(error_msg) 1517 | raise ValueError(error_msg) 1518 | 1519 | request_body: dict[str, Any] = { 1520 | "price": price, 1521 | "qty": str(quantity), 1522 | "routeId": self.get_trade_route_id(instrument_id), 1523 | "side": side, 1524 | "validity": validity, 1525 | "tradableInstrumentId": str(instrument_id), 1526 | "type": type_, 1527 | "takeProfit": take_profit, 1528 | "takeProfitType": take_profit_type, 1529 | "stopLoss": stop_loss, 1530 | "stopLossType": stop_loss_type, 1531 | "stopPrice": stop_price, 1532 | "strategyId": strategy_id, 1533 | } 1534 | 1535 | if position_netting: 1536 | self.log.warning( 1537 | "Position netting support is deprecated and will be removed after January 1st 2025. Please stop using it by that date." 1538 | ) 1539 | # Try finding opposite orders to net against 1540 | if type_ == "market": 1541 | total_netted = self._perform_order_netting(instrument_id, side, quantity) 1542 | # Reduce the necessary quantity by the total_amount that was netted 1543 | request_body["qty"] = str(float(request_body["qty"]) - total_netted) 1544 | if float(request_body["qty"]) < self._MIN_LOT_SIZE: 1545 | self.log.info( 1546 | "Not placing a new order after closing sufficient opposite orders due to netting." 1547 | ) 1548 | return None 1549 | else: 1550 | error_msg = ( 1551 | "Order netting is only supported for market orders. Continuing without netting." 1552 | ) 1553 | self.log.error(error_msg) 1554 | raise ValueError(error_msg) 1555 | 1556 | try: 1557 | # Place the order 1558 | response_json: JSONType = self._request("post", route_url, json_data=request_body) 1559 | order_id: int = int(get_nested_key(response_json, ["d", "orderId"], str)) 1560 | self.log.info(f"Order {request_body} placed with order_id: {order_id}") 1561 | return order_id 1562 | except (HTTPError, ValueError) as err: 1563 | # HTTPError will be raised if a non-200 response or any request related issues 1564 | # occur. In that case the response_json will not be available since the excetion happens 1565 | # in _request. A ValueError will be raised if the response received is invalid json. 1566 | raise TLAPIException(f"Request failed {err} with {request_body}") from err 1567 | except KeyError as err: 1568 | raise TLAPIOrderException(request_body, response_json) from err 1569 | 1570 | @log_func 1571 | @tl_typechecked 1572 | def delete_order(self, order_id: int) -> bool: 1573 | """Deletes a pending order. 1574 | 1575 | 1576 | Args: 1577 | order_id (int): Order Id 1578 | 1579 | Returns: 1580 | bool: True on success, False on error 1581 | """ 1582 | route_url = f"{self.get_base_url()}/trade/orders/{order_id}" 1583 | 1584 | self.log.info(f"Deleting order with id {order_id}") 1585 | 1586 | response_json = self._request("delete", url=route_url) 1587 | self.log.info(f"Order deletion response: {response_json}") 1588 | response_status: str = get_nested_key(response_json, ["s"], str) 1589 | 1590 | return response_status == "ok" 1591 | 1592 | @log_func 1593 | @tl_typechecked 1594 | def modify_order(self, order_id: int, modification_params: ModificationParamsType) -> bool: 1595 | """Modifies a pending order -- a thin wrapper around PATCH /trade/orders/{order_id}. 1596 | 1597 | Route Name: MODIFY_ORDER 1598 | 1599 | Args: 1600 | order_id (int): Order Id 1601 | modification_params (ModificationParamsType): Order modification details 1602 | 1603 | Returns: 1604 | bool: True on success, False on error 1605 | """ 1606 | route_url = f"{self.get_base_url()}/trade/orders/{order_id}" 1607 | 1608 | self.log.info(f"Modifying the order with id {order_id}") 1609 | 1610 | response_json = self._request("patch", route_url, json_data=modification_params) 1611 | response_status: str = get_nested_key(response_json, ["s"], str) 1612 | return response_status == "ok" 1613 | 1614 | @log_func 1615 | @tl_typechecked 1616 | def modify_position( 1617 | self, position_id: int, modification_params: ModificationParamsType 1618 | ) -> bool: 1619 | """Modifies an open position. 1620 | 1621 | Route Name: MODIFY_POSITION 1622 | 1623 | Args: 1624 | position_id (int): Position Id 1625 | modification_params (_ModificationParamsType): Position modification details 1626 | 1627 | Returns: 1628 | bool: True on success, False on error 1629 | """ 1630 | route_url = f"{self.get_base_url()}/trade/positions/{position_id}" 1631 | 1632 | self.log.info(f"Modifying the position with id {position_id}") 1633 | 1634 | response_json = self._request("patch", route_url, json_data=modification_params) 1635 | response_status: str = get_nested_key(response_json, ["s"], str) 1636 | return response_status == "ok" 1637 | 1638 | @log_func 1639 | @tl_typechecked 1640 | def get_position_id_from_order_id(self, order_id: int) -> Optional[int]: 1641 | """Retrieves position_id from the given order_id (if one exists). 1642 | 1643 | Args: 1644 | order_id (int): An order id 1645 | 1646 | Returns: 1647 | Optional[int]: position_id or None 1648 | """ 1649 | self.log.info(f"Getting execution id from orders history") 1650 | orders_history = self.get_all_orders(history=True) 1651 | 1652 | matching_orders = orders_history[orders_history["id"] == order_id] 1653 | if len(matching_orders) == 0: 1654 | self.log.info(f"No matching order found for order_id: {order_id}") 1655 | return None 1656 | 1657 | position_id = int(matching_orders["positionId"].iloc[0]) 1658 | return position_id 1659 | -------------------------------------------------------------------------------- /src/tradelocker/types.py: -------------------------------------------------------------------------------- 1 | from typing import TypeAlias as TA, Literal, Optional 2 | from numpy import int64 3 | 4 | # Custom type aliases 5 | RequestsMappingType: TA = dict[str, str | bytes] 6 | StopLossType: TA = Literal["absolute", "offset", "trailingOffset"] 7 | TakeProfitType: TA = Literal["absolute", "offset"] 8 | ValidityType: TA = Literal["GTC", "IOC"] 9 | MarketDepthlistType: TA = dict[Literal["asks", "bids"], list[list[float]]] 10 | OrderTypeType: TA = Literal["limit", "market", "stop"] 11 | TradingRulesType: TA = dict[str, bool | int] 12 | RiskRulesType: TA = dict[str, Optional[int | float]] 13 | TradeAccountsType: TA = list[dict[str, str | TradingRulesType | RiskRulesType]] 14 | DailyBarType: TA = dict[Literal["c", "h", "l", "o", "v"], float] 15 | SessionType: TA = dict[str, str | list[dict[str, str]]] 16 | SideType: TA = Literal["buy", "sell"] 17 | SessionHolidayType: TA = dict[str, Optional[str | None]] 18 | JSONType: TA = dict[str, object] 19 | SessionDetailsType: TA = dict[str, str | bool | None | list[SessionHolidayType] | SessionType] 20 | SessionStatusDetailsType: TA = dict[ 21 | Literal["allowedOperations", "allowedOrderTypes"], list[Literal[0, 1]] 22 | ] 23 | InstrumentDetailsType: TA = dict[str, str | int | float | list[dict[str, int | float]]] 24 | ColumnConfigKeysType: TA = Literal[ 25 | "positionsConfig", 26 | "ordersConfig", 27 | "ordersHistoryConfig", 28 | "filledOrdersConfig", 29 | "accountDetailsConfig", 30 | ] 31 | ConfigColumnType: TA = list[dict[Literal["id", "description"], str]] 32 | ColumnConfigValuesType: TA = ( 33 | dict[Literal["id", "title"], str] | dict[Literal["columns"], ConfigColumnType] 34 | ) 35 | 36 | 37 | RouteNamesType: TA = Literal[ 38 | "GET_ACCOUNTS", 39 | "GET_EXECUTIONS", 40 | "GET_INSTRUMENTS", 41 | "GET_ORDERS", 42 | "GET_ORDERS_HISTORY", 43 | "GET_POSITIONS", 44 | "GET_ACCOUNTS_STATE", 45 | "GET_INSTRUMENT_DETAILS", 46 | "GET_TRADE_SESSIONS", 47 | "GET_SESSION_STATUSES", 48 | "PLACE_ORDER", 49 | "MODIFY_ORDER", 50 | "MODIFY_POSITION", 51 | "DAILY_BAR", 52 | "QUOTES", 53 | "DEPTH", 54 | "TRADES", 55 | "QUOTES_HISTORY", 56 | ] 57 | 58 | RateLimitMeasureTypes: TA = Literal["SECONDS", "MINUTES"] 59 | 60 | LimitsType: TA = dict[Literal["limitType", "limit"], str | int | float] 61 | RateLimitType: TA = dict[ 62 | Literal["rateLimitType", "measure", "intervalNum", "limit"], 63 | int | float | RateLimitMeasureTypes | RouteNamesType, 64 | ] 65 | 66 | ConfigType: TA = ( 67 | dict[Literal["customerAccess"], dict[str, bool]] 68 | | dict[ColumnConfigKeysType, ColumnConfigValuesType] 69 | | dict[Literal["limits"], list[LimitsType]] 70 | | dict[ 71 | Literal["rateLimits"], 72 | list[RateLimitType], 73 | ] 74 | ) 75 | 76 | ResolutionType: TA = Literal["1M", "1W", "1D", "4H", "1H", "30m", "15m", "5m", "1m"] 77 | ModificationParamsType: TA = dict[str, str | float | StopLossType | TakeProfitType | ValidityType] 78 | LocaleType: TA = Literal[ 79 | "ar", "en", "es", "fr", "ja", "ko", "pl", "pt", "ru", "tr", "ua", "zh_sm", "zh_tr" 80 | ] 81 | EnvironmentsType: TA = Literal["demo", "live"] 82 | DevEnvironmentsType: TA = Literal["dev", "stg", "exp"] # For internal use 83 | RouteType: TA = dict[Literal["id", "type"], int | str] 84 | RouteTypeType: TA = Literal["INFO", "TRADE"] 85 | LogLevelType: TA = Literal["debug", "info", "warning", "error", "critical", "notset"] 86 | DictValuesType: TA = dict[str, str | float | int] 87 | CredentialsType: TA = dict[Literal["username", "password", "server"], str] 88 | QuotesKeyType: TA = Literal["ap", "bp", "as", "bs"] 89 | QuotesType: TA = dict[QuotesKeyType, float] 90 | 91 | 92 | AccountsColumns: dict[str, type] = { 93 | "id": int64, 94 | "name": str, 95 | "currency": str, 96 | "accNum": int64, 97 | "accountBalance": float, 98 | } 99 | 100 | ExecutionsColumns: dict[str, type] = { 101 | "id": int64, 102 | "price": float, 103 | "side": SideType, 104 | "createdDate": int64, 105 | "qty": float, 106 | "orderId": int64, 107 | "positionId": int64, 108 | "tradableInstrumentId": int64, 109 | } 110 | 111 | OrdersColumns: dict[str, type] = { 112 | "id": int64, 113 | "tradableInstrumentId": int64, 114 | "routeId": int64, 115 | "qty": float, 116 | "side": SideType, 117 | "type": OrderTypeType, 118 | "status": str, 119 | "filledQty": float, 120 | "avgPrice": float, 121 | "price": float, 122 | "stopPrice": float, 123 | "validity": ValidityType, 124 | "expireDate": int64, 125 | "createdDate": int64, 126 | "lastModified": int64, 127 | "isOpen": bool, 128 | "positionId": int64, 129 | "stopLoss": float, 130 | "stopLossType": StopLossType, 131 | "takeProfit": float, 132 | "takeProfitType": TakeProfitType, 133 | "strategyId": str, 134 | } 135 | 136 | PositionsColumns: dict[str, type] = { 137 | "id": int64, 138 | "tradableInstrumentId": int64, 139 | "routeId": int64, 140 | "side": SideType, 141 | "qty": float, 142 | "avgPrice": float, 143 | "stopLossId": int64, 144 | "takeProfitId": int64, 145 | "openDate": int64, 146 | "unrealizedPl": float, 147 | "strategyId": str, 148 | } 149 | 150 | PriceHistoryColumns: dict[str, type] = { 151 | "t": int64, 152 | "o": float, 153 | "h": float, 154 | "l": float, 155 | "c": float, 156 | "v": float, 157 | } 158 | 159 | InstrumentsColumns: dict[str, type] = { 160 | "tradableInstrumentId": int64, 161 | "id": int64, 162 | "name": str, 163 | "description": str, 164 | "type": str, 165 | "tradingExchange": str, 166 | "marketDataExchange": str, 167 | "country": str, 168 | "logoUrl": str, 169 | "localizedName": str, 170 | "routes": list[RouteType], 171 | "barSource": str, 172 | "hasIntraday": bool, 173 | "hasDaily": bool, 174 | } 175 | 176 | AccountDetailsColumns: dict[str, type] = { 177 | "balance": float, 178 | "projectedBalance": float, 179 | "availableFunds": float, 180 | "blockedBalance": float, 181 | "cashBalance": float, 182 | "unsettledCash": float, 183 | "withdrawalAvailable": float, 184 | "stocksValue": float, 185 | "optionValue": float, 186 | "initialMarginReq": float, 187 | "maintMarginReq": float, 188 | "marginWarningLevel": float, 189 | "blockedForStocks": float, 190 | "stockOrdersReq": float, 191 | "stopOutLevel": float, 192 | "warningMarginReq": float, 193 | "marginBeforeWarning": float, 194 | "todayGross": float, 195 | "todayNet": float, 196 | "todayFees": float, 197 | "todayVolume": float, 198 | "todayTradesCount": int64, 199 | "openGrossPnL": float, 200 | "openNetPnL": float, 201 | "positionsCount": int64, 202 | "ordersCount": int64, 203 | } 204 | 205 | order_history_statuses = ["Filled", "Cancelled", "Refused", "Unplaced", "Removed"] 206 | -------------------------------------------------------------------------------- /src/tradelocker/utils.py: -------------------------------------------------------------------------------- 1 | from functools import wraps, lru_cache 2 | import inspect 3 | from typing import Any, Callable, TypeVar, cast 4 | import datetime 5 | import logging as logging_module 6 | import time 7 | import os 8 | 9 | from dotenv import dotenv_values 10 | import jwt 11 | 12 | from requests.exceptions import RequestException 13 | from tradelocker.types import ResolutionType, LogLevelType 14 | 15 | # This will allow us to keep track of the return type of the functions 16 | # being decorated. 17 | RT = TypeVar("RT") # Return Type 18 | 19 | # ----------- Import conditional dependencies --------------- 20 | try: 21 | # Try importing typechecked from typeguard 22 | logging_module.info("typechecked imported from typeguard") 23 | from typeguard import typechecked as tl_typechecked 24 | 25 | except ImportError: 26 | logging_module.info("typechecked defined as a noop decatorator") 27 | 28 | # If it fails, define a noop decorator 29 | def tl_typechecked(func: Callable[..., RT]) -> Callable[..., RT]: 30 | return func 31 | 32 | 33 | try: 34 | from typeguard import check_type as tl_check_type 35 | except ImportError: 36 | # Define a noop check_type function 37 | def tl_check_type(arg: Any, arg_type: Any) -> None: 38 | pass 39 | 40 | 41 | # ------------------------------------------------------------ 42 | 43 | # Constants 44 | MS_COEFF = 1000 45 | RESOLUTION_COEFF_MS = { 46 | "s": 1 * MS_COEFF, 47 | "m": 60 * MS_COEFF, 48 | "H": 60 * 60 * MS_COEFF, 49 | "D": 24 * 60 * 60 * MS_COEFF, 50 | "W": 7 * 24 * 60 * 60 * MS_COEFF, 51 | "M": 30 * 24 * 60 * 60 * MS_COEFF, 52 | "Y": 365 * 24 * 60 * 60 * MS_COEFF, 53 | } 54 | 55 | # Default logging if setup_utils_logging was not called 56 | logging = logging_module 57 | 58 | 59 | # Overwrites the logging module 60 | def setup_utils_logging(logger: logging_module.Logger) -> None: 61 | global logging 62 | logging = logger 63 | 64 | 65 | def get_logger(name: str, log_level: LogLevelType, format: str) -> logging_module.Logger: 66 | """Returns a logger with the specified name, log_level and format.""" 67 | logger = logging_module.getLogger(name) 68 | logger.setLevel(log_level.upper()) 69 | # add handler only once 70 | if len(logger.handlers) == 0: 71 | handler = logging_module.StreamHandler() 72 | handler.setFormatter(logging_module.Formatter(format)) 73 | logger.addHandler(handler) 74 | return logger 75 | 76 | 77 | # Define the new method 78 | def always_return_true(*args, **kwargs): 79 | return True 80 | 81 | 82 | # This decorator logs the function call and its arguments 83 | def log_func(func: Callable[..., RT]) -> Callable[..., RT]: 84 | @wraps(func) 85 | def wrapper(*args: Any, **kwargs: Any) -> RT: 86 | args_repr = [repr(a) for a in args] 87 | kwargs_repr = [f"{k}={v!r}" for k, v in kwargs.items()] 88 | signature = ", ".join(args_repr + kwargs_repr) 89 | 90 | logging.debug(f"**** CALLING {func.__name__}({signature})") 91 | 92 | return_value = func(*args, **kwargs) 93 | 94 | max_return_string_length = 1000 95 | return_string = repr(return_value) 96 | if len(return_string) > max_return_string_length: 97 | return_string = ( 98 | return_string[:max_return_string_length] 99 | + " ... ===<< TRUNCATED DUE TO LENGTH >>=== " 100 | ) 101 | logging.debug(f"**** RETURN from {func.__name__}({signature}):\n{return_string}") 102 | 103 | return return_value 104 | 105 | return cast(Callable[..., RT], wrapper) 106 | 107 | 108 | def has_parameter(func, param_name): 109 | signature = inspect.signature(func) 110 | return param_name in signature.parameters 111 | 112 | 113 | # Use disk_cache (joblib) if self._disk_cache is set, otherwise uses lru_cache 114 | def disk_or_memory_cache(cache_validation_callback=None): 115 | def decorator(func): 116 | # Get the original function signature 117 | sig = inspect.signature(func) 118 | # Create a new parameter for '_cache_key' 119 | cache_key_param = inspect.Parameter( 120 | "_cache_key", kind=inspect.Parameter.KEYWORD_ONLY, default=None 121 | ) 122 | # Build a new signature with '_cache_key' added 123 | new_params = [*sig.parameters.values(), cache_key_param] 124 | new_sig = sig.replace(parameters=new_params) 125 | 126 | @wraps(func) 127 | def wrapper(*args, **kwargs): 128 | cache_attr = f"__cached_{func.__name__}" 129 | if len(args) == 0: 130 | raise ValueError( 131 | "Decorator must be used with a class method. First argument must be 'self'" 132 | ) 133 | self = args[0] 134 | 135 | # Define a new function with the updated signature 136 | @wraps(func) 137 | def func_with_cache_key(*args, _cache_key=None, **kwargs): 138 | return func(*args, **kwargs) 139 | 140 | # Assign the new signature to the function 141 | func_with_cache_key.__signature__ = new_sig 142 | func_with_cache_key.__name__ = func.__name__ + "_with_cache_key" 143 | 144 | if not hasattr(self, cache_attr): 145 | if hasattr(self, "_disk_cache") and self._disk_cache is not None: 146 | cached_func_applied_self = self._disk_cache.cache( 147 | ignore=["self"], cache_validation_callback=cache_validation_callback 148 | )(func_with_cache_key) 149 | logging.debug(f"Creating disk cache for {func.__name__}") 150 | else: 151 | cached_func_applied_self = lru_cache()(func_with_cache_key) 152 | logging.debug(f"Creating memory cache for {func.__name__}") 153 | 154 | setattr(self, cache_attr, cached_func_applied_self) 155 | else: 156 | cached_func_applied_self = getattr(self, cache_attr) 157 | 158 | # Add '_cache_key' to kwargs 159 | kwargs["_cache_key"] = getattr(self, "_cache_key", None) 160 | response = cached_func_applied_self(*args, **kwargs) 161 | return response 162 | 163 | return wrapper 164 | 165 | return decorator 166 | 167 | 168 | def retry(func: Callable[..., RT], delay: float = 1) -> Callable[..., RT]: 169 | @wraps(func) 170 | def wrapper(*args: Any, **kwargs: Any) -> RT: 171 | max_retries = 3 172 | 173 | for attempt in range(max_retries): 174 | time.sleep(delay) # Must be below delay limit 175 | try: 176 | return func(*args, **kwargs) 177 | except RequestException as err: 178 | logging.warning(f"Retry #{attempt}, Error: {err}, retrying...") 179 | return func(*args, **kwargs) 180 | 181 | return cast(Callable[..., RT], wrapper) 182 | 183 | 184 | # Returns the value of a nested key in a JSON object 185 | @tl_typechecked 186 | def get_nested_key( 187 | json_data: dict[str, Any], keys: list[str], return_type_assertion: Any = None 188 | ) -> Any: 189 | current_data: Any = json_data 190 | for key in keys: 191 | if key not in current_data: 192 | raise KeyError(f"Key {key} ({keys}) missing from JSON data {str(json_data)}") 193 | 194 | current_data = current_data[key] 195 | 196 | if return_type_assertion: 197 | # check whether current_data is of type return_type_assertion 198 | tl_check_type(current_data, return_type_assertion) 199 | 200 | return current_data 201 | 202 | 203 | @tl_typechecked 204 | def timestamps_from_lookback(lookback_period: str) -> tuple[int, int]: 205 | assert ( 206 | len(lookback_period) > 1 207 | ), f"lookback_period ({lookback_period}) must be at least 2 characters long" 208 | 209 | lookback_period_num = int(lookback_period[:-1]) 210 | 211 | if lookback_period[-1] not in RESOLUTION_COEFF_MS: 212 | raise ValueError( 213 | f"last character ({lookback_period[-1]}) not among {RESOLUTION_COEFF_MS.keys()}" 214 | ) 215 | 216 | end_timestamp = int(datetime.datetime.now().timestamp() * MS_COEFF) 217 | # Depending on the lookback_period, we need to calculate the start_timestamp 218 | start_timestamp = end_timestamp - lookback_period_num * RESOLUTION_COEFF_MS[lookback_period[-1]] 219 | 220 | logging.debug(f"start_timestamp: {start_timestamp}") 221 | logging.debug(f"end_timestamp: {end_timestamp}") 222 | 223 | return start_timestamp, end_timestamp 224 | 225 | 226 | def convert_resolution_to_mins(resolution: ResolutionType) -> int: 227 | # if last character is "m", then it is minutes, "H" is for hours, "D" for days, W weeks, M monthts 228 | if resolution[-1] in RESOLUTION_COEFF_MS: 229 | val = int(resolution[:-1]) 230 | val_ms = val * RESOLUTION_COEFF_MS[resolution[-1]] 231 | if val_ms < 60 * 1000: 232 | raise ValueError(f"Resolution {resolution} is too small. Minimum is 1 minute.") 233 | return_value = val_ms // (60 * 1000) 234 | logging.debug(f"Converted {resolution} to minutes: {return_value}") 235 | return return_value 236 | 237 | raise ValueError(f"last character of {resolution[-1]} not among {RESOLUTION_COEFF_MS.keys()}") 238 | 239 | 240 | @tl_typechecked 241 | def resolve_lookback_and_timestamps( 242 | lookback_period: str, start_timestamp: int, end_timestamp: int 243 | ) -> tuple[int, int]: 244 | """This assumes that either lookback_period or start timestamp is provided. 245 | lookback_period needs to be in the format of 1Y, 1M, 1D, 1H, 1m, 1s, where M = 30 days and Y = 365 days 246 | """ 247 | # If end_timestamp is 0, we can assume that we want to get data until now 248 | if end_timestamp == 0: 249 | end_timestamp = int(datetime.datetime.now().timestamp() * MS_COEFF) 250 | 251 | valid_timestamps = ( 252 | start_timestamp != 0 and end_timestamp != 0 and start_timestamp <= end_timestamp 253 | ) 254 | if valid_timestamps: 255 | if lookback_period != "": 256 | logging.warning( 257 | "Both lookback_period and start_timestamp/end_timestamp were provided.\n" 258 | "Continuing with only the start_timestamp/end_timestamp." 259 | ) 260 | return start_timestamp, end_timestamp 261 | 262 | try: 263 | start_from_lookback, end_from_lookback = timestamps_from_lookback(lookback_period) 264 | return start_from_lookback, end_from_lookback 265 | except Exception as e: 266 | raise ValueError( 267 | "Neither lookback_period nor valid start_timestamp/end_timestamp provided." 268 | ) from e 269 | 270 | 271 | @tl_typechecked 272 | def estimate_history_size( 273 | start_timestamp: int, end_timestamp: int, resolution: ResolutionType 274 | ) -> int: 275 | total_miliseconds: float = end_timestamp - start_timestamp 276 | coeff = int(resolution[:-1]) * RESOLUTION_COEFF_MS[resolution[-1]] 277 | total_bars: int = int(total_miliseconds / coeff) 278 | return total_bars 279 | 280 | 281 | @tl_typechecked 282 | def time_to_token_expiry(access_token: str) -> float: 283 | if not access_token: 284 | logging.warning(f"invalid access token: |{access_token}|") 285 | return 0 286 | 287 | # No explicit need to verify the signature as there is a direct https connection between the client and the server 288 | decoded_payload: dict[str, Any] = jwt.decode(access_token, options={"verify_signature": False}) 289 | expiration_time: float = decoded_payload["exp"] 290 | remaining_time: float = expiration_time - datetime.datetime.now().timestamp() 291 | return remaining_time 292 | 293 | 294 | @tl_typechecked 295 | # Should be called with callers_file = __file__ 296 | def load_env_config(callers_file: str, backup_env_file=".env") -> dict[str, str | int]: 297 | """Load the .env file from the path defined in ENV_FILE_PATH or the backup_env_file, relative to current dir""" 298 | 299 | # Get the current script's directory 300 | script_dir = os.path.abspath(os.path.dirname(callers_file)) 301 | 302 | env_var_name = "ENV_FILE_PATH" 303 | 304 | # read the "$(env_var_name)" environment variable if it exists, otherwise use .env or .env-test 305 | env_path = os.environ.get(env_var_name, os.path.join(script_dir, backup_env_file)) 306 | 307 | # Load the .env file from that directory 308 | config: dict[str, str] = dotenv_values(env_path) 309 | if "tl_acc_num" not in config: 310 | config["tl_acc_num"] = 0 311 | 312 | return config 313 | 314 | 315 | @tl_typechecked 316 | def is_more_frequent(resolution1: ResolutionType, resolution2: ResolutionType) -> bool: 317 | all_resolutions = ["1m", "5m", "15m", "30m", "1H", "4H", "1D", "1W", "1M"] 318 | return all_resolutions.index(resolution1) < all_resolutions.index(resolution2) 319 | -------------------------------------------------------------------------------- /tests/test_tl_api.py: -------------------------------------------------------------------------------- 1 | import random 2 | import string 3 | from time import sleep 4 | from datetime import datetime, timedelta 5 | import re 6 | import os 7 | import pandas as pd 8 | import pytest 9 | from typing import Literal, Optional 10 | from tradelocker.tradelocker_api import TLAPIException, TLAPIOrderException 11 | from tradelocker.utils import load_env_config, tl_check_type 12 | from tradelocker import TLAPI 13 | from tradelocker.types import ( 14 | AccountDetailsColumns, 15 | InstrumentDetailsType, 16 | RateLimitType, 17 | RateLimitMeasureTypes, 18 | RouteNamesType, 19 | SessionDetailsType, 20 | QuotesType, 21 | QuotesKeyType, 22 | OrdersColumns, 23 | ExecutionsColumns, 24 | PositionsColumns, 25 | ) 26 | 27 | MAX_ORDER_HISTORY = 10000 28 | 29 | # check if the typeguard is installed and raise an explicit error if not 30 | try: 31 | from typeguard import TypeCheckError 32 | except ImportError: 33 | raise ImportError( 34 | "====== To run tests, you should manually install typeguard using 'poetry run pip install typeguard' =====" 35 | ) 36 | 37 | 38 | # How long do each of the sleeps last? 39 | # Largely used to not trigger the rate limits and/or to allow the server to process the requests 40 | LONG_BREAK = 0.8 41 | MID_BREAK = 0.5 42 | SHORT_BREAK = 0.3 43 | 44 | # default timestamp to use in tests to have a common start time 45 | # when fetching orders 46 | hour_ago_timestamp = int((datetime.now() - timedelta(hours=1)).timestamp() * 1000) 47 | 48 | 49 | # Create the global fixture 50 | @pytest.fixture(scope="session", autouse=True) 51 | def setup_everything(): 52 | global tl, config, default_instrument_id, default_symbol_name 53 | 54 | parent_folder_env = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".env") 55 | config = load_env_config(__file__, backup_env_file=parent_folder_env) 56 | 57 | tl = TLAPI( 58 | environment=config["tl_environment"], 59 | username=config["tl_email"], 60 | password=config["tl_password"], 61 | server=config["tl_server"], 62 | acc_num=int(config.get("tl_acc_num", 0)), 63 | developer_api_key=config.get("tl_developer_api_key", None), 64 | ) 65 | 66 | default_symbol_name = "BTCUSD" # Since the market is always open for crypto 67 | default_instrument_id = tl.get_instrument_id_from_symbol_name(default_symbol_name) 68 | assert default_instrument_id 69 | 70 | 71 | def test_user_accounts(): 72 | all_account_nums = tl.get_all_accounts()["accNum"] 73 | first_account_id = int(tl.get_all_accounts()["id"].iloc[0]) 74 | 75 | with pytest.raises(ValueError): 76 | tl0 = TLAPI( 77 | environment=config["tl_environment"], 78 | username=config["tl_email"], 79 | password=config["tl_password"], 80 | server=config["tl_server"], 81 | developer_api_key=config.get("tl_developer_api_key", None), 82 | acc_num=-1, 83 | ) 84 | 85 | tl1 = TLAPI( 86 | environment=config["tl_environment"], 87 | username=config["tl_email"], 88 | password=config["tl_password"], 89 | server=config["tl_server"], 90 | developer_api_key=config.get("tl_developer_api_key", None), 91 | acc_num=int(all_account_nums.iloc[0]), 92 | ) 93 | 94 | assert tl1 95 | 96 | tl1_by_id = TLAPI( 97 | environment=config["tl_environment"], 98 | username=config["tl_email"], 99 | password=config["tl_password"], 100 | server=config["tl_server"], 101 | developer_api_key=config.get("tl_developer_api_key", None), 102 | account_id=first_account_id, 103 | ) 104 | 105 | assert len(all_account_nums) > 0 106 | 107 | if len(all_account_nums) > 1: 108 | tl2 = TLAPI( 109 | environment=config["tl_environment"], 110 | username=config["tl_email"], 111 | password=config["tl_password"], 112 | server=config["tl_server"], 113 | developer_api_key=config.get("tl_developer_api_key", None), 114 | acc_num=int(all_account_nums.iloc[1]), 115 | ) 116 | 117 | assert tl2.account_id != tl1.account_id 118 | 119 | 120 | def get_hour_ago_timestamp(): 121 | """Get the hour ago timestamp in milliseconds""" 122 | return hour_ago_timestamp 123 | 124 | 125 | # test that __about__.py 's __version__ is set 126 | def test_version(): 127 | from tradelocker.__about__ import __version__ 128 | 129 | assert __version__ is not None 130 | pattern = r"^\d+\.\d+\.\d+(-(alpha|beta|rc)\.\d+)?$" 131 | assert re.match(pattern, __version__), f"Invalid version format: {__version__}" 132 | 133 | 134 | def test_refresh_tokens(): 135 | old_access_token = tl.get_access_token() 136 | old_refresh_token = tl.get_refresh_token() 137 | tl.refresh_access_tokens() 138 | assert tl.get_access_token() != old_access_token 139 | assert tl.get_refresh_token() != old_refresh_token 140 | 141 | 142 | def test_latest_asking_price(): 143 | latest_price = tl.get_latest_asking_price(default_instrument_id) 144 | tl_check_type(latest_price, float) 145 | assert latest_price 146 | 147 | 148 | def test_quotes(): 149 | quotes = tl.get_quotes(default_instrument_id) 150 | tl_check_type(quotes, QuotesType) 151 | keys = QuotesKeyType.__args__ 152 | for key in keys: 153 | assert key in quotes 154 | 155 | 156 | def test_get_instrument_id(): 157 | eurusd_id = tl.get_instrument_id_from_symbol_name("EURUSD") 158 | assert eurusd_id == 278 159 | with pytest.raises(ValueError): 160 | tl.get_instrument_id_from_symbol_name("DOESNOTEXIST") 161 | 162 | all_instruments = tl.get_all_instruments() 163 | assert eurusd_id in all_instruments["tradableInstrumentId"].values 164 | 165 | eurusdSymbolId = int( 166 | all_instruments[all_instruments["tradableInstrumentId"] == eurusd_id]["id"].values[0] 167 | ) 168 | 169 | eurusd_id_from_symbol_id = tl.get_instrument_id_from_symbol_id(eurusdSymbolId) 170 | assert eurusd_id_from_symbol_id == eurusd_id 171 | 172 | 173 | # TODO: test this properly! 174 | def test_all_executions(): 175 | all_executions = tl.get_all_executions() 176 | tl_check_type(all_executions, pd.DataFrame) 177 | assert set(all_executions.columns) == set(ExecutionsColumns.keys()) 178 | 179 | tl.create_order(default_instrument_id, quantity=0.01, side="buy", price=0, type_="market") 180 | assert "positionId" in all_executions 181 | 182 | 183 | def test_access_token(): 184 | # Check whether access token was received 185 | assert hasattr(tl, "_access_token") 186 | assert tl._access_token != "" 187 | 188 | 189 | def test_get_position_id_from_order_id(): 190 | order_id = tl.create_order( 191 | default_instrument_id, quantity=0.01, side="buy", price=0, type_="market" 192 | ) 193 | sleep(MID_BREAK) 194 | position_id = position_id_from_order_id(order_id) 195 | assert position_id 196 | assert isinstance(position_id, int) 197 | 198 | all_orders = tl.get_all_orders(history=True) 199 | assert order_id in all_orders["id"].values 200 | assert position_id in all_orders["positionId"].values 201 | 202 | 203 | def test_old_history(): 204 | prices_history_last_2Y = tl.get_price_history( 205 | default_instrument_id, resolution="1D", lookback_period="3Y" 206 | ) 207 | 208 | assert not prices_history_last_2Y.empty 209 | 210 | ts_2022_05_01 = 1651396149000 211 | ts_2022_07_01 = 1656666549000 212 | prices_history_2022 = tl.get_price_history( 213 | default_instrument_id, 214 | resolution="1H", 215 | start_timestamp=ts_2022_05_01, 216 | end_timestamp=ts_2022_07_01, 217 | ) 218 | 219 | assert not prices_history_2022.empty 220 | # size > 100 221 | assert len(prices_history_2022) > 100 222 | 223 | 224 | def test_multiton_single_account(): 225 | tl2 = TLAPI( 226 | environment=config["tl_environment"], 227 | username=config["tl_email"], 228 | password=config["tl_password"], 229 | server=config["tl_server"], 230 | developer_api_key=config.get("tl_developer_api_key", None), 231 | acc_num=int(config["tl_acc_num"]), 232 | ) 233 | 234 | assert tl2 235 | assert tl2 == tl 236 | 237 | 238 | def test_multiton_multiple_accounts(): 239 | all_account_nums = tl.get_all_accounts()["accNum"] 240 | 241 | # Check that there are more than one account in the list (required for testing) 242 | if len(all_account_nums) == 1: 243 | pytest.skip("Need more than one account to test multiton") 244 | 245 | assert len(all_account_nums) > 1, "Need more than one account to test multiton" 246 | 247 | other_acc_num = -1 248 | used_acc_num = int(config.get("tl_acc_num", 0)) 249 | 250 | for acc_num in all_account_nums: 251 | if acc_num != used_acc_num: 252 | other_acc_num = acc_num 253 | break 254 | 255 | tl3 = TLAPI( 256 | environment=config["tl_environment"], 257 | username=config["tl_email"], 258 | password=config["tl_password"], 259 | server=config["tl_server"], 260 | developer_api_key=config.get("tl_developer_api_key", None), 261 | acc_num=int(other_acc_num), 262 | ) 263 | 264 | assert tl3 265 | assert tl3 != tl 266 | 267 | 268 | def test_price_history(): 269 | price_history = tl.get_price_history( 270 | default_instrument_id, resolution="1D", lookback_period="5D" 271 | ) 272 | assert not price_history.empty 273 | tl_check_type(price_history, pd.DataFrame) 274 | assert "c" in price_history 275 | assert "h" in price_history 276 | assert "l" in price_history 277 | assert "o" in price_history 278 | assert price_history["c"].iloc[-1] > 0 279 | 280 | # Check that ValueError is raised when resolution is not valid 281 | with pytest.raises(TypeCheckError): 282 | price_history = tl.get_price_history( 283 | default_instrument_id, resolution="bla", lookback_period="5D" 284 | ) 285 | assert price_history == None 286 | sleep(LONG_BREAK) 287 | 288 | # Should fail since "m4" is not a good lookback period value 289 | with pytest.raises(ValueError): 290 | price_history = tl.get_price_history( 291 | default_instrument_id, resolution="15m", lookback_period="m4" 292 | ) 293 | sleep(LONG_BREAK) 294 | 295 | # Should fail due to trying to fetch too much data 296 | with pytest.raises(ValueError): 297 | tl.get_price_history(default_instrument_id, resolution="5m", lookback_period="5Y") 298 | sleep(LONG_BREAK) 299 | 300 | # Should also fail due to fetching too much data 301 | with pytest.raises(ValueError): 302 | tl.get_price_history(default_instrument_id, resolution="1m", lookback_period="2Y") 303 | sleep(LONG_BREAK) 304 | 305 | price_history_15m_1M = tl.get_price_history( 306 | default_instrument_id, resolution="15m", lookback_period="1M" 307 | ) 308 | assert not price_history_15m_1M.empty 309 | sleep(LONG_BREAK) 310 | 311 | price_history_1H_1Y = tl.get_price_history( 312 | default_instrument_id, resolution="15m", lookback_period="1M" 313 | ) 314 | assert not price_history_1H_1Y.empty 315 | sleep(LONG_BREAK) 316 | 317 | jan_1st_2020_ms: int = 1578524400000 318 | jan_1st_2023_ms: int = 1672527600000 319 | jan_9th_2023_ms: int = 1673218800000 320 | jun_1st_2023_ms: int = 1685570400000 321 | jun_9th_2023_ms: int = 1686261600000 322 | 323 | no_data_history = tl.get_price_history( 324 | default_instrument_id, 325 | resolution="1W", 326 | start_timestamp=jan_9th_2023_ms, 327 | end_timestamp=jan_9th_2023_ms, 328 | ) 329 | assert no_data_history.empty 330 | sleep(LONG_BREAK) 331 | 332 | price_history_timestamps = tl.get_price_history( 333 | default_instrument_id, 334 | resolution="1H", 335 | start_timestamp=jun_1st_2023_ms, 336 | end_timestamp=jun_9th_2023_ms, 337 | ) 338 | assert not price_history_timestamps.empty 339 | sleep(LONG_BREAK) 340 | 341 | price_history_1Y = tl.get_price_history( 342 | default_instrument_id, 343 | resolution="1D", 344 | start_timestamp=jan_1st_2020_ms, 345 | end_timestamp=jan_1st_2023_ms, 346 | ) 347 | assert not price_history_1Y.empty 348 | sleep(LONG_BREAK) 349 | 350 | # Wrong order 351 | with pytest.raises(ValueError): 352 | tl.get_price_history( 353 | default_instrument_id, 354 | resolution="1H", 355 | start_timestamp=jan_9th_2023_ms, 356 | end_timestamp=jan_1st_2023_ms, 357 | ) 358 | sleep(LONG_BREAK) 359 | 360 | # Too much data 361 | with pytest.raises(ValueError): 362 | sleep(LONG_BREAK) 363 | tl.get_price_history( 364 | default_instrument_id, 365 | resolution="1m", 366 | start_timestamp=jan_1st_2020_ms, 367 | end_timestamp=jan_1st_2023_ms, 368 | ) 369 | sleep(LONG_BREAK) 370 | 371 | # Too much data / non-existing start and lookback 372 | with pytest.raises(ValueError): 373 | tl.get_price_history( 374 | default_instrument_id, 375 | resolution="1m", 376 | start_timestamp=0, 377 | end_timestamp=jan_1st_2020_ms, 378 | ) 379 | sleep(LONG_BREAK) 380 | 381 | price_history_no_end_timestamp = tl.get_price_history( 382 | default_instrument_id, resolution="1H", start_timestamp=jun_1st_2023_ms 383 | ) 384 | assert not price_history_no_end_timestamp.empty 385 | 386 | 387 | def test_get_all_instruments(): 388 | all_instruments = tl.get_all_instruments() 389 | assert not all_instruments.empty 390 | tl_check_type(all_instruments, pd.DataFrame) 391 | assert len(all_instruments.columns) > 1 392 | assert all_instruments["name"].str.contains("USD").any() 393 | assert all_instruments["name"].str.contains("EURUSD").any() 394 | assert not all_instruments["name"].str.contains("DOES_NOT_EXIST").any() 395 | assert all_instruments[all_instruments["name"] == "EURUSD"]["id"].values[0] == 315 396 | 397 | 398 | def test_instrument_and_session_details(): 399 | with pytest.raises(TypeCheckError): 400 | instrument_details = tl.get_instrument_details(default_instrument_id, locale="BLA") 401 | 402 | instrument_details: InstrumentDetailsType = tl.get_instrument_details(default_instrument_id) 403 | assert instrument_details 404 | tl_check_type(instrument_details, InstrumentDetailsType) 405 | assert instrument_details["name"] == default_symbol_name 406 | 407 | session_id: int = instrument_details["tradeSessionId"] 408 | tl_check_type(session_id, int) 409 | assert session_id 410 | 411 | session_details: SessionDetailsType = tl.get_session_details(session_id) 412 | assert session_details 413 | tl_check_type(session_details, SessionDetailsType) 414 | 415 | # validate that ValueError is raised when session_id is not an int 416 | with pytest.raises(TypeCheckError): 417 | error_session_details = tl.get_session_details("STRING_NOT_INT") 418 | assert error_session_details == None 419 | 420 | session_status_id = instrument_details["tradeSessionStatusId"] 421 | assert session_status_id 422 | 423 | session_status_details = tl.get_session_status_details(session_status_id) 424 | assert session_status_details 425 | tl_check_type(session_status_details, dict) 426 | assert len(session_status_details["allowedOperations"]) == 3 427 | assert len(session_status_details["allowedOrderTypes"]) == 6 428 | 429 | 430 | def test_get_market_depth(): 431 | market_depth = tl.get_market_depth(default_instrument_id) 432 | assert market_depth 433 | tl_check_type(market_depth, dict) 434 | assert "asks" in market_depth 435 | assert "bids" in market_depth 436 | tl_check_type(market_depth["asks"], list) 437 | tl_check_type(market_depth["bids"], list) 438 | 439 | 440 | def test_get_daily_bar(): 441 | daily_bar = tl.get_daily_bar(default_instrument_id) 442 | assert daily_bar 443 | tl_check_type(daily_bar, dict) 444 | assert "o" in daily_bar 445 | assert "h" in daily_bar 446 | assert "l" in daily_bar 447 | assert "c" in daily_bar 448 | assert "v" in daily_bar 449 | tl_check_type(daily_bar["o"], float) 450 | 451 | with pytest.raises(TypeCheckError): 452 | tl.get_daily_bar(default_instrument_id, bar_type="NOT_VALID_BAR_TYPE") 453 | 454 | 455 | def test_instrument_id_from_symbol_name(): 456 | btcusd_instrument_id = tl.get_instrument_id_from_symbol_name("BTCUSD") 457 | assert btcusd_instrument_id == 206 458 | 459 | 460 | def get_order_status(order_id: int) -> str: 461 | return tl.get_order_details(order_id)["status"] 462 | 463 | 464 | def _columns_set(columns_list: list[dict[Literal["id"], str]]) -> set[str]: 465 | return set([column["id"] for column in columns_list]) 466 | 467 | 468 | def test_get_config(): 469 | config = tl.get_config() 470 | assert config 471 | tl_check_type(config, dict) 472 | 473 | # check that config.keys() equals to ['customerAccess', 'positionsConfig', 'ordersConfig', 'ordersHistoryConfig', 'filledOrdersConfig', 'accountDetailsConfig', 'rateLimits', 'limits'] 474 | expected_config_keys = [ 475 | "customerAccess", 476 | "positionsConfig", 477 | "ordersConfig", 478 | "ordersHistoryConfig", 479 | "filledOrdersConfig", 480 | "accountDetailsConfig", 481 | "rateLimits", 482 | "limits", 483 | ] 484 | assert list(config.keys()) == expected_config_keys 485 | 486 | assert _columns_set(config["positionsConfig"]["columns"]) == set(PositionsColumns.keys()) 487 | 488 | assert _columns_set(config["ordersConfig"]["columns"]) == set(OrdersColumns.keys()) 489 | 490 | assert _columns_set(config["ordersHistoryConfig"]["columns"]) == set(OrdersColumns.keys()) 491 | 492 | assert _columns_set(config["filledOrdersConfig"]["columns"]) == set(ExecutionsColumns.keys()) 493 | 494 | assert _columns_set(config["accountDetailsConfig"]["columns"]) == set( 495 | AccountDetailsColumns.keys() 496 | ) 497 | 498 | 499 | def test_rate_limits_config(): 500 | route_names = RouteNamesType.__args__ 501 | 502 | for route_name in route_names: 503 | rate_limit_dict = tl.get_route_rate_limit(route_name) 504 | assert rate_limit_dict 505 | tl_check_type(rate_limit_dict, RateLimitType) 506 | 507 | assert "rateLimitType" in rate_limit_dict 508 | assert rate_limit_dict["rateLimitType"] == route_name 509 | assert "measure" in rate_limit_dict 510 | tl_check_type(rate_limit_dict["measure"], RateLimitMeasureTypes) 511 | assert "intervalNum" in rate_limit_dict 512 | assert "limit" in rate_limit_dict 513 | 514 | 515 | def test_orders_history_with_limit_order(ensure_order_fill: bool = False): 516 | # What am I expecting the final order status to be? 517 | expected_order_status: str = "Cancelled" if not ensure_order_fill else "Filled" 518 | # Decide whether I am trying to buy or sell for severely under-market price 519 | order_side: str = ( 520 | "sell" if ensure_order_fill else "buy" 521 | ) # I am using a super-low price, so using "sell" will always fill 522 | 523 | # oh_X -> orders history --> all final orders 524 | # o_X -> current orders --> all non-final and final orders in this session 525 | start_timestamp = get_hour_ago_timestamp() 526 | oh_initial: pd.DataFrame = tl.get_all_orders(history=True, start_timestamp=start_timestamp) 527 | o_initial: pd.DataFrame = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 528 | 529 | with pytest.raises(ValueError): 530 | order_id: int = tl.create_order( 531 | default_instrument_id, 532 | quantity=0.01, 533 | side=order_side, 534 | price=0.01, 535 | type_="limit", 536 | validity="IOC", 537 | ) 538 | 539 | order_id: int = tl.create_order( 540 | default_instrument_id, 541 | quantity=0.01, 542 | side=order_side, 543 | price=0.01, 544 | type_="limit", 545 | validity="GTC", 546 | ) 547 | 548 | sleep(SHORT_BREAK) 549 | 550 | # Let's wait for a max of 10 seconds for the order to be filled 551 | max_wait_seconds: int = 10 552 | sleep_delay: int = 2 553 | if ensure_order_fill: 554 | for i in range(0, max_wait_seconds, sleep_delay): 555 | oh_after_order: pd.DataFrame = tl.get_all_orders( 556 | history=True, start_timestamp=start_timestamp 557 | ) 558 | if ( 559 | order_id in oh_after_order["id"].values 560 | and oh_after_order[oh_after_order["id"] == order_id]["status"].values[0] == "Filled" 561 | ): 562 | break 563 | else: 564 | sleep(sleep_delay) 565 | 566 | if i + sleep_delay >= max_wait_seconds: 567 | break 568 | 569 | oh_after_order: pd.DataFrame = tl.get_all_orders(history=True, start_timestamp=start_timestamp) 570 | o_after_order: pd.DataFrame = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 571 | 572 | # Go over each element in oh_initial and check if they are inside oh_after_order 573 | is_in_oh_after = oh_initial["id"].isin(oh_after_order["id"]) 574 | assert is_in_oh_after.all() 575 | 576 | assert not oh_after_order.empty 577 | if ensure_order_fill: 578 | assert len(o_after_order) == len(o_initial) 579 | else: 580 | assert not o_after_order.empty 581 | 582 | tl_check_type(oh_after_order, pd.DataFrame) 583 | tl_check_type(o_after_order, pd.DataFrame) 584 | 585 | # If the order was filled, it shows up in history 586 | assert len(oh_after_order) == len(oh_initial) + (1 if ensure_order_fill else 0) 587 | 588 | # If the order was filled, it does not show up in /orders 589 | assert len(o_after_order) == len(o_initial) + (0 if ensure_order_fill else 1) 590 | 591 | delete_success = tl.delete_order(order_id) 592 | 593 | sleep(SHORT_BREAK) 594 | 595 | if not ensure_order_fill: 596 | assert delete_success 597 | else: 598 | assert not delete_success 599 | 600 | oh_after_delete = tl.get_all_orders(history=True, start_timestamp=start_timestamp) 601 | o_after_delete = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 602 | 603 | # Check that an order is always visible in order history, regardless whether 604 | # it deleted or previously filled 605 | assert len(oh_after_delete) == len(oh_initial) + 1 606 | 607 | # Assert that the order is not visible in current orders 608 | assert len(o_after_delete) == len(o_initial) 609 | 610 | # ----------Ensure that the order is visible in ordersHistory, but not orders---------- 611 | 612 | assert order_id in oh_after_delete["id"].values 613 | # check the status of the deleted order in order history 614 | assert ( 615 | oh_after_delete[oh_after_delete["id"] == order_id]["status"].values[0] 616 | == expected_order_status 617 | ) 618 | 619 | assert order_id not in o_after_delete["id"].values 620 | # check the order status of the deleted order 621 | 622 | 623 | def test_order_quantities(): 624 | qts_to_test = { 625 | 0.001: True, 626 | 0.0001: True, 627 | 0.13: False, 628 | 0.12: False, 629 | 0.123: True, 630 | 0.129: True, 631 | -0.05: True, 632 | } 633 | for qty, should_fail in qts_to_test.items(): 634 | order_id: Optional[int] = None 635 | try: 636 | order_id = tl.create_order( 637 | default_instrument_id, 638 | quantity=qty, 639 | side="buy", 640 | price=0.01, 641 | type_="limit", 642 | validity="GTC", 643 | ) 644 | except TLAPIOrderException: 645 | order_id = None 646 | 647 | if should_fail: 648 | assert order_id == None 649 | else: 650 | assert order_id 651 | 652 | sleep(SHORT_BREAK) 653 | 654 | 655 | def test_orders_history_with_filled_limit_order(): 656 | test_orders_history_with_limit_order(ensure_order_fill=True) 657 | 658 | 659 | def test_get_trade_accounts(): 660 | trade_accounts = tl.get_trade_accounts() 661 | # check that trade accounts is a dataframe 662 | tl_check_type(trade_accounts, list) 663 | assert len(trade_accounts) > 0 664 | tl_check_type(trade_accounts[0], dict) 665 | assert trade_accounts[0]["id"] 666 | tl_check_type(trade_accounts[0]["tradingRules"], dict) 667 | 668 | 669 | def test_orders(): 670 | ###### Printing order history (len) 671 | all_orders = tl.get_all_orders(history=False) 672 | tl_check_type(all_orders, pd.DataFrame) 673 | assert len(all_orders.columns) > 1 674 | 675 | 676 | def test_get_account_state(): 677 | account_state = tl.get_account_state() 678 | assert len(account_state) > 0 679 | # check that this is a dataframe 680 | tl_check_type(account_state, dict) 681 | assert account_state["balance"] > 0 682 | assert account_state["availableFunds"] > 0 683 | 684 | fields = [ 685 | "balance", 686 | "projectedBalance", 687 | "availableFunds", 688 | "blockedBalance", 689 | "cashBalance", 690 | "unsettledCash", 691 | "withdrawalAvailable", 692 | "stocksValue", 693 | "optionValue", 694 | "initialMarginReq", 695 | "maintMarginReq", 696 | "marginWarningLevel", 697 | "blockedForStocks", 698 | "stockOrdersReq", 699 | "stopOutLevel", 700 | "warningMarginReq", 701 | "marginBeforeWarning", 702 | "todayGross", 703 | "todayNet", 704 | "todayFees", 705 | "todayVolume", 706 | "todayTradesCount", 707 | "openGrossPnL", 708 | "openNetPnL", 709 | "positionsCount", 710 | "ordersCount", 711 | ] 712 | 713 | # Check if all fields are in account_info columns 714 | assert all(field in account_state for field in fields) 715 | 716 | 717 | def test_create_and_close_position(): 718 | ###### Getting all positions 719 | positions = tl.get_all_positions() 720 | len_positions_initial = len(positions) 721 | assert len_positions_initial >= 0 722 | 723 | ##### Creating and placing an order 724 | tl_check_type(default_instrument_id, int) 725 | order_id = tl.create_order( 726 | default_instrument_id, quantity=0.01, side="buy", price=0, type_="market" 727 | ) 728 | assert order_id 729 | 730 | start_timestamp = get_hour_ago_timestamp() 731 | all_orders_history = tl.get_all_orders(history=True, start_timestamp=start_timestamp) 732 | assert not all_orders_history.empty 733 | 734 | len_positions_after_order = len(tl.get_all_positions()) 735 | assert len_positions_after_order == len_positions_initial + 1 736 | 737 | tl.close_position(order_id) 738 | len_positions_after_close = len(tl.get_all_positions()) 739 | 740 | assert len_positions_after_close == len_positions_initial 741 | assert len_positions_after_close == len_positions_after_order - 1 742 | 743 | 744 | def test_close_position_partial(): 745 | ###### Getting all positions 746 | positions = tl.get_all_positions() 747 | len_positions_initial = len(positions) 748 | assert len_positions_initial >= 0 749 | 750 | ##### Creating and placing an order 751 | tl_check_type(default_instrument_id, int) 752 | order_id = tl.create_order( 753 | default_instrument_id, quantity=0.02, side="buy", price=0, type_="market" 754 | ) 755 | assert order_id 756 | 757 | start_timestamp = get_hour_ago_timestamp() 758 | all_orders_history = tl.get_all_orders(history=True, start_timestamp=start_timestamp) 759 | assert not all_orders_history.empty 760 | 761 | len_positions_after_order = len(tl.get_all_positions()) 762 | assert len_positions_after_order == len_positions_initial + 1 763 | 764 | tl.close_position(order_id=order_id, close_quantity=0.01) 765 | positions_after_close = tl.get_all_positions() 766 | len_positions_after_close = len(positions_after_close) 767 | 768 | assert len_positions_after_close == len_positions_initial + 1 769 | assert len_positions_after_close == len_positions_after_order 770 | 771 | # get the position from the order_id 772 | position_id = position_id_from_order_id(order_id) 773 | assert position_id in positions_after_close["id"].values 774 | assert ( 775 | positions_after_close[positions_after_close["id"] == position_id]["qty"].values[0] == 0.01 776 | ) 777 | 778 | tl.close_position(position_id=position_id, close_quantity=0.01) 779 | positions_final = tl.get_all_positions() 780 | assert len(positions_final) == len_positions_initial 781 | 782 | 783 | def test_position_netting(): 784 | # Test that position_netting = False yields in two positions 785 | tl.close_all_positions() 786 | order1_id = tl.create_order( 787 | default_instrument_id, quantity=0.01, side="buy", price=0, type_="market" 788 | ) 789 | sleep(SHORT_BREAK) 790 | order2_id = tl.create_order( 791 | default_instrument_id, quantity=0.03, side="sell", price=0, type_="market" 792 | ) 793 | sleep(SHORT_BREAK) 794 | all_positions = tl.get_all_positions() 795 | # Expected: 0.01 buy ; 0.01 sell 796 | assert len(all_positions) == 2 797 | 798 | # Create another position, which should fully cancel the position created in the first order 799 | order3_id = tl.create_order( 800 | default_instrument_id, 801 | quantity=0.01, 802 | side="sell", 803 | price=0, 804 | type_="market", 805 | position_netting=True, 806 | ) 807 | sleep(SHORT_BREAK) 808 | # Expected: 0.01 sell (buy was closed due to netting) 809 | all_positions_netting = tl.get_all_positions() 810 | assert len(all_positions_netting) == 1 811 | 812 | # Create another "sell" position, then create a position that should close this positions, as well as partially close the order_2 position 813 | order4_id = tl.create_order( 814 | default_instrument_id, 815 | quantity=0.01, 816 | side="sell", 817 | price=0, 818 | type_="market", 819 | position_netting=True, 820 | ) 821 | sleep(SHORT_BREAK) 822 | order5_id = tl.create_order( 823 | default_instrument_id, 824 | quantity=0.02, 825 | side="buy", 826 | price=0, 827 | type_="market", 828 | position_netting=True, 829 | ) 830 | sleep(SHORT_BREAK) 831 | 832 | all_positions_netting_partial = tl.get_all_positions() 833 | # Expected: the 0.02 buy actually cancelled the order_4 sell, and reduced the order2's sell side to 0.02 834 | assert len(all_positions_netting_partial) == 1 835 | assert all_positions_netting_partial["qty"].iloc[0] == 0.02 836 | 837 | tl.create_order( 838 | default_instrument_id, 839 | quantity=0.02, 840 | side="buy", 841 | price=0, 842 | type_="market", 843 | position_netting=True, 844 | ) 845 | all_positions_netting_full = tl.get_all_positions() 846 | 847 | # Expected: the 0.02 buy actually cancelled the order_2's remaining sell (which was 0.02), so now there should be no open positions 848 | assert len(all_positions_netting_full) == 0 849 | 850 | 851 | def position_id_from_order_id(order_id: int, all_orders: Optional[pd.DataFrame] = None) -> int: 852 | if all_orders is None: 853 | all_orders = tl.get_all_orders(history=True) 854 | 855 | matching_orders = all_orders[all_orders["id"] == order_id] 856 | if len(matching_orders) == 0: 857 | raise ValueError(f"No order found with order_id = {order_id}") 858 | return int(matching_orders["positionId"].iloc[0]) 859 | 860 | 861 | def test_close_position_by_position_id(): 862 | all_positions = tl.get_all_positions() 863 | order_id1 = tl.create_order( 864 | default_instrument_id, quantity=0.01, side="buy", price=0, type_="market" 865 | ) 866 | all_positions_after_order = tl.get_all_positions() 867 | 868 | assert len(all_positions_after_order) == len(all_positions) + 1 869 | 870 | position_id1 = position_id_from_order_id(order_id1) 871 | tl.close_position(position_id=position_id1) 872 | all_positions_after_close = tl.get_all_positions() 873 | 874 | assert len(all_positions_after_close) == len(all_positions) 875 | 876 | 877 | def test_close_all_positions(): 878 | all_positions_initial = tl.get_all_positions() 879 | tl.close_all_positions() 880 | all_positions_after_close = tl.get_all_positions() 881 | assert len(all_positions_after_close) == 0 882 | 883 | # Create two market orders/positions 884 | order_id1 = tl.create_order( 885 | default_instrument_id, quantity=0.01, side="buy", price=0, type_="market" 886 | ) 887 | sleep(SHORT_BREAK) 888 | order_id2 = tl.create_order( 889 | default_instrument_id, quantity=0.02, side="sell", price=0, type_="market" 890 | ) 891 | sleep(SHORT_BREAK) 892 | instrument_id3 = tl.get_instrument_id_from_symbol_name("ETHUSD") 893 | order_id3 = tl.create_order(instrument_id3, quantity=0.01, side="sell", price=0, type_="market") 894 | 895 | # Check that the positions were received 896 | assert order_id1 897 | assert order_id2 898 | assert order_id3 899 | 900 | # Crude way of waiting for the orders to be filled 901 | for _ in range(5): 902 | try: 903 | position_id1 = position_id_from_order_id(order_id1) 904 | sleep(SHORT_BREAK) 905 | position_id2 = position_id_from_order_id(order_id2) 906 | sleep(SHORT_BREAK) 907 | position_id3 = position_id_from_order_id(order_id3) 908 | sleep(SHORT_BREAK) 909 | break 910 | except ValueError: 911 | sleep(LONG_BREAK) 912 | 913 | orders_history = tl.get_all_orders(history=True) 914 | assert order_id1 in orders_history["id"].values 915 | assert order_id2 in orders_history["id"].values 916 | assert order_id3 in orders_history["id"].values 917 | 918 | assert position_id1, "Position not created after 10 seconds!" 919 | assert position_id2, "Position not created after 10 seconds!" 920 | assert position_id3, "Position not created after 10 seconds!" 921 | 922 | # Check that the orders were filled and became positions 923 | all_positions = tl.get_all_positions() 924 | assert position_id1 in all_positions["id"].values 925 | assert position_id2 in all_positions["id"].values 926 | assert position_id3 in all_positions["id"].values 927 | 928 | tl.close_all_positions(instrument_id_filter=instrument_id3) 929 | sleep(LONG_BREAK) 930 | 931 | # Check that only position_id3 was closed 932 | all_positions = tl.get_all_positions() 933 | assert position_id1 in all_positions["id"].values 934 | assert position_id2 in all_positions["id"].values 935 | assert position_id3 not in all_positions["id"].values 936 | 937 | tl.close_all_positions() 938 | sleep(LONG_BREAK) 939 | 940 | # Check that the remaining positions were closed 941 | all_positions_after_close = tl.get_all_positions() 942 | assert position_id1 not in all_positions_after_close["id"].values 943 | assert position_id2 not in all_positions_after_close["id"].values 944 | assert position_id3 not in all_positions_after_close["id"].values 945 | 946 | 947 | def test_modify_and_delete_order(): 948 | start_timestamp = get_hour_ago_timestamp() 949 | orders_before = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 950 | 951 | # create a limit order 952 | order_id: int = tl.create_order( 953 | default_instrument_id, 954 | quantity=0.01, 955 | side="buy", 956 | price=0.01, 957 | type_="limit", 958 | validity="GTC", 959 | ) 960 | assert order_id 961 | tl_check_type(order_id, int) 962 | sleep(SHORT_BREAK) 963 | 964 | orders_after_buy = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 965 | assert len(orders_after_buy) == len(orders_before) + 1 966 | assert order_id in orders_after_buy["id"].values 967 | last_modified_buy = orders_after_buy[orders_after_buy["id"] == order_id]["lastModified"].values[ 968 | 0 969 | ] 970 | 971 | all_orders_history = tl.get_all_orders(history=True, start_timestamp=start_timestamp) 972 | assert not all_orders_history.empty 973 | 974 | # modify the limit order 975 | tl.modify_order(order_id, modification_params={"price": "0.02", "qty": "0.02"}) 976 | 977 | orders_after_modify = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 978 | assert order_id in orders_after_modify["id"].values 979 | assert len(orders_after_modify) == len(orders_after_buy) 980 | last_modified_modify = orders_after_modify[orders_after_modify["id"] == order_id][ 981 | "lastModified" 982 | ].values[0] 983 | 984 | assert last_modified_modify > last_modified_buy 985 | 986 | tl.delete_order(order_id) 987 | sleep(SHORT_BREAK) 988 | 989 | orders_after_delete = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 990 | assert len(orders_after_delete) == len(orders_before) 991 | assert order_id not in orders_after_delete["id"].values 992 | 993 | oh_after_delete = tl.get_all_orders(history=True, start_timestamp=start_timestamp) 994 | # check the order status of the deleted order 995 | assert oh_after_delete[oh_after_delete["id"] == order_id]["status"].values[0] == "Cancelled" 996 | 997 | 998 | def test_modify_position(): 999 | # Create a position 1000 | order_id = tl.create_order( 1001 | default_instrument_id, quantity=0.01, side="buy", price=0, type_="market" 1002 | ) 1003 | sleep(SHORT_BREAK) 1004 | position_id = position_id_from_order_id(order_id) 1005 | assert position_id 1006 | 1007 | SL_VAL = 0.01 1008 | TP_VAL = 10000000 1009 | # Modify the position 1010 | tl.modify_position(position_id, modification_params={"stopLoss": SL_VAL, "takeProfit": TP_VAL}) 1011 | 1012 | sleep(SHORT_BREAK) 1013 | 1014 | all_positions = tl.get_all_positions() 1015 | assert position_id in all_positions["id"].values 1016 | stop_loss_id = all_positions[all_positions["id"] == position_id]["stopLossId"].values[0] 1017 | take_profit_id = all_positions[all_positions["id"] == position_id]["takeProfitId"].values[0] 1018 | 1019 | assert stop_loss_id 1020 | assert take_profit_id 1021 | assert stop_loss_id != take_profit_id 1022 | 1023 | # Check that the stop loss and take profit values are correct 1024 | all_orders = tl.get_all_orders(history=False) 1025 | assert stop_loss_id in all_orders["id"].values 1026 | assert take_profit_id in all_orders["id"].values 1027 | 1028 | stop_loss_order = all_orders[all_orders["id"] == stop_loss_id] 1029 | take_profit_order = all_orders[all_orders["id"] == take_profit_id] 1030 | 1031 | assert stop_loss_order["stopPrice"].values[0] == SL_VAL 1032 | assert take_profit_order["price"].values[0] == TP_VAL 1033 | 1034 | tl.modify_position( 1035 | position_id, 1036 | modification_params={"stopLoss": SL_VAL * 2, "takeProfit": TP_VAL / 2}, 1037 | ) 1038 | 1039 | sleep(SHORT_BREAK) 1040 | 1041 | all_orders_after_modify = tl.get_all_orders(history=False) 1042 | stop_loss_order_after_modify = all_orders_after_modify[ 1043 | all_orders_after_modify["id"] == stop_loss_id 1044 | ] 1045 | take_profit_order_after_modify = all_orders_after_modify[ 1046 | all_orders_after_modify["id"] == take_profit_id 1047 | ] 1048 | 1049 | assert stop_loss_order_after_modify["stopPrice"].values[0] == SL_VAL * 2 1050 | assert take_profit_order_after_modify["price"].values[0] == TP_VAL / 2 1051 | 1052 | # Close the position 1053 | tl.close_position(position_id=position_id) 1054 | sleep(SHORT_BREAK) 1055 | 1056 | all_positions_after_close = tl.get_all_positions() 1057 | assert position_id not in all_positions_after_close["id"].values 1058 | 1059 | 1060 | def test_order_with_take_profit_and_stop_loss(): 1061 | SL_VAL = 0.01 1062 | TP_VAL = 10000000 1063 | 1064 | # place an order 1065 | order_id = tl.create_order( 1066 | default_instrument_id, 1067 | quantity=0.01, 1068 | side="buy", 1069 | price=0, 1070 | type_="market", 1071 | stop_loss=SL_VAL, 1072 | stop_loss_type="absolute", 1073 | take_profit=TP_VAL, 1074 | take_profit_type="absolute", 1075 | ) 1076 | sleep(SHORT_BREAK) 1077 | 1078 | all_orders = tl.get_all_orders(history=True) 1079 | order = all_orders[all_orders["id"] == order_id].iloc[0] 1080 | 1081 | assert order["stopLoss"] == SL_VAL 1082 | assert order["takeProfit"] == TP_VAL 1083 | 1084 | 1085 | def test_orders_history_time_ranges_and_instrument_filter(): 1086 | six_hours_ago_timestamp = int((datetime.now() - timedelta(hours=6)).timestamp() * 1000) 1087 | two_hours_ago_timestamp = int((datetime.now() - timedelta(hours=2)).timestamp() * 1000) 1088 | oh_last_6_hours = tl.get_all_orders(history=True, start_timestamp=six_hours_ago_timestamp) 1089 | oh_last_2_hours = tl.get_all_orders(history=True, start_timestamp=two_hours_ago_timestamp) 1090 | 1091 | if len(oh_last_6_hours) >= MAX_ORDER_HISTORY: 1092 | pytest.skip( 1093 | "Too many orders in the last 6 hours, skipping " 1094 | f"because of get_all_orders returned {len(oh_last_6_hours)} orders" 1095 | ) 1096 | 1097 | assert len(oh_last_6_hours) >= len(oh_last_2_hours) 1098 | 1099 | LTCUSD_instrument_id = tl.get_instrument_id_from_symbol_name("LTCUSD") 1100 | 1101 | # use last one hour as the start time 1102 | start_timestamp = get_hour_ago_timestamp() 1103 | oh_last_1_hour_LTCUSD_before = tl.get_all_orders( 1104 | history=True, start_timestamp=start_timestamp, instrument_id_filter=LTCUSD_instrument_id 1105 | ) 1106 | 1107 | order_id = tl.create_order( 1108 | instrument_id=LTCUSD_instrument_id, quantity=0.01, side="buy", type_="market" 1109 | ) 1110 | sleep(MID_BREAK) 1111 | 1112 | oh_last_1_hour_LTCUSD = tl.get_all_orders( 1113 | history=True, start_timestamp=start_timestamp, instrument_id_filter=LTCUSD_instrument_id 1114 | ) 1115 | assert len(oh_last_1_hour_LTCUSD) == len(oh_last_1_hour_LTCUSD_before) + 1 1116 | 1117 | tl.close_position(order_id=order_id) 1118 | sleep(MID_BREAK) 1119 | 1120 | oh_last_1_hour_LTCUSD_after = tl.get_all_orders( 1121 | history=True, start_timestamp=start_timestamp, instrument_id_filter=LTCUSD_instrument_id 1122 | ) 1123 | assert len(oh_last_1_hour_LTCUSD_after) == len(oh_last_1_hour_LTCUSD_before) + 2 1124 | 1125 | oh_last_6_hours_after = tl.get_all_orders(history=True, start_timestamp=six_hours_ago_timestamp) 1126 | assert len(oh_last_6_hours_after) == len(oh_last_6_hours) + 2 1127 | 1128 | 1129 | def test_delete_all_orders(): 1130 | tl.delete_all_orders() 1131 | sleep(SHORT_BREAK) 1132 | 1133 | start_timestamp = get_hour_ago_timestamp() 1134 | orders_before = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 1135 | orders_history_before = tl.get_all_orders(history=True, start_timestamp=start_timestamp) 1136 | 1137 | # create a limit order 1138 | order_id1: int = tl.create_order( 1139 | default_instrument_id, 1140 | quantity=0.01, 1141 | side="buy", 1142 | price=0.01, 1143 | type_="limit", 1144 | validity="GTC", 1145 | ) 1146 | sleep(SHORT_BREAK) 1147 | order_id2: int = tl.create_order( 1148 | default_instrument_id, 1149 | quantity=0.01, 1150 | side="sell", 1151 | price=1000000.0, 1152 | type_="limit", 1153 | validity="GTC", 1154 | ) 1155 | sleep(SHORT_BREAK) 1156 | instrument_id3 = tl.get_instrument_id_from_symbol_name("ETHUSD") 1157 | order_id3: int = tl.create_order( 1158 | instrument_id3, 1159 | quantity=0.01, 1160 | side="buy", 1161 | price=0.01, 1162 | type_="limit", 1163 | validity="GTC", 1164 | ) 1165 | sleep(SHORT_BREAK) 1166 | instrument_id4 = tl.get_instrument_id_from_symbol_name("DOGEUSD") 1167 | order_id4: int = tl.create_order( 1168 | instrument_id4, 1169 | quantity=0.01, 1170 | side="buy", 1171 | price=0.02, 1172 | type_="limit", 1173 | validity="GTC", 1174 | ) 1175 | sleep(SHORT_BREAK) 1176 | 1177 | assert order_id1 1178 | assert order_id2 1179 | assert order_id3 1180 | assert order_id4 1181 | 1182 | orders_after_buy = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 1183 | assert len(orders_after_buy) == len(orders_before) + 4 1184 | 1185 | tl.delete_all_orders(instrument_id_filter=instrument_id3) 1186 | sleep(SHORT_BREAK) 1187 | 1188 | orders_history_after = tl.get_all_orders(history=True, start_timestamp=start_timestamp) 1189 | orders_after = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 1190 | 1191 | # Only one order has become final ("Cancelled") and will thus be "added" to ordersHistory) 1192 | assert len(orders_history_after) == len(orders_history_before) + 1 1193 | 1194 | # The one order that was deleted should not be on the orders list anymore 1195 | assert len(orders_after) == len(orders_before) + 3 1196 | assert orders_after[orders_after["id"] == order_id1]["status"].values[0] == "New" 1197 | 1198 | assert orders_after[orders_after["id"] == order_id2]["status"].values[0] == "New" 1199 | 1200 | # Check order status for order3 to be "Cancelled" 1201 | assert order_id3 not in orders_after["id"].values 1202 | 1203 | assert orders_after[orders_after["id"] == order_id4]["status"].values[0] == "New" 1204 | 1205 | tl.delete_all_orders() 1206 | 1207 | orders_final = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 1208 | sleep(SHORT_BREAK) 1209 | oh_final = tl.get_all_orders(history=True, start_timestamp=start_timestamp) 1210 | 1211 | # Check that all order statuses are "Cancelled" 1212 | assert oh_final[oh_final["id"] == order_id1]["status"].values[0] == "Cancelled" 1213 | assert oh_final[oh_final["id"] == order_id2]["status"].values[0] == "Cancelled" 1214 | assert oh_final[oh_final["id"] == order_id4]["status"].values[0] == "Cancelled" 1215 | 1216 | assert len(orders_final) == len(orders_before) 1217 | assert len(oh_final) == len(orders_history_before) + 4 1218 | 1219 | 1220 | def random_string(length: int) -> str: 1221 | # randomly generate a strategy_id with length 5 * _MAX_STRATEGY_ID_LEN 1222 | return "".join(random.choices(string.ascii_lowercase, k=length)) 1223 | 1224 | 1225 | def test_ok_strategy_id(): 1226 | run_strategy_id_test(random_string(tl._MAX_STRATEGY_ID_LEN - 5)) 1227 | 1228 | 1229 | def test_exact_len_strategy_id(): 1230 | run_strategy_id_test(random_string(tl._MAX_STRATEGY_ID_LEN)) 1231 | 1232 | 1233 | def test_plus_one_len_strategy_id(): 1234 | # This is expected to fail 1235 | with pytest.raises(TLAPIException): 1236 | run_strategy_id_test(random_string(tl._MAX_STRATEGY_ID_LEN + 1)) 1237 | 1238 | 1239 | def test_super_long_strategy_id(): 1240 | # This is expected to fail 1241 | with pytest.raises(TLAPIException): 1242 | run_strategy_id_test(random_string(5 * tl._MAX_STRATEGY_ID_LEN)) 1243 | 1244 | 1245 | def run_strategy_id_test(strategy_id: str): 1246 | """Disable strategy_id length checks, create a limit and market order, check that strategyId shows in /orders /ordersHistory and /positions 1247 | 1) create a market order, check that strategy id works /ordersHistory and /positions 1248 | 2) create a limit order, chech that /orders workswell with a too-long strategy_id 1249 | """ 1250 | 1251 | # 1) market order 1252 | long_market_order_id = tl.create_order( 1253 | default_instrument_id, 1254 | quantity=0.01, 1255 | side="buy", 1256 | type_="market", 1257 | strategy_id=strategy_id, 1258 | _ignore_len_check=True, 1259 | ) 1260 | sleep(SHORT_BREAK) 1261 | 1262 | start_timestamp = get_hour_ago_timestamp() 1263 | all_market_orders_long = tl.get_all_orders(history=True, start_timestamp=start_timestamp) 1264 | all_positions_long = tl.get_all_positions() 1265 | 1266 | assert strategy_id in all_market_orders_long["strategyId"].values 1267 | 1268 | assert long_market_order_id in all_market_orders_long["id"].values 1269 | assert ( 1270 | all_market_orders_long[all_market_orders_long["id"] == long_market_order_id][ 1271 | "strategyId" 1272 | ].values[0] 1273 | == strategy_id 1274 | ) 1275 | 1276 | position_id = position_id_from_order_id(long_market_order_id, all_market_orders_long) 1277 | 1278 | assert position_id in all_positions_long["id"].values 1279 | assert ( 1280 | all_positions_long[all_positions_long["id"] == position_id]["strategyId"].values[0] 1281 | == strategy_id 1282 | ) 1283 | 1284 | # 2) limit order 1285 | # replace the last 5 characters with LIMIT to make it unique 1286 | limit_strategy_id = strategy_id[:-5] + "LIMIT" 1287 | long_market_order_id = tl.create_order( 1288 | default_instrument_id, 1289 | quantity=0.01, 1290 | price=0.01, 1291 | side="buy", 1292 | type_="limit", 1293 | validity="GTC", 1294 | strategy_id=limit_strategy_id, 1295 | _ignore_len_check=True, 1296 | ) 1297 | sleep(SHORT_BREAK) 1298 | all_orders_long = tl.get_all_orders(history=False, start_timestamp=start_timestamp) 1299 | 1300 | assert long_market_order_id in all_orders_long["id"].values 1301 | 1302 | assert ( 1303 | all_orders_long[all_orders_long["id"] == long_market_order_id]["strategyId"].values[0] 1304 | == limit_strategy_id 1305 | ) 1306 | 1307 | tl.delete_all_orders() 1308 | tl.close_all_positions() 1309 | 1310 | 1311 | if __name__ == "__main__": 1312 | pytest.main([__file__]) 1313 | --------------------------------------------------------------------------------