├── .github └── workflows │ └── pypi-publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── bitpy ├── __init__.py ├── clients │ ├── account.py │ ├── base_client.py │ ├── market.py │ ├── position.py │ └── ws.py ├── exceptions.py ├── models │ ├── account.py │ ├── base.py │ ├── login.py │ ├── market.py │ └── position.py ├── rest_api.py ├── utils │ ├── log_manager.py │ └── request_handler.py └── ws_api.py └── pyproject.toml /.github/workflows/pypi-publish.yml: -------------------------------------------------------------------------------- 1 | name: Python Package Release 2 | on: 3 | push: 4 | tags: 5 | - 'v[0-9]+.[0-9]+.[0-9]+' 6 | - 'v[0-9]+.[0-9]+.[0-9]+a[0-9]+' 7 | - 'v[0-9]+.[0-9]+.[0-9]+b[0-9]+' 8 | - 'v[0-9]+.[0-9]+.[0-9]+rc[0-9]+' 9 | env: 10 | PYPI_PACKAGE_NAME: "bitget-python" 11 | 12 | jobs: 13 | version-check: 14 | runs-on: ubuntu-latest 15 | outputs: 16 | new_version: ${{ steps.extract_version.outputs.version }} 17 | steps: 18 | - name: Extract version from tag 19 | id: extract_version 20 | run: | 21 | TAG_NAME=${GITHUB_REF#refs/tags/v} 22 | echo "version=$TAG_NAME" >> $GITHUB_OUTPUT 23 | echo "Version is $TAG_NAME" 24 | - name: Check PyPI version 25 | run: | 26 | response=$(curl -s https://pypi.org/pypi/${{ env.PYPI_PACKAGE_NAME }}/json || echo "{}") 27 | latest_version=$(echo $response | jq --raw-output "select(.releases != null) | .releases | keys_unsorted | last") 28 | if [ -z "$latest_version" ]; then 29 | echo "Package not found on PyPI. Proceeding with first release." 30 | exit 0 31 | fi 32 | echo "Latest version on PyPI: $latest_version" 33 | NEW_VERSION=${GITHUB_REF#refs/tags/v} 34 | if [ "$(printf '%s\n' "$latest_version" "$NEW_VERSION" | sort -rV | head -n 1)" != "$NEW_VERSION" ]; then 35 | echo "Error: New version $NEW_VERSION is not greater than latest version $latest_version" 36 | exit 1 37 | fi 38 | 39 | release-build: 40 | needs: version-check 41 | runs-on: ubuntu-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - name: Set up Python 45 | uses: actions/setup-python@v5 46 | with: 47 | python-version: "3.11" 48 | cache: 'pip' 49 | - name: Install build dependencies 50 | run: | 51 | python -m pip install --upgrade pip 52 | pip install build wheel twine 53 | - name: Build package 54 | run: python -m build 55 | - name: Check distributions 56 | run: | 57 | python -m twine check dist/* 58 | - name: Upload distributions 59 | uses: actions/upload-artifact@v4 60 | with: 61 | name: release-dists 62 | path: dist/ 63 | 64 | pypi-publish: 65 | needs: [version-check, release-build] 66 | runs-on: ubuntu-latest 67 | environment: 68 | name: pypi 69 | url: https://pypi.org/p/${{ env.PYPI_PACKAGE_NAME }} 70 | 71 | permissions: 72 | contents: write 73 | 74 | steps: 75 | - name: Download release distributions 76 | uses: actions/download-artifact@v4 77 | with: 78 | name: release-dists 79 | path: dist/ 80 | 81 | - name: Set up Python 82 | uses: actions/setup-python@v5 83 | with: 84 | python-version: "3.11" 85 | 86 | - name: Install twine 87 | run: | 88 | python -m pip install --upgrade pip 89 | pip install twine 90 | 91 | - name: Publish to PyPI 92 | env: 93 | TWINE_USERNAME: __token__ 94 | TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} 95 | run: | 96 | python -m twine upload dist/* 97 | 98 | - name: Create Release 99 | uses: softprops/action-gh-release@v1 100 | with: 101 | files: dist/* 102 | generate_release_notes: true 103 | name: Release ${{ github.ref_name }} 104 | draft: false 105 | prerelease: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | .idea/ 169 | 170 | # PyPI configuration file 171 | .pypirc 172 | 173 | test*.py -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Adam Veldhousen 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 Bitget API V2 Python Client 2 | 3 | A comprehensive Python client for the Bitget API V2, providing extensive functionality for futures trading, account management, and market data access. 4 | 5 | ## ✨ Features 6 | 7 | - 📊 Complete futures market trading capabilities 8 | - 💼 Account management and settings 9 | - 📈 Real-time and historical market data 10 | - 🔄 Automatic rate limiting and request handling 11 | - 🛡️ Comprehensive error handling and validation 12 | - 📝 Detailed debug logging capabilities 13 | - 🎯 Type hints and dataclass models for better code completion 14 | 15 | ## 🛠️ Installation 16 | 17 | ```bash 18 | # Install using PYPI 19 | pip install bitget-python 20 | ``` 21 | 22 | ## 🔧 Quick Start 23 | 24 | ```python 25 | from bitpy import BitgetAPI 26 | 27 | # For market data only - no API keys needed 28 | client = BitgetAPI( 29 | api_key=None, 30 | secret_key=None, 31 | api_passphrase=None 32 | ) 33 | 34 | # Get market data without authentication 35 | ticker = client.market.get_ticker( 36 | symbol="BTCUSDT", 37 | product_type="USDT-FUTURES" 38 | ) 39 | 40 | # Get candlestick data 41 | candles = client.market.get_candlestick( 42 | symbol="BTCUSDT", 43 | product_type="USDT-FUTURES", 44 | granularity="1m", 45 | limit=100 46 | ) 47 | ``` 48 | 49 | For account and position operations, API keys are required: 50 | 51 | ```python 52 | # For trading operations - API keys required 53 | client = BitgetAPI( 54 | api_key="your_api_key", 55 | secret_key="your_secret_key", 56 | api_passphrase="your_passphrase", 57 | debug=True 58 | ) 59 | 60 | # Get account information (requires authentication) 61 | account = client.account.get_account( 62 | symbol="BTCUSDT", 63 | product_type="USDT-FUTURES", 64 | margin_coin="USDT" 65 | ) 66 | ``` 67 | 68 | ## 🌐 WebSocket Support (Only Public) 69 | 70 | ```python 71 | from bitpy import BitgetWebsocketAPI 72 | import asyncio 73 | 74 | async def handle_ticker(data: dict): 75 | if "data" in data and len(data["data"]) > 0: 76 | ticker = data["data"][0] 77 | print(f"Symbol: {ticker['instId']}") 78 | print(f"Last Price: {ticker['lastPr']}") 79 | print(f"24h High: {ticker['high24h']}") 80 | print(f"24h Low: {ticker['low24h']}") 81 | print(f"24h Change %: {ticker['change24h']}") 82 | print("-" * 50) 83 | 84 | async def main(): 85 | # Initialize WebSocket client 86 | api = BitgetWebsocketAPI(is_private=False, debug=False) 87 | ws_client = api.websocket 88 | # Subscribe to channels 89 | subscriptions = [ 90 | { 91 | "instType": "SPOT", 92 | "channel": "ticker", 93 | "instId": "BTCUSDT" 94 | } 95 | ] 96 | try: 97 | await ws_client.connect() 98 | print("Connected to WebSocket") 99 | await ws_client.subscribe(subscriptions, handle_ticker) 100 | # Keep connection alive 101 | while ws_client.connected: 102 | await asyncio.sleep(1) 103 | 104 | except KeyboardInterrupt: 105 | await ws_client.close() 106 | 107 | if __name__ == "__main__": 108 | asyncio.run(main()) 109 | ``` 110 | 111 | ## 🔑 Core Components 112 | 113 | **Account Management** 114 | - Account information and settings 115 | - Leverage and margin configuration 116 | - Position mode management 117 | - Asset mode settings 118 | - Interest and bill history 119 | 120 | **Position Management** 121 | - Position tracking and history 122 | - Position tier information 123 | - Multiple position modes support 124 | 125 | **Market Data** 126 | - Real-time tickers and depth 127 | - Candlestick data with multiple timeframes 128 | - Funding rates and open interest 129 | - Historical transaction data 130 | - Contract specifications 131 | 132 | ## 💹 Supported Markets 133 | 134 | | Market Type | Description | 135 | |------------|-------------| 136 | | USDT-FUTURES | USDT margined futures | 137 | | COIN-FUTURES | Coin margined futures | 138 | | USDC-FUTURES | USDC margined futures | 139 | | SUSDT-FUTURES| Simulated USDT futures | 140 | | SCOIN-FUTURES| Simulated coin futures | 141 | | SUSDC-FUTURES| Simulated USDC futures | 142 | 143 | ## ⚠️ Error Handling 144 | 145 | ```python 146 | from bitpy.exceptions import InvalidProductTypeError, BitgetAPIError 147 | 148 | try: 149 | positions = client.position.get_all_positions("INVALID-TYPE") 150 | except InvalidProductTypeError as e: 151 | print(f"Invalid product type: {e}") 152 | except BitgetAPIError as e: 153 | print(f"API Error {e.code}: {e.message}") 154 | ``` 155 | 156 | ## 🔄 Rate Limiting 157 | 158 | The client implements a smart token bucket algorithm for rate limiting, automatically tracking and managing request limits per endpoint to ensure optimal API usage. 159 | 160 | ## 📊 Advanced Market Data 161 | 162 | ```python 163 | # Get candlestick data 164 | candles = client.market.get_candlestick( 165 | symbol="BTCUSDT", 166 | product_type="USDT-FUTURES", 167 | granularity="1m", 168 | limit=100 169 | ) 170 | 171 | # Get market depth 172 | depth = client.market.get_merge_depth( 173 | symbol="BTCUSDT", 174 | product_type="USDT-FUTURES", 175 | precision="0.1" 176 | ) 177 | ``` 178 | 179 | ## 🤝 Contributing 180 | 181 | Contributions are welcome! Feel free to submit a Pull Request. For feature requests or bug reports, please open an issue. 182 | 183 | ## 📄 License 184 | 185 | This project is licensed under the MIT License. -------------------------------------------------------------------------------- /bitpy/__init__.py: -------------------------------------------------------------------------------- 1 | """A Python client for the Bitget API with comprehensive position management 2 | 3 | ... moduleauthor: Tentoxa 4 | """ 5 | 6 | __version__ = '1.0.0' 7 | 8 | from bitpy.rest_api import BitgetAPI 9 | from bitpy.ws_api import BitgetWebsocketAPI 10 | from bitpy.exceptions import ( 11 | BitgetAPIError, 12 | InvalidProductTypeError, 13 | InvalidGranularityError, 14 | InvalidBusinessTypeError, 15 | RequestError 16 | ) 17 | 18 | __all__ = [ 19 | 'BitgetWebsocketAPI', 20 | 'BitgetAPI', 21 | 'BitgetAPIError', 22 | 'InvalidProductTypeError', 23 | 'InvalidGranularityError', 24 | 'InvalidBusinessTypeError', 25 | 'RequestError' 26 | ] -------------------------------------------------------------------------------- /bitpy/clients/account.py: -------------------------------------------------------------------------------- 1 | from .base_client import BitgetBaseClient 2 | from bitpy.exceptions import InvalidBusinessTypeError 3 | from ..models.account import * 4 | from datetime import datetime 5 | from typing import Optional, Union 6 | 7 | 8 | class BitgetAccountClient(BitgetBaseClient): 9 | def get_account(self, symbol: str, product_type: str, margin_coin: str) -> AccountResponse: 10 | self._validate_product_type(product_type) 11 | 12 | params = self._build_params( 13 | symbol=self._clean_symbol(symbol), 14 | productType=product_type, 15 | marginCoin=margin_coin 16 | ) 17 | 18 | response = self.request_handler.request( 19 | method="GET", 20 | endpoint="/api/v2/mix/account/account", 21 | params=params, 22 | authenticate=True 23 | ) 24 | 25 | return AccountResponse(**response) 26 | 27 | @staticmethod 28 | def _validate_businesstype(business_type: str) -> None: 29 | 30 | if type(business_type) != str: 31 | raise InvalidBusinessTypeError(f"Invalid business type. Must be a string") 32 | 33 | valid_types = [pt.value.lower() for pt in BusinessType] 34 | if business_type.lower() not in valid_types: 35 | raise InvalidBusinessTypeError(f"Invalid business type. Must be one of: {', '.join(valid_types)}") 36 | 37 | def get_accounts(self, product_type: str) -> AccountListResponse: 38 | self._validate_product_type(product_type) 39 | 40 | params = self._build_params(productType=product_type) 41 | 42 | response = self.request_handler.request( 43 | method="GET", 44 | endpoint="/api/v2/mix/account/accounts", 45 | params=params, 46 | authenticate=True 47 | ) 48 | 49 | return AccountListResponse(**response) 50 | 51 | def get_sub_account_assets(self, product_type: str) -> SubAccountAssetsResponse: 52 | self._validate_product_type(product_type) 53 | 54 | params = self._build_params(productType=product_type) 55 | 56 | response = self.request_handler.request( 57 | method="GET", 58 | endpoint="/api/v2/mix/account/sub-account-assets", 59 | params=params, 60 | authenticate=True 61 | ) 62 | 63 | return SubAccountAssetsResponse(**response) 64 | 65 | def get_interest_history( 66 | self, 67 | product_type: str, 68 | coin: Optional[str] = None, 69 | id_less_than: Optional[str] = None, 70 | start_time: Optional[Union[datetime, int]] = None, 71 | end_time: Optional[Union[datetime, int]] = None, 72 | limit: Optional[int] = None 73 | ) -> InterestHistoryResponse: 74 | self._validate_product_type(product_type) 75 | 76 | start_time, end_time = self._validate_time_range(start_time, end_time) 77 | limit = min(limit or 20, 100) 78 | 79 | params = self._build_params( 80 | productType=product_type, 81 | coin=coin, 82 | idLessThan=id_less_than, 83 | startTime=start_time, 84 | endTime=end_time, 85 | limit=limit 86 | ) 87 | 88 | response = self.request_handler.request( 89 | method="GET", 90 | endpoint="/api/v2/mix/account/interest-history", 91 | params=params, 92 | authenticate=True 93 | ) 94 | return InterestHistoryResponse(**response) 95 | 96 | def get_open_count(self, symbol: str, product_type: str, margin_coin: str, 97 | open_price: str, open_amount: str, leverage: Optional[str] = None) -> OpenCountResponse: 98 | self._validate_product_type(product_type) 99 | 100 | params = self._build_params( 101 | symbol=self._clean_symbol(symbol), 102 | productType=product_type, 103 | marginCoin=margin_coin, 104 | openPrice=open_price, 105 | openAmount=open_amount, 106 | leverage=leverage 107 | ) 108 | 109 | response = self.request_handler.request( 110 | method="GET", 111 | endpoint="/api/v2/mix/account/open-count", 112 | params=params, 113 | authenticate=True 114 | ) 115 | 116 | return OpenCountResponse(**response) 117 | 118 | def set_auto_margin(self, symbol: str, auto_margin: str, margin_coin: str, hold_side: str) -> SetAutoMarginResponse: 119 | params = self._build_params( 120 | symbol=self._clean_symbol(symbol), 121 | autoMargin=auto_margin, 122 | marginCoin=margin_coin, 123 | holdSide=hold_side 124 | ) 125 | 126 | response = self.request_handler.request( 127 | method="POST", 128 | endpoint="/api/v2/mix/account/set-auto-margin", 129 | params=params, 130 | authenticate=True 131 | ) 132 | 133 | return SetAutoMarginResponse(**response) 134 | 135 | def set_leverage(self, symbol: str, product_type: str, margin_coin: str, 136 | leverage: str, hold_side: Optional[str] = None) -> SetLeverageResponse: 137 | self._validate_product_type(product_type) 138 | 139 | params = self._build_params( 140 | symbol=self._clean_symbol(symbol), 141 | productType=product_type, 142 | marginCoin=margin_coin, 143 | leverage=leverage, 144 | holdSide=hold_side 145 | ) 146 | 147 | response = self.request_handler.request( 148 | method="POST", 149 | endpoint="/api/v2/mix/account/set-leverage", 150 | params=params, 151 | authenticate=True 152 | ) 153 | 154 | return SetLeverageResponse(**response) 155 | 156 | 157 | def set_margin(self, symbol: str, product_type: str, margin_coin: str, amount: str, hold_side: str) -> BaseResponse: 158 | self._validate_product_type(product_type) 159 | 160 | params = self._build_params( 161 | symbol=self._clean_symbol(symbol), 162 | productType=product_type, 163 | marginCoin=margin_coin, 164 | amount=amount, 165 | holdSide=hold_side 166 | ) 167 | 168 | response = self.request_handler.request( 169 | method="POST", 170 | endpoint="/api/v2/mix/account/set-margin", 171 | params=params, 172 | authenticate=True 173 | ) 174 | 175 | return BaseResponse(**response) 176 | 177 | 178 | def set_asset_mode(self, product_type: str, asset_mode: str) -> BaseResponse: 179 | self._validate_product_type(product_type) 180 | 181 | params = self._build_params( 182 | productType=product_type, 183 | assetMode=asset_mode 184 | ) 185 | 186 | response = self.request_handler.request( 187 | method="POST", 188 | endpoint="/api/v2/mix/account/set-asset-mode", 189 | params=params, 190 | authenticate=True 191 | ) 192 | 193 | return BaseResponse(**response) 194 | 195 | 196 | def set_position_mode(self, product_type: str, pos_mode: str) -> PositionModeResponse: 197 | self._validate_product_type(product_type) 198 | 199 | params = self._build_params( 200 | productType=product_type, 201 | posMode=pos_mode 202 | ) 203 | 204 | response = self.request_handler.request( 205 | method="POST", 206 | endpoint="/api/v2/mix/account/set-position-mode", 207 | params=params, 208 | authenticate=True 209 | ) 210 | 211 | return PositionModeResponse(**response) 212 | 213 | def get_bills( 214 | self, 215 | product_type: str, 216 | coin: Optional[str] = None, 217 | business_type: Optional[str] = None, 218 | id_less_than: Optional[str] = None, 219 | start_time: Optional[Union[datetime, int]] = None, 220 | end_time: Optional[Union[datetime, int]] = None, 221 | limit: Optional[int] = None 222 | ) -> BillsResponse: 223 | self._validate_product_type(product_type) 224 | if business_type: 225 | self._validate_businesstype(business_type) 226 | 227 | # Validate coin parameter 228 | if coin and business_type not in ('trans_from_exchange', 'trans_to_exchange'): 229 | raise ValueError( 230 | "coin parameter is only valid when businessType is 'trans_from_exchange' or 'trans_to_exchange'") 231 | 232 | start_time, end_time = self._validate_time_range(start_time, end_time) 233 | limit = min(limit or 20, 100) 234 | 235 | params = self._build_params( 236 | productType=product_type, 237 | coin=coin, 238 | businessType=business_type, 239 | idLessThan=id_less_than, 240 | startTime=start_time, 241 | endTime=end_time, 242 | limit=limit 243 | ) 244 | 245 | response = self.request_handler.request( 246 | method="GET", 247 | endpoint="/api/v2/mix/account/bill", 248 | params=params, 249 | authenticate=True 250 | ) 251 | 252 | return BillsResponse(**response) 253 | 254 | def set_margin_mode(self, symbol: str, product_type: str, margin_coin: str, margin_mode: str) -> MarginModeResponse: 255 | self._validate_product_type(product_type) 256 | 257 | params = self._build_params( 258 | symbol=self._clean_symbol(symbol), 259 | productType=product_type, 260 | marginCoin=margin_coin, 261 | marginMode=margin_mode 262 | ) 263 | 264 | response = self.request_handler.request( 265 | method="POST", 266 | endpoint="/api/v2/mix/account/set-margin-mode", 267 | params=params, 268 | authenticate=True 269 | ) 270 | 271 | return MarginModeResponse(**response) 272 | -------------------------------------------------------------------------------- /bitpy/clients/base_client.py: -------------------------------------------------------------------------------- 1 | from ..utils.request_handler import RequestHandler 2 | from ..models.base import ProductType 3 | from bitpy.exceptions import InvalidProductTypeError 4 | from datetime import datetime, timedelta 5 | from typing import Optional, Union 6 | 7 | 8 | class BitgetBaseClient: 9 | MAX_TIME_RANGE_DAYS = 90 10 | 11 | def __init__(self, request_handler: RequestHandler, debug: bool = False): 12 | self.request_handler = request_handler 13 | self.debug = debug 14 | 15 | @staticmethod 16 | def _build_params(**kwargs) -> dict: 17 | return {k: str(v) for k, v in kwargs.items() if v is not None} 18 | 19 | @staticmethod 20 | def _clean_symbol(symbol: str) -> str: 21 | cleaned = ''.join(c for c in symbol.lower().strip() if c.isalnum()) 22 | return f"{cleaned[:-4]}usdt" if "usdt" in cleaned else cleaned 23 | 24 | @staticmethod 25 | def _validate_product_type(product_type: str) -> None: 26 | if type(product_type) != str: 27 | raise InvalidProductTypeError(f"Invalid Product Type. Must be a string") 28 | 29 | valid_types = [pt.value for pt in ProductType] 30 | if product_type not in valid_types: 31 | raise InvalidProductTypeError(f"Invalid product type. Must be one of: {', '.join(valid_types)}") 32 | 33 | def _validate_time_range(self, start_time: Optional[Union[datetime, int]], 34 | end_time: Optional[Union[datetime, int]]) -> tuple: 35 | if not (start_time and end_time): 36 | return start_time, end_time 37 | 38 | if isinstance(end_time, datetime) and isinstance(start_time, datetime): 39 | time_diff = end_time - start_time 40 | if time_diff.days > self.MAX_TIME_RANGE_DAYS: 41 | start_time = end_time - timedelta(days=self.MAX_TIME_RANGE_DAYS) 42 | 43 | return ( 44 | int(start_time.timestamp() * 1000) if isinstance(start_time, datetime) else start_time, 45 | int(end_time.timestamp() * 1000) if isinstance(end_time, datetime) else end_time 46 | ) 47 | -------------------------------------------------------------------------------- /bitpy/clients/market.py: -------------------------------------------------------------------------------- 1 | from ..models.market import * 2 | from bitpy.exceptions import InvalidGranularityError 3 | from typing import Optional, Union 4 | from datetime import datetime 5 | from .base_client import BitgetBaseClient 6 | 7 | 8 | class BitgetMarketClient(BitgetBaseClient): 9 | def get_vip_fee_rate(self) -> VIPFeeRateResponse: 10 | response = self.request_handler.request( 11 | method="GET", 12 | endpoint="/api/v2/mix/market/vip-fee-rate", 13 | authenticate=False 14 | ) 15 | 16 | return VIPFeeRateResponse( 17 | code=response["code"], 18 | msg=response["msg"], 19 | requestTime=response["requestTime"], 20 | data=[VIPFeeRateData(**item) for item in response["data"]] 21 | ) 22 | 23 | @staticmethod 24 | def _validate_granularity(granularity: str) -> None: 25 | if type(granularity) != str: 26 | raise InvalidGranularityError(f"Invalid granularity. Must be a string") 27 | 28 | valid_types = [pt.value.lower() for pt in CandleGranularity] 29 | if granularity.lower() not in valid_types: 30 | raise InvalidGranularityError(f"Invalid granularity type. Must be one of: {', '.join(valid_types)}") 31 | 32 | def get_interest_rate_history(self, coin: str) -> InterestRateHistoryResponse: 33 | response = self.request_handler.request( 34 | method="GET", 35 | endpoint="/api/v2/mix/market/union-interest-rate-history", 36 | params={"coin": coin}, 37 | authenticate=False 38 | ) 39 | 40 | return InterestRateHistoryResponse(**response) 41 | 42 | def get_exchange_rate(self) -> ExchangeRateResponse: 43 | response = self.request_handler.request( 44 | method="GET", 45 | endpoint="/api/v2/mix/market/exchange-rate", 46 | authenticate=False 47 | ) 48 | 49 | return ExchangeRateResponse(**response) 50 | 51 | def get_discount_rate(self) -> DiscountRateResponse: 52 | response = self.request_handler.request( 53 | method="GET", 54 | endpoint="/api/v2/mix/market/discount-rate", 55 | authenticate=False 56 | ) 57 | return DiscountRateResponse(**response) 58 | 59 | def get_merge_depth(self, symbol: str, product_type: str, precision: str = None, 60 | limit: str = None) -> MarketDepthResponse: 61 | self._validate_product_type(product_type) 62 | params = self._build_params( 63 | symbol=self._clean_symbol(symbol), 64 | productType=product_type, 65 | precision=precision, 66 | limit=limit 67 | ) 68 | 69 | response = self.request_handler.request( 70 | method="GET", 71 | endpoint="/api/v2/mix/market/merge-depth", 72 | params=params, 73 | authenticate=False 74 | ) 75 | return MarketDepthResponse(**response) 76 | 77 | def get_ticker(self, symbol: str, product_type: str) -> TickerResponse: 78 | self._validate_product_type(product_type) 79 | params = self._build_params( 80 | symbol=self._clean_symbol(symbol), 81 | productType=product_type 82 | ) 83 | response = self.request_handler.request( 84 | method="GET", 85 | endpoint="/api/v2/mix/market/ticker", 86 | params=params, 87 | authenticate=False 88 | ) 89 | return TickerResponse(**response) 90 | 91 | def get_all_tickers(self, product_type: str) -> TickerResponse: 92 | self._validate_product_type(product_type) 93 | params = self._build_params(productType=product_type) 94 | response = self.request_handler.request( 95 | method="GET", 96 | endpoint="/api/v2/mix/market/tickers", 97 | params=params, 98 | authenticate=False 99 | ) 100 | return TickerResponse(**response) 101 | 102 | def get_recent_transactions(self, symbol: str, product_type: str, 103 | limit: str = None) -> RecentTransactionsResponse: 104 | self._validate_product_type(product_type) 105 | params = self._build_params( 106 | symbol=self._clean_symbol(symbol), 107 | productType=product_type, 108 | limit=limit 109 | ) 110 | 111 | response = self.request_handler.request( 112 | method="GET", 113 | endpoint="/api/v2/mix/market/fills", 114 | params=params, 115 | authenticate=False 116 | ) 117 | return RecentTransactionsResponse(**response) 118 | 119 | def get_historical_transactions( 120 | self, 121 | symbol: str, 122 | product_type: str, 123 | limit: Optional[int] = 500, 124 | id_less_than: Optional[str] = None, 125 | start_time: Optional[Union[datetime, int]] = None, 126 | end_time: Optional[Union[datetime, int]] = None 127 | ) -> HistoricalTransactionsResponse: 128 | self._validate_product_type(product_type) 129 | 130 | start_time, end_time = self._validate_time_range(start_time, end_time) 131 | limit = min(limit or 500, 1000) 132 | 133 | params = self._build_params( 134 | symbol=self._clean_symbol(symbol), 135 | productType=product_type, 136 | limit=limit, 137 | idLessThan=id_less_than, 138 | startTime=start_time, 139 | endTime=end_time 140 | ) 141 | 142 | response = self.request_handler.request( 143 | method="GET", 144 | endpoint="/api/v2/mix/market/fills-history", 145 | params=params, 146 | authenticate=False 147 | ) 148 | return HistoricalTransactionsResponse(**response) 149 | 150 | def get_candlestick(self, symbol: str, product_type: str, granularity: str, 151 | start_time: Optional[Union[datetime, int]] = None, 152 | end_time: Optional[Union[datetime, int]] = None, 153 | k_line_type: str = None, limit: Optional[int] = 100) -> CandlestickResponse: 154 | self._validate_product_type(product_type) 155 | self._validate_granularity(granularity) 156 | 157 | start_time, end_time = self._validate_time_range(start_time, end_time) 158 | limit = min(limit or 100, 1000) 159 | 160 | params = self._build_params( 161 | symbol=self._clean_symbol(symbol), 162 | productType=product_type, 163 | granularity=granularity, 164 | startTime=start_time, 165 | endTime=end_time, 166 | kLineType=k_line_type, 167 | limit=limit 168 | ) 169 | 170 | response = self.request_handler.request( 171 | method="GET", 172 | endpoint="/api/v2/mix/market/candles", 173 | params=params, 174 | authenticate=False 175 | ) 176 | return CandlestickResponse(**response) 177 | 178 | def get_history_candlestick(self, symbol: str, product_type: str, granularity: str, 179 | start_time: Optional[Union[datetime, int]] = None, 180 | end_time: Optional[Union[datetime, int]] = None, 181 | limit: Optional[int] = 100) -> CandlestickResponse: 182 | self._validate_product_type(product_type) 183 | self._validate_granularity(granularity) 184 | start_time, end_time = self._validate_time_range(start_time, end_time) 185 | limit = min(limit or 100, 200) 186 | 187 | params = self._build_params( 188 | symbol=self._clean_symbol(symbol), 189 | productType=product_type, 190 | granularity=granularity, 191 | startTime=start_time, 192 | endTime=end_time, 193 | limit=limit 194 | ) 195 | response = self.request_handler.request( 196 | method="GET", 197 | endpoint="/api/v2/mix/market/history-candles", 198 | params=params, 199 | authenticate=False 200 | ) 201 | return CandlestickResponse(**response) 202 | 203 | def get_history_index_candlestick(self, symbol: str, product_type: str, granularity: str, 204 | start_time: Optional[Union[datetime, int]] = None, 205 | end_time: Optional[Union[datetime, int]] = None, 206 | limit: Optional[int] = 100) -> CandlestickResponse: 207 | self._validate_product_type(product_type) 208 | self._validate_granularity(granularity) 209 | start_time, end_time = self._validate_time_range(start_time, end_time) 210 | limit = min(limit or 100, 200) 211 | 212 | params = self._build_params( 213 | symbol=self._clean_symbol(symbol), 214 | productType=product_type, 215 | granularity=granularity, 216 | startTime=start_time, 217 | endTime=end_time, 218 | limit=limit 219 | ) 220 | response = self.request_handler.request( 221 | method="GET", 222 | endpoint="/api/v2/mix/market/history-index-candles", 223 | params=params, 224 | authenticate=False 225 | ) 226 | return CandlestickResponse(**response) 227 | 228 | def get_history_mark_candlestick(self, symbol: str, product_type: str, granularity: str, 229 | start_time: Optional[Union[datetime, int]] = None, 230 | end_time: Optional[Union[datetime, int]] = None, 231 | limit: Optional[int] = 100) -> CandlestickResponse: 232 | self._validate_product_type(product_type) 233 | self._validate_granularity(granularity) 234 | start_time, end_time = self._validate_time_range(start_time, end_time) 235 | limit = min(limit or 100, 200) 236 | 237 | params = self._build_params( 238 | symbol=self._clean_symbol(symbol), 239 | productType=product_type, 240 | granularity=granularity, 241 | startTime=start_time, 242 | endTime=end_time, 243 | limit=limit 244 | ) 245 | response = self.request_handler.request( 246 | method="GET", 247 | endpoint="/api/v2/mix/market/history-mark-candles", 248 | params=params, 249 | authenticate=False 250 | ) 251 | return CandlestickResponse(**response) 252 | 253 | def get_open_interest(self, symbol: str, product_type: str) -> OpenInterestResponse: 254 | self._validate_product_type(product_type) 255 | params = self._build_params( 256 | symbol=self._clean_symbol(symbol), 257 | productType=product_type 258 | ) 259 | response = self.request_handler.request( 260 | method="GET", 261 | endpoint="/api/v2/mix/market/open-interest", 262 | params=params, 263 | authenticate=False 264 | ) 265 | return OpenInterestResponse(**response) 266 | 267 | def get_funding_time(self, symbol: str, product_type: str) -> FundingTimeResponse: 268 | self._validate_product_type(product_type) 269 | params = self._build_params( 270 | symbol=self._clean_symbol(symbol), 271 | productType=product_type 272 | ) 273 | response = self.request_handler.request( 274 | method="GET", 275 | endpoint="/api/v2/mix/market/funding-time", 276 | params=params, 277 | authenticate=False 278 | ) 279 | return FundingTimeResponse(**response) 280 | 281 | def get_symbol_price(self, symbol: str, product_type: str) -> SymbolPriceResponse: 282 | self._validate_product_type(product_type) 283 | params = self._build_params( 284 | symbol=self._clean_symbol(symbol), 285 | productType=product_type 286 | ) 287 | response = self.request_handler.request( 288 | method="GET", 289 | endpoint="/api/v2/mix/market/symbol-price", 290 | params=params, 291 | authenticate=False 292 | ) 293 | return SymbolPriceResponse(**response) 294 | 295 | def get_historical_funding_rates(self, symbol: str, product_type: str, 296 | page_size: str = None, page_no: str = None) -> HistoricalFundingRateResponse: 297 | self._validate_product_type(product_type) 298 | if page_size: 299 | page_size = min(int(page_size), 100) 300 | params = self._build_params( 301 | symbol=self._clean_symbol(symbol), 302 | productType=product_type, 303 | pageSize=page_size, 304 | pageNo=page_no 305 | ) 306 | response = self.request_handler.request( 307 | method="GET", 308 | endpoint="/api/v2/mix/market/history-fund-rate", 309 | params=params, 310 | authenticate=False 311 | ) 312 | return HistoricalFundingRateResponse(**response) 313 | 314 | def get_current_funding_rate(self, symbol: str, product_type: str) -> CurrentFundingRateResponse: 315 | self._validate_product_type(product_type) 316 | params = self._build_params( 317 | symbol=self._clean_symbol(symbol), 318 | productType=product_type 319 | ) 320 | response = self.request_handler.request( 321 | method="GET", 322 | endpoint="/api/v2/mix/market/current-fund-rate", 323 | params=params, 324 | authenticate=False 325 | ) 326 | return CurrentFundingRateResponse(**response) 327 | 328 | def get_contract_config(self, symbol: str = None, product_type: str = None) -> ContractConfigResponse: 329 | self._validate_product_type(product_type) 330 | params = self._build_params( 331 | symbol=self._clean_symbol(symbol) if symbol else None, 332 | productType=product_type 333 | ) 334 | response = self.request_handler.request( 335 | method="GET", 336 | endpoint="/api/v2/mix/market/contracts", 337 | params=params, 338 | authenticate=False 339 | ) 340 | return ContractConfigResponse(**response) 341 | -------------------------------------------------------------------------------- /bitpy/clients/position.py: -------------------------------------------------------------------------------- 1 | from .base_client import BitgetBaseClient 2 | from ..models.position import * 3 | from datetime import datetime 4 | from typing import Optional, Union 5 | 6 | 7 | class BitgetPositionClient(BitgetBaseClient): 8 | def get_all_positions(self, product_type: str, margin_coin: Optional[str] = None) -> AllPositionsResponse: 9 | self._validate_product_type(product_type) 10 | params = self._build_params( 11 | productType=product_type, 12 | marginCoin=margin_coin.upper() if margin_coin else None 13 | ) 14 | response = self.request_handler.request("GET", "/api/v2/mix/position/all-position", params) 15 | return AllPositionsResponse( 16 | code=response["code"], 17 | msg=response["msg"], 18 | requestTime=response["requestTime"], 19 | data=[PositionData(**item) for item in response["data"]] 20 | ) 21 | 22 | def get_single_position(self, symbol: str, product_type: str, margin_coin: str) -> SinglePositionResponse: 23 | self._validate_product_type(product_type) 24 | params = self._build_params( 25 | symbol=self._clean_symbol(symbol), 26 | productType=product_type, 27 | marginCoin=margin_coin.upper() 28 | ) 29 | response = self.request_handler.request("GET", "/api/v2/mix/position/single-position", params) 30 | return SinglePositionResponse( 31 | code=response["code"], 32 | msg=response["msg"], 33 | requestTime=response["requestTime"], 34 | data=[PositionData(**item) for item in response["data"]] 35 | ) 36 | 37 | def get_historical_position( 38 | self, 39 | product_type: str = "USDT-FUTURES", 40 | symbol: Optional[str] = None, 41 | id_less_than: Optional[str] = None, 42 | start_time: Optional[Union[datetime, int]] = None, 43 | end_time: Optional[Union[datetime, int]] = None, 44 | limit: Optional[int] = None 45 | ) -> HistoricalPositionsResponse: 46 | self._validate_product_type(product_type) 47 | 48 | start_time, end_time = self._validate_time_range(start_time, end_time) 49 | limit = min(limit or 20, 100) 50 | 51 | params = self._build_params( 52 | productType=product_type, 53 | symbol=self._clean_symbol(symbol) if symbol else None, 54 | idLessThan=id_less_than, 55 | startTime=start_time, 56 | endTime=end_time, 57 | limit=limit 58 | ) 59 | response = self.request_handler.request("GET", "/api/v2/mix/position/history-position", params) 60 | return HistoricalPositionsResponse( 61 | code=response["code"], 62 | msg=response["msg"], 63 | requestTime=response["requestTime"], 64 | data=[HistoricalPositionData(**item) for item in response["data"]["list"]] 65 | ) 66 | 67 | def get_position_tier(self, symbol: str, product_type: str) -> PositionTierResponse: 68 | self._validate_product_type(product_type) 69 | params = self._build_params( 70 | symbol=self._clean_symbol(symbol), 71 | productType=product_type 72 | ) 73 | response = self.request_handler.request("GET", "/api/v2/mix/market/query-position-lever", params) 74 | return PositionTierResponse( 75 | code=response["code"], 76 | msg=response["msg"], 77 | requestTime=response["requestTime"], 78 | data=[PositionTierData(**item) for item in response["data"]] 79 | ) 80 | -------------------------------------------------------------------------------- /bitpy/clients/ws.py: -------------------------------------------------------------------------------- 1 | import json 2 | import time 3 | import hmac 4 | import base64 5 | import hashlib 6 | import asyncio 7 | import websockets 8 | from typing import Optional, List, Dict, Any, Callable 9 | 10 | from ..models.login import BitgetCredentials 11 | from ..utils.log_manager import LogManager 12 | 13 | 14 | class BitgetWebsocketClient: 15 | def __init__( 16 | self, 17 | credentials: Optional[BitgetCredentials] = None, 18 | is_private: bool = False, 19 | debug: bool = False 20 | ): 21 | self.api_key = credentials.api_key if credentials else None 22 | self.secret_key = credentials.secret_key if credentials else None 23 | self.passphrase = credentials.api_passphrase if credentials else None 24 | self.debug = debug 25 | 26 | self.websocket = None 27 | self.connected = False 28 | self.subscriptions = set() 29 | self.callbacks = {} 30 | self._tasks = [] 31 | self._should_reconnect = True 32 | 33 | self.is_private = is_private 34 | 35 | self.logger = LogManager(self, debug) 36 | self.logger.info("Initializing BitgetWebsocketClient") 37 | 38 | self.base_url = "wss://ws.bitget.com/v2/ws/" 39 | self.url = f"{self.base_url}{'private' if is_private else 'public'}" 40 | self.logger.debug(f"WebSocket URL set to: {self.url}") 41 | 42 | def _generate_signature(self, timestamp: int) -> str: 43 | """ 44 | Generate HMAC SHA256 signature for authentication. 45 | 46 | Args: 47 | timestamp: Unix timestamp in seconds 48 | 49 | Returns: 50 | Base64 encoded signature 51 | """ 52 | if not self.secret_key: 53 | raise ValueError("Secret key is required for signature generation") 54 | 55 | # Create the message string: timestamp + "GET" + "/user/verify" 56 | message = f"{timestamp}GET/user/verify" 57 | 58 | # Create the HMAC SHA256 signature 59 | hmac_obj = hmac.new( 60 | self.secret_key.encode('utf-8'), 61 | message.encode('utf-8'), 62 | hashlib.sha256 63 | ) 64 | 65 | # Return base64 encoded signature 66 | return base64.b64encode(hmac_obj.digest()).decode('utf-8') 67 | 68 | async def _login(self) -> bool: 69 | """ 70 | Perform login for private websocket connections. 71 | 72 | Returns: 73 | bool: True if login successful, False otherwise 74 | """ 75 | if not all([self.api_key, self.secret_key, self.passphrase]): 76 | self.logger.error("Missing credentials for private connection") 77 | return False 78 | 79 | try: 80 | # Generate timestamp in seconds 81 | timestamp = int(time.time()) 82 | 83 | # Generate signature 84 | signature = self._generate_signature(timestamp) 85 | 86 | # Construct login request 87 | login_request = { 88 | "op": "login", 89 | "args": [{ 90 | "apiKey": self.api_key, 91 | "passphrase": self.passphrase, 92 | "timestamp": str(timestamp), 93 | "sign": signature 94 | }] 95 | } 96 | 97 | # Send login request 98 | self.logger.debug("Sending login request") 99 | await self.websocket.send(json.dumps(login_request)) 100 | 101 | # Wait for login response 102 | response = await self.websocket.recv() 103 | response_data = json.loads(response) 104 | 105 | if response_data.get("event") == "login" and response_data.get("code") == "0": 106 | self.logger.info("Login successful") 107 | return True 108 | else: 109 | self.logger.error(f"Login failed: {response_data}") 110 | return False 111 | 112 | except Exception as e: 113 | self.logger.error(f"Login error: {str(e)}") 114 | return False 115 | 116 | async def _message_handler(self) -> None: 117 | self.logger.debug("Starting message handler") 118 | while self.connected: 119 | try: 120 | message = await self.websocket.recv() 121 | 122 | if message == "pong": 123 | self.logger.debug("Pong received") 124 | continue 125 | 126 | data = json.loads(message) 127 | self.logger.debug(f"Received message: {data}") 128 | 129 | if "event" in data: 130 | if data["event"] == "login" and data.get("code") == "0": 131 | self.logger.info("Successfully logged in") 132 | continue 133 | 134 | channel = data.get("arg", {}).get("channel") 135 | if channel in self.callbacks: 136 | self.logger.debug(f"Processing callback for channel: {channel}") 137 | try: 138 | await self.callbacks[channel](data) 139 | except Exception as callback_error: 140 | self.logger.error(f"Callback error for channel {channel}: {str(callback_error)}") 141 | continue 142 | 143 | except websockets.ConnectionClosed: 144 | self.logger.warning("WebSocket connection closed") 145 | break 146 | except Exception as e: 147 | self.logger.error(f"Message handler error: {str(e)}") 148 | if not isinstance(e, asyncio.CancelledError): 149 | continue 150 | 151 | self.logger.debug("Message handler ended") 152 | if self._should_reconnect: 153 | await self._reconnect() 154 | 155 | async def _reconnect(self) -> None: 156 | self.logger.info("Attempting to reconnect") 157 | self.connected = False 158 | 159 | while self._should_reconnect: 160 | try: 161 | await self.connect() 162 | # Resubscribe to previous subscriptions 163 | for sub in self.subscriptions: 164 | sub_dict = json.loads(sub) 165 | channel = sub_dict.get("channel") 166 | if channel in self.callbacks: 167 | await self.subscribe([sub_dict], self.callbacks[channel]) 168 | break 169 | except Exception as e: 170 | self.logger.error(f"Reconnection failed: {str(e)}") 171 | await asyncio.sleep(5) 172 | 173 | async def _ping(self) -> None: 174 | self.logger.debug("Starting ping loop") 175 | while self.connected: 176 | try: 177 | await self.websocket.send("ping") 178 | self.logger.debug("Ping sent") 179 | await asyncio.sleep(30) 180 | except Exception as e: 181 | self.logger.error(f"Ping error: {str(e)}") 182 | break 183 | self.logger.debug("Ping loop ended") 184 | 185 | async def connect(self) -> None: 186 | if self.connected: 187 | return 188 | 189 | self.logger.info("Attempting to connect to WebSocket") 190 | try: 191 | self.websocket = await websockets.connect(self.url) 192 | self.connected = True 193 | self._should_reconnect = True 194 | self.logger.info("WebSocket connection established") 195 | 196 | if self.is_private: 197 | if not await self._login(): 198 | self.logger.error("Login failed, closing connection") 199 | await self.close() 200 | raise ConnectionError("Login failed") 201 | 202 | # Create background tasks 203 | ping_task = asyncio.create_task(self._ping()) 204 | handler_task = asyncio.create_task(self._message_handler()) 205 | 206 | # Store tasks for cleanup 207 | self._tasks.extend([ping_task, handler_task]) 208 | 209 | self.logger.debug("Started background tasks") 210 | 211 | except Exception as e: 212 | self.logger.error(f"Connection error: {str(e)}") 213 | self.connected = False 214 | raise 215 | 216 | async def subscribe(self, subscriptions: List[Dict[str, str]], callback: Callable[[Dict], Any]) -> None: 217 | if not self.connected: 218 | self.logger.error("Cannot subscribe: WebSocket is not connected") 219 | raise ConnectionError("WebSocket is not connected") 220 | 221 | self.logger.info(f"Subscribing to channels: {subscriptions}") 222 | request = { 223 | "op": "subscribe", 224 | "args": subscriptions 225 | } 226 | 227 | for sub in subscriptions: 228 | channel = sub.get("channel") 229 | if channel: 230 | self.callbacks[channel] = callback 231 | self.subscriptions.add(json.dumps(sub)) 232 | self.logger.debug(f"Added callback for channel: {channel}") 233 | 234 | await self.websocket.send(json.dumps(request)) 235 | 236 | async def close(self) -> None: 237 | self.logger.info("Closing WebSocket connection") 238 | self._should_reconnect = False 239 | if self.connected: 240 | # Cancel all background tasks 241 | for task in self._tasks: 242 | task.cancel() 243 | try: 244 | await task 245 | except asyncio.CancelledError: 246 | pass 247 | 248 | self._tasks.clear() 249 | await self.websocket.close() 250 | self.connected = False 251 | self.logger.debug("WebSocket connection closed") -------------------------------------------------------------------------------- /bitpy/exceptions.py: -------------------------------------------------------------------------------- 1 | class BitgetAPIError(Exception): 2 | def __init__(self, code: str, message: str): 3 | self.code = code 4 | self.message = message 5 | super().__init__(f"BitgetAPI Error {code}: {message}" 6 | f"") 7 | 8 | 9 | class InvalidProductTypeError(Exception): 10 | pass 11 | 12 | 13 | class InvalidGranularityError(Exception): 14 | pass 15 | 16 | 17 | class InvalidBusinessTypeError(Exception): 18 | pass 19 | 20 | 21 | class RequestError(Exception): 22 | pass 23 | -------------------------------------------------------------------------------- /bitpy/models/account.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from .base import BaseResponse, BaseData 4 | 5 | 6 | class BusinessType(Enum): 7 | UNKNOWN = 'unknown' 8 | TRANS_FROM_EXCHANGE = 'trans_from_exchange' 9 | TRANS_TO_EXCHANGE = 'trans_to_exchange' 10 | OPEN_LONG = 'open_long' 11 | OPEN_SHORT = 'open_short' 12 | CLOSE_LONG = 'close_long' 13 | CLOSE_SHORT = 'close_short' 14 | FORCE_CLOSE_LONG = 'force_close_long' 15 | FORCE_CLOSE_SHORT = 'force_close_short' 16 | CONTRACT_SETTLE_FEE = 'contract_settle_fee' 17 | APPEND_MARGIN = 'append_margin' 18 | ADJUST_DOWN_LEVER_APPEND_MARGIN = 'adjust_down_lever_append_margin' 19 | REDUCE_MARGIN = 'reduce_margin' 20 | AUTO_APPEND_MARGIN = 'auto_append_margin' 21 | CASH_GIFT_ISSUE = 'cash_gift_issue' 22 | CASH_GIFT_RECYCLE = 'cash_gift_recycle' 23 | TRACKING_FOLLOW_PAY = 'tracking_follow_pay' 24 | TRACKING_FOLLOW_BACK = 'tracking_follow_back' 25 | TRACKING_TRADER_INCOME = 'tracking_trader_income' 26 | BURST_LONG_LOSS_QUERY = 'burst_long_loss_query' 27 | BURST_SHORT_LOSS_QUERY = 'burst_short_loss_query' 28 | TRANS_FROM_CONTRACT = 'trans_from_contract' 29 | TRANS_TO_CONTRACT = 'trans_to_contract' 30 | TRANS_FROM_OTC = 'trans_from_otc' 31 | TRANS_TO_OTC = 'trans_to_otc' 32 | BUY = 'buy' 33 | SELL = 'sell' 34 | FORCE_BUY = 'force_buy' 35 | FORCE_SELL = 'force_sell' 36 | BURST_BUY = 'burst_buy' 37 | BURST_SELL = 'burst_sell' 38 | BONUS_ISSUE = 'bonus_issue' 39 | BONUS_RECYCLE = 'bonus_recycle' 40 | BONUS_EXPIRED = 'bonus_expired' 41 | DELIVERY_LONG = 'delivery_long' 42 | DELIVERY_SHORT = 'delivery_short' 43 | TRANS_FROM_CROSS = 'trans_from_cross' 44 | TRANS_TO_CROSS = 'trans_to_cross' 45 | TRANS_FROM_ISOLATED = 'trans_from_isolated' 46 | TRANS_TO_ISOLATED = 'trans_to_isolated' 47 | 48 | 49 | @dataclass 50 | class AccountData(BaseData): 51 | marginCoin: str 52 | locked: str 53 | available: str 54 | crossedMaxAvailable: str 55 | isolatedMaxAvailable: str 56 | maxTransferOut: str 57 | accountEquity: str 58 | usdtEquity: str 59 | btcEquity: str 60 | crossedRiskRate: str 61 | crossedMarginLeverage: str 62 | isolatedLongLever: str 63 | isolatedShortLever: str 64 | marginMode: str 65 | posMode: str 66 | unrealizedPL: str 67 | coupon: str 68 | crossedUnrealizedPL: str 69 | isolatedUnrealizedPL: str 70 | assetMode: str 71 | 72 | 73 | @dataclass 74 | class AccountResponse(BaseResponse): 75 | data: AccountData 76 | 77 | 78 | @dataclass 79 | class AccountListData(BaseData): 80 | marginCoin: str 81 | locked: str 82 | available: str 83 | crossedMaxAvailable: str 84 | isolatedMaxAvailable: str 85 | maxTransferOut: str 86 | accountEquity: str 87 | usdtEquity: str 88 | btcEquity: str 89 | crossedRiskRate: str 90 | unrealizedPL: str 91 | coupon: str 92 | unionTotalMagin: str 93 | unionAvailable: str 94 | unionMm: str 95 | assetList: list[dict] 96 | isolatedMargin: str 97 | crossedMargin: str 98 | crossedUnrealizedPL: str 99 | isolatedUnrealizedPL: str 100 | assetMode: str 101 | 102 | 103 | @dataclass 104 | class AccountListResponse(BaseResponse): 105 | data: list[AccountListData] 106 | 107 | 108 | @dataclass 109 | class SubAccountAssetData(BaseData): 110 | marginCoin: str 111 | locked: str 112 | available: str 113 | crossedMaxAvailable: str 114 | isolatedMaxAvailable: str 115 | maxTransferOut: str 116 | accountEquity: str 117 | usdtEquity: str 118 | btcEquity: str 119 | unrealizedPL: str 120 | coupon: str 121 | 122 | 123 | @dataclass 124 | class SubAccountData(BaseData): 125 | userId: int 126 | assetList: list[SubAccountAssetData] 127 | 128 | 129 | @dataclass 130 | class SubAccountAssetsResponse(BaseResponse): 131 | data: list[SubAccountData] 132 | 133 | 134 | @dataclass 135 | class InterestData(BaseData): 136 | coin: str 137 | liability: str 138 | interestFreeLimit: str 139 | interestLimit: str 140 | hourInterestRate: str 141 | interest: str 142 | cTime: str 143 | 144 | 145 | @dataclass 146 | class InterestHistoryData(BaseData): 147 | nextSettleTime: str 148 | borrowAmount: str 149 | borrowLimit: str 150 | interestList: list[InterestData] 151 | endId: str 152 | 153 | 154 | @dataclass 155 | class InterestHistoryResponse(BaseResponse): 156 | data: InterestHistoryData 157 | 158 | 159 | @dataclass 160 | class OpenCountData(BaseData): 161 | size: str 162 | 163 | 164 | @dataclass 165 | class OpenCountResponse(BaseResponse): 166 | data: OpenCountData 167 | 168 | 169 | @dataclass 170 | class SetAutoMarginResponse(BaseResponse): 171 | data: str 172 | 173 | 174 | @dataclass 175 | class SetLeverageData(BaseData): 176 | symbol: str 177 | marginCoin: str 178 | longLeverage: str 179 | shortLeverage: str 180 | crossMarginLeverage: str 181 | marginMode: str 182 | 183 | 184 | @dataclass 185 | class SetLeverageResponse(BaseResponse): 186 | data: SetLeverageData 187 | 188 | 189 | @dataclass 190 | class PositionModeData(BaseData): 191 | posMode: str 192 | 193 | 194 | @dataclass 195 | class PositionModeResponse(BaseResponse): 196 | data: PositionModeData 197 | 198 | 199 | @dataclass 200 | class BillData(BaseData): 201 | billId: str 202 | symbol: str 203 | amount: str 204 | fee: str 205 | feeByCoupon: str 206 | businessType: str 207 | coin: str 208 | balance: str 209 | cTime: str 210 | 211 | 212 | @dataclass 213 | class BillsResponseData(BaseData): 214 | bills: list[BillData] 215 | endId: str 216 | 217 | 218 | @dataclass 219 | class BillsResponse(BaseResponse): 220 | data: BillsResponseData 221 | 222 | 223 | @dataclass 224 | class MarginModeData(BaseData): 225 | symbol: str 226 | marginCoin: str 227 | longLeverage: str 228 | shortLeverage: str 229 | marginMode: str 230 | 231 | 232 | @dataclass 233 | class MarginModeResponse(BaseResponse): 234 | data: MarginModeData 235 | -------------------------------------------------------------------------------- /bitpy/models/base.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | 4 | 5 | class ProductType(Enum): 6 | USDT_FUTURES = 'USDT-FUTURES' 7 | COIN_FUTURES = 'COIN-FUTURES' 8 | USDC_FUTURES = 'USDC-FUTURES' 9 | SUSDT_FUTURES = 'SUSDT-FUTURES' 10 | SCOIN_FUTURES = 'SCOIN-FUTURES' 11 | SUSDC_FUTURES = 'SUSDC-FUTURES' 12 | 13 | 14 | @dataclass 15 | class BaseResponse: 16 | code: str 17 | msg: str 18 | requestTime: int 19 | 20 | 21 | @dataclass 22 | class BaseData: 23 | def __init__(self, **kwargs): 24 | for k, v in kwargs.items(): 25 | setattr(self, k, v if v is not None else '') 26 | -------------------------------------------------------------------------------- /bitpy/models/login.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | @dataclass 5 | class BitgetCredentials: 6 | api_key: Optional[str] = None 7 | secret_key: Optional[str] = None 8 | api_passphrase: Optional[str] = None -------------------------------------------------------------------------------- /bitpy/models/market.py: -------------------------------------------------------------------------------- 1 | from .base import BaseResponse, BaseData 2 | from enum import Enum 3 | from dataclasses import dataclass 4 | from typing import List, Optional 5 | 6 | 7 | class CandleGranularity(Enum): 8 | MINUTE_1 = '1m' 9 | MINUTE_3 = '3m' 10 | MINUTE_5 = '5m' 11 | MINUTE_15 = '15m' 12 | MINUTE_30 = '30m' 13 | HOUR_1 = '1H' 14 | HOUR_4 = '4H' 15 | HOUR_6 = '6H' 16 | HOUR_12 = '12H' 17 | DAY_1 = '1D' 18 | WEEK_1 = '1W' 19 | MONTH_1 = '1M' 20 | HOUR_6_UTC = '6Hutc' 21 | HOUR_12_UTC = '12Hutc' 22 | DAY_1_UTC = '1Dutc' 23 | DAY_3_UTC = '3Dutc' 24 | WEEK_1_UTC = '1Wutc' 25 | MONTH_1_UTC = '1Mutc' 26 | 27 | 28 | @dataclass 29 | class VIPFeeRateData(BaseData): 30 | level: str 31 | dealAmount: str 32 | assetAmount: str 33 | takerFeeRate: str 34 | makerFeeRate: str 35 | btcWithdrawAmount: str 36 | usdtWithdrawAmount: str 37 | 38 | 39 | @dataclass 40 | class VIPFeeRateResponse(BaseResponse): 41 | data: List[VIPFeeRateData] 42 | 43 | 44 | @dataclass 45 | class InterestRateHistory(BaseData): 46 | ts: str 47 | annualInterestRate: str 48 | dailyInterestRate: str 49 | 50 | 51 | @dataclass 52 | class InterestRateHistoryData(BaseData): 53 | coin: str 54 | historyInterestRateList: List[InterestRateHistory] 55 | 56 | 57 | @dataclass 58 | class InterestRateHistoryResponse(BaseResponse): 59 | data: InterestRateHistoryData 60 | 61 | 62 | @dataclass 63 | class ExchangeRateTier(BaseData): 64 | tier: str 65 | minAmount: str 66 | maxAmount: str 67 | exchangeRate: str 68 | 69 | 70 | @dataclass 71 | class ExchangeRateData(BaseData): 72 | coin: str 73 | exchangeRateList: List[ExchangeRateTier] 74 | 75 | 76 | @dataclass 77 | class ExchangeRateResponse(BaseResponse): 78 | data: List[ExchangeRateData] 79 | 80 | 81 | @dataclass 82 | class DiscountRateTier(BaseData): 83 | tier: str 84 | minAmount: str 85 | maxAmount: str 86 | discountRate: str 87 | 88 | 89 | @dataclass 90 | class DiscountRateData(BaseData): 91 | coin: str 92 | userLimit: str 93 | totalLimit: str 94 | discountRateList: List[DiscountRateTier] 95 | 96 | 97 | @dataclass 98 | class DiscountRateResponse(BaseResponse): 99 | data: List[DiscountRateData] 100 | 101 | 102 | @dataclass 103 | class MarketDepthData(BaseData): 104 | asks: List[List[float]] 105 | bids: List[List[float]] 106 | ts: str 107 | scale: str 108 | precision: str 109 | isMaxPrecision: str 110 | 111 | 112 | @dataclass 113 | class MarketDepthResponse(BaseResponse): 114 | data: MarketDepthData 115 | 116 | 117 | @dataclass 118 | class TickerData(BaseData): 119 | symbol: str 120 | lastPr: str 121 | askPr: str 122 | bidPr: str 123 | bidSz: str 124 | askSz: str 125 | high24h: str 126 | low24h: str 127 | ts: str 128 | change24h: str 129 | baseVolume: str 130 | quoteVolume: str 131 | usdtVolume: str 132 | openUtc: str 133 | changeUtc24h: str 134 | indexPrice: str 135 | fundingRate: str 136 | holdingAmount: str 137 | deliveryStartTime: Optional[str] 138 | deliveryTime: Optional[str] 139 | deliveryStatus: str 140 | open24h: str 141 | markPrice: str 142 | 143 | 144 | @dataclass 145 | class TickerResponse(BaseResponse): 146 | data: List[TickerData] 147 | 148 | 149 | @dataclass 150 | class RecentTransaction(BaseData): 151 | tradeId: str 152 | price: str 153 | size: str 154 | side: str 155 | ts: str 156 | symbol: str 157 | 158 | 159 | @dataclass 160 | class RecentTransactionsResponse(BaseResponse): 161 | data: List[RecentTransaction] 162 | 163 | 164 | @dataclass 165 | class HistoricalTransaction(BaseData): 166 | tradeId: str 167 | price: str 168 | size: str 169 | side: str 170 | ts: str 171 | symbol: str 172 | 173 | 174 | @dataclass 175 | class HistoricalTransactionsResponse(BaseResponse): 176 | data: List[HistoricalTransaction] 177 | 178 | 179 | @dataclass 180 | class CandlestickData(BaseData): 181 | timestamp: str 182 | openPrice: str 183 | highPrice: str 184 | lowPrice: str 185 | closePrice: str 186 | baseVolume: str 187 | quoteVolume: str 188 | 189 | 190 | @dataclass 191 | class CandlestickResponse(BaseResponse): 192 | data: List[CandlestickData] 193 | 194 | 195 | @dataclass 196 | class OpenInterestItem(BaseData): 197 | symbol: str 198 | size: str 199 | 200 | 201 | @dataclass 202 | class OpenInterestData(BaseData): 203 | openInterestList: List[OpenInterestItem] 204 | ts: str 205 | 206 | 207 | @dataclass 208 | class OpenInterestResponse(BaseResponse): 209 | data: OpenInterestData 210 | 211 | 212 | @dataclass 213 | class FundingTimeItem(BaseData): 214 | symbol: str 215 | nextFundingTime: str 216 | ratePeriod: str 217 | 218 | 219 | @dataclass 220 | class FundingTimeResponse(BaseResponse): 221 | data: List[FundingTimeItem] 222 | 223 | 224 | @dataclass 225 | class SymbolPrice(BaseData): 226 | symbol: str 227 | price: str 228 | indexPrice: str 229 | markPrice: str 230 | ts: str 231 | 232 | 233 | @dataclass 234 | class SymbolPriceResponse(BaseResponse): 235 | data: List[SymbolPrice] 236 | 237 | 238 | @dataclass 239 | class HistoricalFundingRate(BaseData): 240 | symbol: str 241 | fundingRate: str 242 | fundingTime: str 243 | 244 | 245 | @dataclass 246 | class HistoricalFundingRateResponse(BaseResponse): 247 | data: List[HistoricalFundingRate] 248 | 249 | 250 | @dataclass 251 | class CurrentFundingRate(BaseData): 252 | symbol: str 253 | fundingRate: str 254 | 255 | 256 | @dataclass 257 | class CurrentFundingRateResponse(BaseResponse): 258 | data: List[CurrentFundingRate] 259 | 260 | 261 | @dataclass 262 | class ContractConfig(BaseData): 263 | symbol: str 264 | baseCoin: str 265 | quoteCoin: str 266 | buyLimitPriceRatio: str 267 | sellLimitPriceRatio: str 268 | feeRateUpRatio: str 269 | makerFeeRate: str 270 | takerFeeRate: str 271 | openCostUpRatio: str 272 | supportMarginCoins: List[str] 273 | minTradeNum: str 274 | priceEndStep: str 275 | volumePlace: str 276 | pricePlace: str 277 | sizeMultiplier: str 278 | symbolType: str 279 | minTradeUSDT: str 280 | maxSymbolOrderNum: str 281 | maxProductOrderNum: str 282 | maxPositionNum: str 283 | symbolStatus: str 284 | offTime: str 285 | limitOpenTime: str 286 | deliveryTime: str 287 | deliveryStartTime: str 288 | launchTime: str 289 | fundInterval: str 290 | minLever: str 291 | maxLever: str 292 | posLimit: str 293 | maintainTime: str 294 | 295 | 296 | @dataclass 297 | class ContractConfigResponse(BaseResponse): 298 | data: List[ContractConfig] 299 | -------------------------------------------------------------------------------- /bitpy/models/position.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import List 3 | from .base import BaseResponse, BaseData 4 | 5 | 6 | @dataclass 7 | class PositionData(BaseData): 8 | marginCoin: str 9 | symbol: str 10 | holdSide: str 11 | openDelegateSize: str 12 | marginSize: str 13 | available: str 14 | locked: str 15 | total: str 16 | leverage: str 17 | achievedProfits: str 18 | openPriceAvg: str 19 | marginMode: str 20 | posMode: str 21 | unrealizedPL: str 22 | liquidationPrice: str 23 | keepMarginRate: str 24 | markPrice: str 25 | breakEvenPrice: str 26 | totalFee: str 27 | deductedFee: str 28 | marginRatio: str 29 | assetMode: str 30 | autoMargin: str 31 | grant: str 32 | takeProfit: str 33 | stopLoss: str 34 | takeProfitId: str 35 | stopLossId: str 36 | ctime: str 37 | utime: str 38 | 39 | 40 | @dataclass 41 | class HistoricalPositionData(BaseData): 42 | positionId: str 43 | marginCoin: str 44 | symbol: str 45 | holdSide: str 46 | openAvgPrice: str 47 | closeAvgPrice: str 48 | marginMode: str 49 | openTotalPos: str 50 | closeTotalPos: str 51 | pnl: str 52 | netProfit: str 53 | totalFunding: str 54 | openFee: str 55 | closeFee: str 56 | ctime: str 57 | utime: str 58 | 59 | 60 | @dataclass 61 | class PositionTierData(BaseData): 62 | symbol: str 63 | level: str 64 | startUnit: str 65 | endUnit: str 66 | leverage: str 67 | keepMarginRate: str 68 | 69 | 70 | @dataclass 71 | class PositionTierResponse(BaseResponse): 72 | data: List[PositionTierData] 73 | 74 | 75 | @dataclass 76 | class HistoricalPositionsResponse(BaseResponse): 77 | data: List[HistoricalPositionData] 78 | 79 | 80 | @dataclass 81 | class AllPositionsResponse(BaseResponse): 82 | data: List[PositionData] 83 | 84 | 85 | @dataclass 86 | class SinglePositionResponse(BaseResponse): 87 | data: List[PositionData] 88 | -------------------------------------------------------------------------------- /bitpy/rest_api.py: -------------------------------------------------------------------------------- 1 | from .clients.position import BitgetPositionClient 2 | from .clients.market import BitgetMarketClient 3 | from .clients.account import BitgetAccountClient 4 | 5 | from .utils.log_manager import LogManager 6 | from .utils.request_handler import RequestHandler 7 | 8 | from .models.login import BitgetCredentials 9 | from typing import Optional 10 | 11 | 12 | class BitgetAPI: 13 | def __init__(self, api_key: Optional[str] = None, secret_key: Optional[str] = None, 14 | api_passphrase: Optional[str] = None, base_url: str = "https://api.bitget.com", 15 | debug: bool = False): 16 | 17 | credentials = BitgetCredentials(api_key, secret_key, api_passphrase) 18 | 19 | request_handler = RequestHandler(base_url, credentials, debug) 20 | logger = LogManager(self, debug) 21 | 22 | self.position = BitgetPositionClient(request_handler, debug) 23 | self.market = BitgetMarketClient(request_handler, debug) 24 | self.account = BitgetAccountClient(request_handler, debug) 25 | 26 | if not all([api_key, secret_key, api_passphrase]): 27 | if debug: 28 | logger.debug("Warning: API initialized without full authentication. Only public endpoints will be " 29 | "available.") -------------------------------------------------------------------------------- /bitpy/utils/log_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | class LogManager: 5 | def __init__(self, instance, debug_mode: bool = False): 6 | self.logger = logging.getLogger(instance.__class__.__name__) 7 | self.debug_mode = debug_mode 8 | self._configure_logger() 9 | 10 | def _configure_logger(self): 11 | if not self.debug_mode: 12 | return 13 | 14 | self.logger.setLevel(logging.DEBUG) 15 | if not self.logger.handlers: 16 | handler = logging.StreamHandler() 17 | handler.setLevel(logging.DEBUG) 18 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 19 | handler.setFormatter(formatter) 20 | self.logger.addHandler(handler) 21 | 22 | def debug(self, message: str): 23 | if self.debug_mode: 24 | self.logger.debug(message) 25 | 26 | def error(self, message: str): 27 | if self.debug_mode: 28 | self.logger.error(message) 29 | 30 | def info(self, message: str): 31 | if self.debug_mode: 32 | self.logger.info(message) 33 | -------------------------------------------------------------------------------- /bitpy/utils/request_handler.py: -------------------------------------------------------------------------------- 1 | from venv import logger 2 | 3 | import requests 4 | import base64 5 | import hmac 6 | from datetime import datetime 7 | from typing import Optional, Dict, Any 8 | from .log_manager import LogManager 9 | from time import time 10 | from collections import defaultdict 11 | from bitpy.exceptions import RequestError 12 | 13 | from ..models.login import BitgetCredentials 14 | 15 | 16 | class RateLimiter: 17 | def __init__(self, logger: LogManager): 18 | self.endpoint_limits = defaultdict(lambda: { 19 | "tokens": None, 20 | "last_update": time(), 21 | "max_tokens": None 22 | }) 23 | self.logger = logger 24 | 25 | def update_limit(self, endpoint: str, remaining_limit: int): 26 | current = time() 27 | limit_info = self.endpoint_limits[endpoint] 28 | old_tokens = limit_info["tokens"] 29 | limit_info["tokens"] = remaining_limit 30 | limit_info["last_update"] = current 31 | 32 | current_max = limit_info.get("max_tokens") 33 | limit_info["max_tokens"] = max(remaining_limit, current_max if current_max is not None else 0) 34 | 35 | self.logger.debug(f"Rate limit updated for {endpoint}: {old_tokens} -> {remaining_limit} tokens") 36 | 37 | def acquire(self, endpoint: str) -> bool: 38 | limit_info = self.endpoint_limits[endpoint] 39 | current = time() 40 | 41 | # If we have a rate limit and it's been more than 1 second since last update 42 | if limit_info["tokens"] is not None: 43 | time_passed = current - limit_info["last_update"] 44 | if time_passed >= 1.0: # Rate limit resets every second 45 | limit_info["tokens"] = limit_info["max_tokens"] 46 | limit_info["last_update"] = current 47 | self.logger.debug(f"Rate limit reset for {endpoint}. New tokens: {limit_info['tokens']}") 48 | return True 49 | 50 | if limit_info["tokens"] <= 0: 51 | self.logger.debug(f"No tokens available for {endpoint}. Time until reset: {1.0 - time_passed:.2f}s") 52 | return False 53 | 54 | return True 55 | 56 | 57 | class RequestHandler: 58 | def __init__(self, base_url: str, credentials: BitgetCredentials, 59 | debug: bool = False): 60 | 61 | self.api_key = credentials.api_key if credentials else None 62 | self.secret_key = credentials.secret_key if credentials else None 63 | self.api_passphrase = credentials.api_passphrase if credentials else None 64 | self.base_url = base_url.rstrip('/') 65 | self.debug = debug 66 | self.session = requests.Session() 67 | self.logger = LogManager(self, debug) 68 | self.rate_limiter = RateLimiter(self.logger) 69 | self.static_headers = { 70 | "Content-Type": "application/json", 71 | "locale": "en-US" 72 | } 73 | self.has_auth = all([self.api_key, self.secret_key, self.api_passphrase]) 74 | 75 | # Only add authentication headers if credentials are provided 76 | if self.has_auth: 77 | self.static_headers.update({ 78 | "ACCESS-KEY": self.api_key, 79 | "ACCESS-PASSPHRASE": self.api_passphrase, 80 | }) 81 | 82 | def _get_headers(self, method: str, request_path: str, query_string: str = "", body: str = "") -> dict: 83 | if not self.has_auth: 84 | logger.debug(f"Authentication credentials required for this endpoint: {request_path}") 85 | raise RequestError("Authentication credentials required for this endpoint") 86 | 87 | timestamp = str(int(datetime.now().timestamp() * 1000)) 88 | signature = self._generate_signature(timestamp, method, request_path, query_string, body) 89 | 90 | headers = self.static_headers.copy() 91 | headers.update({ 92 | "ACCESS-SIGN": signature, 93 | "ACCESS-TIMESTAMP": timestamp 94 | }) 95 | return headers 96 | 97 | def _generate_signature(self, timestamp: str, method: str, request_path: str, query_string: str = "", 98 | body: str = "") -> str: 99 | message = timestamp + method.upper() + request_path 100 | if query_string: 101 | message += "?" + query_string 102 | if body: 103 | message += body 104 | 105 | signature = base64.b64encode( 106 | hmac.new( 107 | self.secret_key.encode('utf-8'), 108 | message.encode('utf-8'), 109 | digestmod='sha256' 110 | ).digest() 111 | ).decode('utf-8') 112 | return signature 113 | 114 | def request(self, method: str, endpoint: str, params: Optional[Dict[str, Any]] = None, 115 | authenticate: bool = True) -> Dict: 116 | query_string = "&".join(f"{k}={v}" for k, v in sorted(params.items())) if params else "" 117 | if authenticate: 118 | headers = self._get_headers(method, endpoint, query_string) 119 | else: 120 | headers = self.static_headers 121 | 122 | url = f"{self.base_url}{endpoint}" 123 | if query_string: 124 | url += f"?{query_string}" 125 | 126 | while True: 127 | self.logger.debug(f"Checking rate limit for endpoint: {endpoint}") 128 | if self.rate_limiter.acquire(endpoint): 129 | try: 130 | self.logger.debug("Rate limit check passed, sending request") 131 | response = self.session.request(method, url, headers=headers) 132 | self.logger.debug(f"Response status code: {response.status_code}") 133 | self.logger.debug(f"Response headers: {response.headers}") 134 | 135 | remaining_limit = int(response.headers.get('x-mbx-used-remain-limit', 0)) 136 | self.logger.debug(f"Remaining rate limit: {remaining_limit}") 137 | self.rate_limiter.update_limit(endpoint, remaining_limit) 138 | 139 | if response.status_code == 429: 140 | self.logger.debug("Rate limit (429) response received, retrying") 141 | self.rate_limiter.update_limit(endpoint, 0) 142 | continue 143 | 144 | self.logger.debug(f"Response content: {response.content}") 145 | response_data = response.json() 146 | self.logger.debug(f"Response data: {response_data}") 147 | 148 | if response_data.get("code") != "00000": 149 | raise RequestError(response_data.get("msg", "Unknown error")) 150 | 151 | return response_data 152 | 153 | except requests.exceptions.RequestException as e: 154 | self.logger.error(f"Request failed: {str(e)}") 155 | self.logger.error(f"Full error details: {e.__class__.__name__}: {str(e)}") 156 | raise 157 | -------------------------------------------------------------------------------- /bitpy/ws_api.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from .clients.ws import BitgetWebsocketClient 3 | 4 | from .models.login import BitgetCredentials 5 | 6 | from .utils.log_manager import LogManager 7 | 8 | 9 | class BitgetWebsocketAPI: 10 | def __init__(self, api_key: Optional[str] = None, secret_key: Optional[str] = None, 11 | api_passphrase: Optional[str] = None, is_private: bool = False, debug: bool = False): 12 | credentials = BitgetCredentials(api_key, secret_key, api_passphrase) 13 | if is_private: 14 | print("Private API is not supported yet") 15 | return 16 | 17 | self.websocket = BitgetWebsocketClient(credentials, is_private, debug) 18 | 19 | logger = LogManager(self, debug) 20 | 21 | if not all([api_key, secret_key, api_passphrase]): 22 | if debug: 23 | logger.debug("Warning: Websocket API initialized without full authentication. Only public endpoints will be " 24 | "available.") -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=42"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "bitget-python" 7 | version = "1.0.4" 8 | description = "A Python client for the Bitget API with comprehensive position management" 9 | authors = [ 10 | {name = "Tentoxa"} 11 | ] 12 | requires-python = ">=3.9" 13 | dependencies = [ 14 | "requests>=2.32.2", 15 | "websockets>=14.1", 16 | ] 17 | 18 | 19 | readme = "README.md" 20 | license = {text = "MIT"} 21 | classifiers = [ 22 | "Development Status :: 5 - Production/Stable", 23 | "Intended Audience :: Developers", 24 | "License :: OSI Approved :: MIT License", 25 | "Programming Language :: Python :: 3", 26 | "Programming Language :: Python :: 3.9", 27 | "Programming Language :: Python :: 3.10", 28 | "Programming Language :: Python :: 3.11", 29 | "Topic :: Software Development :: Libraries :: Python Modules", 30 | ] 31 | keywords = ["bitget", "api", "trading", "cryptocurrency"] 32 | 33 | [project.urls] 34 | Homepage = "https://github.com/Tentoxa/bitpy" 35 | Repository = "https://github.com/Tentoxa/bitpy" 36 | Documentation = "https://github.com/Tentoxa/bitpy#readme" 37 | --------------------------------------------------------------------------------