├── .github └── workflows │ └── runtests.yml ├── .gitignore ├── .readthedocs.yaml ├── CHANGELOG.md ├── DEVELOPERS.md ├── LICENSE ├── README.md ├── basana ├── __init__.py ├── backtesting │ ├── __init__.py │ ├── account_balances.py │ ├── charts.py │ ├── config.py │ ├── errors.py │ ├── exchange.py │ ├── fees.py │ ├── helpers.py │ ├── lending │ │ ├── __init__.py │ │ ├── base.py │ │ └── margin.py │ ├── liquidity.py │ ├── loan_mgr.py │ ├── order_mgr.py │ ├── orders.py │ ├── prices.py │ ├── requests.py │ └── value_map.py ├── core │ ├── __init__.py │ ├── bar.py │ ├── config.py │ ├── dispatcher.py │ ├── dt.py │ ├── enums.py │ ├── errors.py │ ├── event.py │ ├── event_sources │ │ ├── __init__.py │ │ ├── csv.py │ │ └── trading_signal.py │ ├── helpers.py │ ├── logs.py │ ├── pair.py │ ├── token_bucket.py │ └── websockets.py └── external │ ├── __init__.py │ ├── binance │ ├── __init__.py │ ├── client │ │ ├── __init__.py │ │ ├── base.py │ │ ├── margin.py │ │ └── spot.py │ ├── common.py │ ├── config.py │ ├── cross_margin.py │ ├── csv │ │ ├── __init__.py │ │ └── bars.py │ ├── exchange.py │ ├── helpers.py │ ├── isolated_margin.py │ ├── klines.py │ ├── margin.py │ ├── margin_requests.py │ ├── order_book.py │ ├── spot.py │ ├── spot_requests.py │ ├── tools │ │ ├── __init__.py │ │ └── download_bars.py │ ├── trades.py │ ├── user_data.py │ ├── websocket_mgr.py │ └── websockets.py │ ├── bitstamp │ ├── __init__.py │ ├── client.py │ ├── config.py │ ├── csv │ │ ├── __init__.py │ │ └── bars.py │ ├── exchange.py │ ├── helpers.py │ ├── order_book.py │ ├── orders.py │ ├── requests.py │ ├── tools │ │ ├── __init__.py │ │ └── download_bars.py │ ├── trades.py │ └── websockets.py │ ├── common │ ├── __init__.py │ └── csv │ │ ├── __init__.py │ │ └── bars.py │ └── yahoo │ ├── __init__.py │ └── bars.py ├── docs ├── Makefile ├── _static │ ├── backtesting_bbands.png │ └── readme_pairs_trading.png ├── api.rst ├── backtesting_charts.rst ├── backtesting_exchange.rst ├── backtesting_fees.rst ├── backtesting_lending.rst ├── backtesting_liquidity.rst ├── basana.rst ├── binance_cross.rst ├── binance_exchange.rst ├── binance_isolated.rst ├── binance_margin.rst ├── binance_order_book.rst ├── binance_spot.rst ├── binance_trades.rst ├── binance_user_data.rst ├── bitstamp_exchange.rst ├── bitstamp_order_book.rst ├── bitstamp_orders.rst ├── bitstamp_trades.rst ├── conf.py ├── help.rst ├── index.rst ├── make.bat └── quickstart.rst ├── poetry.lock ├── pyproject.toml ├── samples ├── __init__.py ├── backtest_bbands.py ├── backtest_pairs_trading.py ├── backtest_rsi.py ├── backtest_sma.py ├── backtesting │ ├── __init__.py │ └── position_manager.py ├── binance │ ├── __init__.py │ └── position_manager.py ├── binance_bbands.py ├── binance_websockets.py ├── bitstamp_websockets.py └── strategies │ ├── __init__.py │ ├── bbands.py │ ├── dmac.py │ ├── pairs_trading.py │ ├── rsi.py │ └── sma.py ├── setup.cfg ├── tasks.py └── tests ├── __init__.py ├── conftest.py ├── data ├── binance_btc_usdt_exchange_info.json ├── binance_btcusdt_day_2020.csv ├── binance_cross_margin_account_details.json ├── binance_isolated_margin_account_details.json ├── bitstamp_btcusd_day_2015.csv ├── bitstamp_btcusd_day_2015.csv.utf16 ├── bitstamp_btcusd_min_2020_01_01.csv ├── orcl-2000-yahoo-sorted.csv ├── orcl-2000-yahoo.csv └── orcl-2001-yahoo.csv ├── fixtures ├── __init__.py ├── binance.py ├── bitstamp.py └── dispatcher.py ├── helpers.py ├── test_backtesting_account_balances.py ├── test_backtesting_charts.py ├── test_backtesting_config.py ├── test_backtesting_exchange.py ├── test_backtesting_exchange_auto_lending.py ├── test_backtesting_fees.py ├── test_backtesting_liquidity.py ├── test_backtesting_loans.py ├── test_backtesting_orders.py ├── test_backtesting_prices.py ├── test_backtesting_value_map.py ├── test_bar.py ├── test_binance_bars.py ├── test_binance_client.py ├── test_binance_csv_bars.py ├── test_binance_exchange.py ├── test_binance_exchange_cross_margin.py ├── test_binance_exchange_isolated_margin.py ├── test_binance_exchange_spot.py ├── test_binance_order_book.py ├── test_binance_tools.py ├── test_binance_trades.py ├── test_binance_user_data.py ├── test_bitstamp_bars.py ├── test_bitstamp_client.py ├── test_bitstamp_csv_bars.py ├── test_bitstamp_exchange.py ├── test_bitstamp_order_book.py ├── test_bitstamp_orders.py ├── test_bitstamp_tools.py ├── test_bitstamp_trades.py ├── test_config.py ├── test_core_helpers.py ├── test_core_websockets.py ├── test_dispatcher.py ├── test_enums.py ├── test_event.py ├── test_pair.py ├── test_samples_backtesting_pos_info.py ├── test_token_bucket.py ├── test_trading_signal.py └── test_yahoo.py /.github/workflows/runtests.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a variety of Python versions 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Run testcases 5 | 6 | on: 7 | push: 8 | branches: 9 | - master 10 | - develop 11 | - release/* 12 | - feature/* 13 | - hotfix/* 14 | - bugfix/* 15 | - issues/* 16 | pull_request: 17 | branches: 18 | - master 19 | - develop 20 | 21 | jobs: 22 | build: 23 | 24 | timeout-minutes: 15 25 | strategy: 26 | fail-fast: false 27 | matrix: 28 | python-version: ["3.9", "3.10", "3.11", "3.12"] 29 | # windows-latest is not enabled due to static image export hanging when using kaleido: https://github.com/plotly/Kaleido/issues/126 30 | os: [ubuntu-latest, macos-latest] 31 | 32 | runs-on: ${{ matrix.os }} 33 | name: Test on ${{ matrix.os }} with Python ${{ matrix.python-version }}. 34 | 35 | steps: 36 | - uses: actions/checkout@v4 37 | - name: Set up Python ${{ matrix.python-version }} 38 | uses: actions/setup-python@v5 39 | with: 40 | python-version: ${{ matrix.python-version }} 41 | - name: Update system 42 | if: ${{ matrix.os == 'ubuntu-latest' }} 43 | run: | 44 | sudo apt-get update 45 | - name: Install dependencies 46 | run: | 47 | python -m pip install --upgrade pip 48 | pip install poetry 49 | - name: Initialize the virtual environment. 50 | run: | 51 | poetry install --no-root --all-extras --without=docs 52 | - name: Static checks 53 | run: | 54 | poetry run -- mypy basana 55 | poetry run -- flake8 56 | - name: Run testcases 57 | run: | 58 | poetry run pytest -vv --cov --cov-config=setup.cfg --durations=10 59 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | .idea 3 | .venv/* 4 | .vscode 5 | __pycache__ 6 | cov_html 7 | data/* 8 | dist/* 9 | docs/_build 10 | docs/generated 11 | pocs/* 12 | venv/* 13 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-22.04" 5 | tools: 6 | python: "3.10" 7 | jobs: 8 | post_create_environment: 9 | # Install poetry 10 | # https://python-poetry.org/docs/#installing-manually 11 | - pip install poetry 12 | # Build and install basana. 13 | - poetry build 14 | - pip install dist/basana-`poetry version -s`.tar.gz[charts] 15 | 16 | sphinx: 17 | configuration: docs/conf.py 18 | fail_on_warning: true 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## TBD 4 | 5 | ### Features 6 | 7 | * `backtesting.exchange.OrderInfo` now includes pair and order fills. 8 | 9 | ## 1.7.1 10 | 11 | ### Bug fixes 12 | 13 | * No event was generated when a backtesting order got canceled. 14 | 15 | ## 1.7 16 | 17 | ### Features 18 | 19 | * Added support for Binance order and user data events via websockets. 20 | * Added support for backtesting order events. 21 | * Added loan ids to order info. 22 | 23 | ### Bug fixes 24 | 25 | * `bitstamp.orders.Order.amount` was returning the order amount left to be executed instead of the original amount. 26 | 27 | ### Misc 28 | 29 | * Updated dependencies and minimum Python version. 30 | 31 | ## 1.6.2 32 | 33 | ### Bug fixes 34 | 35 | * `VolumeShareImpact.calculate_price` and `VolumeShareImpact.calculate_amount` were failing when there was no available liquidity. 36 | 37 | ## 1.6.1 38 | 39 | ### Bug fixes 40 | 41 | * Ignore events scheduled after the last event is procesed. 42 | 43 | ## 1.6 44 | 45 | ### Features 46 | 47 | * Support for borrowing funds and opening short positions with the backtesting exchange. 48 | 49 | ### Bug fixes 50 | 51 | * [Parse BOM when opening CSV files and set the encoding appropriately](https://github.com/gbeced/basana/issues/36) 52 | * [Stop subtracting microseconds in RowParser](https://github.com/gbeced/basana/issues/37) 53 | 54 | ## 1.5.0 55 | 56 | * `backtesting.fees.Percentage` now supports a minimum fee. 57 | 58 | ## 1.4.1 59 | 60 | * Bug fix. Capture and log unhandled exceptions in scheduled jobs. 61 | * Bug fix. Rounding in charts. 62 | 63 | ## 1.4.0 64 | 65 | * Support for scheduling functions that will be executed at a given date and time. 66 | * Improved dispatcher concurrency, specially for the realtime dispatcher. 67 | 68 | ## 1.3.2 69 | 70 | * Bug fix in download_bars. 71 | 72 | ## 1.3.1 73 | 74 | * Updated docs. 75 | * Bug fix in samples. 76 | * Bug fix in charts. 77 | 78 | ## 1.3.0 79 | 80 | * Updates to Binance Bar events. 81 | * Updated docs. 82 | * Bug fix in samples. 83 | * Support for charts. 84 | 85 | ## 1.2.2 86 | 87 | * Updated docs. 88 | * Bug fix in signal handling under Win32. 89 | 90 | ## 1.2.1 91 | 92 | * Bug fix in datetime management when dealing with Bar events. 93 | 94 | ## 1.2.0 95 | 96 | * Updated docs. 97 | 98 | ## 1.1.0 99 | 100 | * Bug fix in samples. 101 | * Added tool to download bars from Binance. 102 | 103 | ## 1.0.0 104 | -------------------------------------------------------------------------------- /DEVELOPERS.md: -------------------------------------------------------------------------------- 1 | # For Developers 2 | 3 | ## Requirements 4 | 5 | * Python 3.9 or greater. 6 | * [Poetry](https://python-poetry.org/) for dependency and package management. 7 | * Optionally, [Invoke](https://www.pyinvoke.org/). 8 | 9 | ## Environment setup and testing 10 | 11 | ### Using Poetry 12 | 13 | 1. Initialize the virtual environment and install dependencies. 14 | 15 | ``` 16 | $ poetry install --all-extras 17 | ``` 18 | 19 | 1. Static checks 20 | 21 | ``` 22 | $ poetry run -- mypy basana 23 | $ poetry run -- flake8 24 | ``` 25 | 26 | 1. Execute testcases 27 | 28 | ``` 29 | $ poetry run pytest -vv --cov --cov-config=setup.cfg --durations=10 30 | ``` 31 | 32 | ### Using Invoke 33 | 34 | Instead of running those commands manually, a couple of Invoke tasks are provided to wrap those. 35 | 36 | 1. Initialize the virtual environment and install dependencies. 37 | 38 | ``` 39 | $ inv create-virtualenv 40 | ``` 41 | 42 | 1. Execute static checks and testcases 43 | 44 | ``` 45 | $ inv test 46 | ``` 47 | 48 | ## Building docs 49 | 50 | ``` 51 | $ inv build-docs 52 | ``` 53 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Basana 2 | 3 | Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); 6 | you may not use this file except in compliance with the License. 7 | You may obtain a copy of the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, 13 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | See the License for the specific language governing permissions and 15 | limitations under the License. 16 | 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Testcases](https://github.com/gbeced/basana/actions/workflows/runtests.yml/badge.svg?branch=master)](https://github.com/gbeced/basana/actions/workflows/runtests.yml) 2 | [![PyPI version](https://badge.fury.io/py/basana.svg)](https://badge.fury.io/py/basana) 3 | [![Read The Docs](https://readthedocs.org/projects/basana/badge/?version=latest)](https://basana.readthedocs.io/en/latest/) 4 | [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) 5 | [![Downloads](https://static.pepy.tech/badge/basana/month)](https://pepy.tech/project/basana) 6 | 7 | # Basana 8 | 9 | **Basana** is a Python **async and event driven** framework for **algorithmic trading**, with a focus on **crypto currencies**. 10 | 11 | ## Key Features 12 | 13 | * Backtesting exchange so you can try your trading strategies before using real funds. 14 | * Live trading at [Binance](https://www.binance.com/) and [Bitstamp](https://www.bitstamp.net/) crypto currency exchanges. 15 | * Asynchronous I/O and event driven. 16 | 17 | ## Getting Started 18 | 19 | ### Installation 20 | 21 | The examples use [TALIpp](https://github.com/nardew/talipp) for the technical indicators, pandas and statsmodels. 22 | 23 | ``` 24 | $ pip install basana[charts] talipp pandas statsmodels 25 | ``` 26 | 27 | ### Backtest a pairs trading strategy 28 | 29 | 1. Download and unzip [samples](https://github.com/gbeced/basana/releases/download/1.7/samples.zip). 30 | 31 | 2. Download historical data for backtesting 32 | 33 | ``` 34 | $ python -m basana.external.binance.tools.download_bars -c BCH/USDT -p 1h -s 2021-12-01 -e 2021-12-26 -o binance_bchusdt_hourly.csv 35 | $ python -m basana.external.binance.tools.download_bars -c CVC/USDT -p 1h -s 2021-12-01 -e 2021-12-26 -o binance_cvcusdt_hourly.csv 36 | ``` 37 | 38 | 3. Run the backtest 39 | 40 | ``` 41 | $ python -m samples.backtest_pairs_trading 42 | ``` 43 | 44 | ![./docs/_static/readme_pairs_trading.png](./docs/_static/readme_pairs_trading.png) 45 | 46 | The Basana repository comes with a number of [examples](./samples) you can experiment with or use as a template for your own projects: 47 | 48 | **Note that these examples are provided for educational purposes only. Use at your own risk.** 49 | 50 | ## Documentation 51 | 52 | [https://basana.readthedocs.io/en/latest/](https://basana.readthedocs.io/en/latest/) 53 | 54 | ## Help 55 | 56 | You can seek help with using Basana in the discussion area on [GitHub](https://github.com/gbeced/basana/discussions). 57 | -------------------------------------------------------------------------------- /basana/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # flake8: noqa 18 | 19 | from .core.bar import ( 20 | Bar, 21 | BarEvent, 22 | ) 23 | 24 | from .core.dispatcher import ( 25 | EventDispatcher, 26 | BacktestingDispatcher, 27 | RealtimeDispatcher, 28 | backtesting_dispatcher, 29 | realtime_dispatcher, 30 | ) 31 | 32 | from .core.dt import ( 33 | local_now, 34 | utc_now, 35 | ) 36 | 37 | from .core.event import ( 38 | Event, 39 | EventSource, 40 | FifoQueueEventSource, 41 | Producer, 42 | ) 43 | 44 | from .core.event_sources.trading_signal import ( 45 | TradingSignal, 46 | TradingSignalSource, 47 | ) 48 | 49 | from .core.enums import ( 50 | OrderOperation, 51 | Position, 52 | ) 53 | 54 | from .core.helpers import ( 55 | round_decimal, 56 | truncate_decimal, 57 | ) 58 | 59 | from .core.pair import ( 60 | Pair, 61 | PairInfo, 62 | ) 63 | 64 | from .core.token_bucket import ( 65 | TokenBucketLimiter, 66 | ) 67 | 68 | -------------------------------------------------------------------------------- /basana/backtesting/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /basana/backtesting/config.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from typing import Dict, Optional 18 | import dataclasses 19 | 20 | from basana.backtesting import errors 21 | from basana.core.pair import Pair, PairInfo 22 | 23 | 24 | @dataclasses.dataclass(frozen=True) 25 | class SymbolInfo: 26 | precision: int 27 | 28 | 29 | class Config: 30 | def __init__(self, default_symbol_info: Optional[SymbolInfo] = None, default_pair_info: Optional[PairInfo] = None): 31 | self._symbol_info: Dict[str, SymbolInfo] = {} 32 | self._default_symbol_info = default_symbol_info 33 | self._pair_info: Dict[Pair, PairInfo] = {} 34 | self._default_pair_info = default_pair_info 35 | 36 | def set_pair_info(self, pair: Pair, pair_info: PairInfo): 37 | self._pair_info[pair] = pair_info 38 | 39 | def get_pair_info(self, pair: Pair) -> PairInfo: 40 | ret = self._pair_info.get(pair) 41 | 42 | # If we don't have config for this specific pair we'll try to build it using the config for the individual 43 | # symbols. 44 | if ret is None: 45 | base_symbol_config = self._symbol_info.get(pair.base_symbol) 46 | quote_symbol_config = self._symbol_info.get(pair.quote_symbol) 47 | if base_symbol_config and quote_symbol_config: 48 | ret = PairInfo( 49 | base_precision=base_symbol_config.precision, quote_precision=quote_symbol_config.precision 50 | ) 51 | # Default pair info, if set, is the last option. 52 | if ret is None: 53 | ret = self._default_pair_info 54 | 55 | if ret is None: 56 | raise errors.Error(f"No config for {pair}") 57 | return ret 58 | 59 | def set_symbol_info(self, symbol: str, symbol_info: SymbolInfo): 60 | self._symbol_info[symbol] = symbol_info 61 | 62 | def get_symbol_info(self, symbol: str) -> SymbolInfo: 63 | ret = self._symbol_info.get(symbol, self._default_symbol_info) 64 | if ret is None: 65 | raise errors.Error(f"No config for {symbol}") 66 | return ret 67 | -------------------------------------------------------------------------------- /basana/backtesting/errors.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from basana.core import errors 18 | 19 | 20 | class Error(errors.Error): 21 | """Base class for backtesting exceptions.""" 22 | pass 23 | 24 | 25 | class NotEnoughBalance(Error): 26 | """Not enough balance.""" 27 | pass 28 | 29 | 30 | class NotFound(Error): 31 | pass 32 | 33 | 34 | class NoPrice(Error): 35 | pass 36 | -------------------------------------------------------------------------------- /basana/backtesting/fees.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | from typing import Dict 19 | import abc 20 | 21 | from . import orders 22 | 23 | 24 | class FeeStrategy(metaclass=abc.ABCMeta): 25 | """Base class for strategies that model fee schemes. 26 | 27 | .. note:: 28 | 29 | * This is a base class and should not be used directly. 30 | """ 31 | 32 | @abc.abstractmethod 33 | def calculate_fees( 34 | self, order: orders.Order, balance_updates: Dict[str, Decimal] 35 | ) -> Dict[str, Decimal]: 36 | raise NotImplementedError() 37 | 38 | 39 | class NoFee(FeeStrategy): 40 | """This strategy applies no fees to the trades.""" 41 | 42 | def calculate_fees(self, order: orders.Order, balance_updates: Dict[str, Decimal]) -> Dict[str, Decimal]: 43 | return {} 44 | 45 | 46 | class Percentage(FeeStrategy): 47 | """This strategy applies a fixed percentage per trade, in quote currency. 48 | 49 | :param percentage: The percentage to apply. 50 | :param min_fee: Minimum fee amount, in quote currency. 51 | """ 52 | 53 | def __init__(self, percentage: Decimal, min_fee: Decimal = Decimal(0)): 54 | assert percentage >= 0 and percentage < 100, f"Invalid percentage {percentage}" 55 | assert min_fee >= 0, f"Minimum fee cannot be negative {min_fee}" 56 | self._percentage = percentage 57 | self._min_fee = min_fee 58 | 59 | def calculate_fees(self, order: orders.Order, balance_updates: Dict[str, Decimal]) -> Dict[str, Decimal]: 60 | ret = {} 61 | 62 | # Fees are always charged in quote amount. 63 | symbol = order.pair.quote_symbol 64 | 65 | # Rounding may have taken place in previous fills, so fees may have been overcharged. For that reason we 66 | # calculate the total fees to charge, and subtract what we have charged so far. 67 | charged_fee_amount = order.fees.get(symbol, Decimal(0)) 68 | assert charged_fee_amount <= Decimal(0), "Fees should always be negative" 69 | total_quote_amount = order.balance_updates.get(symbol, Decimal(0)) + balance_updates.get(symbol, Decimal(0)) 70 | total_fee_amount = -max(abs(total_quote_amount) * self._percentage / Decimal(100), self._min_fee) 71 | pending_fee = total_fee_amount - charged_fee_amount 72 | if pending_fee < Decimal(0): 73 | ret[symbol] = pending_fee 74 | 75 | return ret 76 | -------------------------------------------------------------------------------- /basana/backtesting/helpers.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | from typing import Dict, Generator, Generic, Iterable, List, Optional, Protocol, TypeVar 19 | 20 | from basana.core.enums import OrderOperation 21 | 22 | 23 | def get_base_sign_for_operation(operation: OrderOperation) -> Decimal: 24 | if operation == OrderOperation.BUY: 25 | base_sign = Decimal(1) 26 | else: 27 | assert operation == OrderOperation.SELL 28 | base_sign = Decimal(-1) 29 | return base_sign 30 | 31 | 32 | class ExchangeObjectProto(Protocol): 33 | @property 34 | def id(self) -> str: # pragma: no cover 35 | ... 36 | 37 | @property 38 | def is_open(self) -> bool: # pragma: no cover 39 | ... 40 | 41 | 42 | TExchangeObject = TypeVar('TExchangeObject', bound=ExchangeObjectProto) 43 | 44 | 45 | class ExchangeObjectContainer(Generic[TExchangeObject]): 46 | def __init__(self): 47 | self._items: Dict[str, TExchangeObject] = {} # Items by id. 48 | self._open_items: List[TExchangeObject] = [] 49 | self._reindex_every = 50 50 | self._reindex_counter = 0 51 | 52 | def add(self, item: TExchangeObject): 53 | assert item.id not in self._items 54 | self._items[item.id] = item 55 | if item.is_open: 56 | self._open_items.append(item) 57 | 58 | def get(self, id: str) -> Optional[TExchangeObject]: 59 | return self._items.get(id) 60 | 61 | def get_open(self) -> Generator[TExchangeObject, None, None]: 62 | self._reindex_counter += 1 63 | new_open_items: Optional[List[TExchangeObject]] = None 64 | if self._reindex_counter % self._reindex_every == 0: 65 | new_open_items = [] 66 | 67 | for item in self._open_items: 68 | if item.is_open: 69 | yield item 70 | if new_open_items is not None and item.is_open: 71 | new_open_items.append(item) 72 | 73 | if new_open_items is not None: 74 | self._open_items = new_open_items 75 | 76 | def get_all(self) -> Iterable[TExchangeObject]: 77 | return self._items.values() 78 | -------------------------------------------------------------------------------- /basana/backtesting/lending/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # flake8: noqa 18 | 19 | from .base import ( 20 | LendingStrategy, 21 | LoanInfo, 22 | NoLoans, 23 | ) 24 | 25 | 26 | from .margin import ( 27 | MarginLoanConditions, 28 | MarginLoans, 29 | ) 30 | -------------------------------------------------------------------------------- /basana/backtesting/lending/base.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | from typing import Dict 19 | import abc 20 | import dataclasses 21 | import datetime 22 | 23 | from basana.backtesting import account_balances, config, errors, prices 24 | from basana.backtesting.value_map import ValueMap, ValueMapDict 25 | from basana.core import dispatcher 26 | 27 | 28 | @dataclasses.dataclass 29 | class LoanInfo: 30 | #: The loan id. 31 | id: str 32 | #: True if the loan is open, False otherwise. 33 | is_open: bool 34 | #: The symbol being borrowed. 35 | borrowed_symbol: str 36 | #: The amount being borrowed. 37 | borrowed_amount: Decimal 38 | #: The outstanding interest. Only valid for open loans. 39 | outstanding_interest: Dict[str, Decimal] 40 | #: The paid interest. Only valid for closed loans. 41 | paid_interest: Dict[str, Decimal] 42 | 43 | 44 | class Loan(metaclass=abc.ABCMeta): 45 | def __init__( 46 | self, id: str, borrowed_symbol: str, borrowed_amount: Decimal, created_at: datetime.datetime 47 | ): 48 | assert borrowed_amount > Decimal(0), f"Invalid amount {borrowed_amount}" 49 | 50 | self._id = id 51 | self._borrowed_symbol = borrowed_symbol 52 | self._borrowed_amount = borrowed_amount 53 | self._is_open = True 54 | self._created_at = created_at 55 | self._paid_interest = ValueMap() 56 | 57 | @property 58 | def id(self) -> str: 59 | return self._id 60 | 61 | @property 62 | def is_open(self) -> bool: 63 | return self._is_open 64 | 65 | @property 66 | def borrowed_symbol(self) -> str: 67 | return self._borrowed_symbol 68 | 69 | @property 70 | def borrowed_amount(self) -> Decimal: 71 | return self._borrowed_amount 72 | 73 | @property 74 | def created_at(self) -> datetime.datetime: 75 | return self._created_at 76 | 77 | @property 78 | def paid_interest(self) -> ValueMapDict: 79 | return self._paid_interest 80 | 81 | def close(self): 82 | assert self._is_open 83 | self._is_open = False 84 | 85 | def add_paid_interest(self, interest: ValueMapDict): 86 | self._paid_interest += interest 87 | 88 | @abc.abstractmethod 89 | def calculate_interest(self, at: datetime.datetime, prices: prices.Prices) -> ValueMapDict: 90 | raise NotImplementedError() 91 | 92 | @abc.abstractmethod 93 | def calculate_collateral(self, prices: prices.Prices) -> ValueMapDict: 94 | raise NotImplementedError() 95 | 96 | 97 | @dataclasses.dataclass 98 | class ExchangeContext: 99 | dispatcher: dispatcher.BacktestingDispatcher 100 | account_balances: account_balances.AccountBalances 101 | prices: prices.Prices 102 | config: config.Config 103 | 104 | 105 | class LendingStrategy(metaclass=abc.ABCMeta): 106 | """ 107 | Base class for lending strategies. 108 | """ 109 | 110 | def set_exchange_context(self, loan_mgr, exchange_context: ExchangeContext): 111 | """ 112 | This method will be called during exchange initialization to give lending strategies a chance to later 113 | use those services. 114 | """ 115 | pass 116 | 117 | @abc.abstractmethod 118 | def create_loan(self, symbol: str, amount: Decimal, created_at: datetime.datetime) -> Loan: 119 | raise NotImplementedError() 120 | 121 | 122 | class NoLoans(LendingStrategy): 123 | """ 124 | Lending not supported. 125 | """ 126 | 127 | def create_loan(self, symbol: str, amount: Decimal, created_at: datetime.datetime) -> Loan: 128 | raise errors.Error("Lending is not supported") 129 | -------------------------------------------------------------------------------- /basana/backtesting/prices.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | from typing import Dict, Tuple 19 | 20 | from basana.backtesting import config, errors, value_map 21 | from basana.core import helpers as core_helpers 22 | from basana.core.bar import Bar, BarEvent 23 | from basana.core.pair import Pair 24 | 25 | 26 | class Prices: 27 | def __init__(self, bid_ask_spread_pct: Decimal, config: config.Config): 28 | assert bid_ask_spread_pct > Decimal(0) 29 | 30 | self._bid_ask_spread_pct = bid_ask_spread_pct 31 | self._config = config 32 | self._last_bars: Dict[Pair, Bar] = {} 33 | 34 | def on_bar_event(self, event: BarEvent): 35 | self._last_bars[event.bar.pair] = event.bar 36 | 37 | def get_bid_ask(self, pair: Pair) -> Tuple[Decimal, Decimal]: 38 | last_bar = self._last_bars.get(pair) 39 | if not last_bar: 40 | raise errors.NoPrice(f"No price for {pair}") 41 | 42 | last_price = last_bar.close 43 | pair_info = self._config.get_pair_info(pair) 44 | half_spread = core_helpers.truncate_decimal( 45 | (last_price * self._bid_ask_spread_pct / Decimal("100")) / Decimal(2), 46 | pair_info.quote_precision 47 | ) 48 | bid = last_price - half_spread 49 | ask = last_price + half_spread 50 | return bid, ask 51 | 52 | def get_price(self, pair: Pair) -> Decimal: 53 | last_bar = self._last_bars.get(pair) 54 | if not last_bar: 55 | raise errors.NoPrice(f"No price for {pair}") 56 | return last_bar.close 57 | 58 | def convert(self, amount: Decimal, from_symbol: str, to_symbol: str) -> Decimal: 59 | if amount == Decimal(0): 60 | return Decimal(0) 61 | 62 | for pair, price_fun in [ 63 | (Pair(from_symbol, to_symbol), lambda price: price), 64 | (Pair(to_symbol, from_symbol), lambda price: Decimal(1) / price), 65 | ]: 66 | last_bar = self._last_bars.get(pair) 67 | if last_bar: 68 | return amount * price_fun(last_bar.close) 69 | raise errors.NoPrice(f"No price to convert from {from_symbol} to {to_symbol}") 70 | 71 | def convert_value_map(self, values: value_map.ValueMapDict, to_symbol: str) -> Decimal: 72 | ret = Decimal(0) 73 | for symbol, value in values.items(): 74 | if symbol != to_symbol: 75 | value = self.convert(value, symbol, to_symbol) 76 | ret += value 77 | return ret 78 | -------------------------------------------------------------------------------- /basana/backtesting/value_map.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | from typing import Dict 19 | import itertools 20 | 21 | 22 | from basana.backtesting import config 23 | from basana.core import helpers 24 | 25 | 26 | ZERO = Decimal(0) 27 | ValueMapDict = Dict[str, Decimal] 28 | 29 | 30 | class ValueMap(ValueMapDict): 31 | def prune(self): 32 | keys = [key for key, value in self.items() if not value] 33 | for key in keys: 34 | del self[key] 35 | 36 | def truncate(self, config: config.Config): 37 | for symbol, amount in self.items(): 38 | symbol_info = config.get_symbol_info(symbol) 39 | self[symbol] = helpers.truncate_decimal(amount, symbol_info.precision) 40 | 41 | def __add__(self, other: ValueMapDict) -> "ValueMap": 42 | keys = set(itertools.chain(self.keys(), other.keys())) 43 | return ValueMap({key: self.get(key, ZERO) + other.get(key, ZERO) for key in keys}) 44 | 45 | def __iadd__(self, other: ValueMapDict) -> "ValueMap": 46 | keys = set(itertools.chain(self.keys(), other.keys())) 47 | for key in keys: 48 | self[key] = self.get(key, ZERO) + other.get(key, ZERO) 49 | return self 50 | 51 | def __radd__(self, other: ValueMapDict) -> "ValueMap": 52 | return self.__add__(other) 53 | 54 | def __sub__(self, other: ValueMapDict) -> "ValueMap": 55 | keys = set(itertools.chain(self.keys(), other.keys())) 56 | return ValueMap({key: self.get(key, ZERO) - other.get(key, ZERO) for key in keys}) 57 | 58 | def __isub__(self, other: ValueMapDict) -> "ValueMap": 59 | keys = set(itertools.chain(self.keys(), other.keys())) 60 | for key in keys: 61 | self[key] = self.get(key, ZERO) - other.get(key, ZERO) 62 | return self 63 | 64 | def __rsub__(self, other: ValueMapDict) -> "ValueMap": 65 | keys = set(itertools.chain(self.keys(), other.keys())) 66 | return ValueMap({key: other.get(key, ZERO) - self.get(key, ZERO) for key in keys}) 67 | 68 | def __mul__(self, other: ValueMapDict) -> "ValueMap": 69 | keys = set(itertools.chain(self.keys(), other.keys())) 70 | ret = ValueMap() 71 | for key in keys: 72 | ret[key] = self.get(key, ZERO) * other.get(key, ZERO) 73 | return ret 74 | 75 | def __imul__(self, other: ValueMapDict) -> "ValueMap": 76 | keys = set(itertools.chain(self.keys(), other.keys())) 77 | for key in keys: 78 | self[key] = self.get(key, ZERO) * other.get(key, ZERO) 79 | return self 80 | 81 | def __rmul__(self, other: ValueMapDict) -> "ValueMap": 82 | return self.__mul__(other) 83 | -------------------------------------------------------------------------------- /basana/core/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /basana/core/config.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | class Missing: 18 | pass 19 | 20 | 21 | def _get_config_value_impl(config: dict, path: str, default=None): 22 | ret = default 23 | current_dict = config 24 | keys = path.split(".") 25 | for i, key in enumerate(keys): 26 | assert key, "Invalid path {}".format(path) 27 | if i == len(keys) - 1: 28 | ret = current_dict.get(key, default) 29 | else: 30 | current_dict = current_dict.get(key, {}) 31 | assert isinstance(current_dict, dict), f"Element at {key} is not a dictionary" 32 | return ret 33 | 34 | 35 | def get_config_value(config: dict, path: str, default=None, overrides: dict = {}): 36 | missing = Missing() 37 | ret = _get_config_value_impl(overrides, path, default=missing) 38 | if ret == missing: 39 | ret = _get_config_value_impl(config, path, default=default) 40 | return ret 41 | -------------------------------------------------------------------------------- /basana/core/dt.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import calendar 18 | import datetime 19 | 20 | from dateutil import tz 21 | 22 | 23 | def is_naive(dt: datetime.datetime) -> bool: 24 | """Returns True if datetime is naive.""" 25 | return dt.tzinfo is None or dt.tzinfo.utcoffset(dt) is None 26 | 27 | 28 | def utc_now() -> datetime.datetime: 29 | """Returns the current datetime in UTC timezone.""" 30 | return datetime.datetime.utcnow().replace(tzinfo=datetime.timezone.utc) 31 | 32 | 33 | def local_datetime(*args, **kwargs) -> datetime.datetime: 34 | return datetime.datetime(*args, **kwargs).replace(tzinfo=tz.tzlocal()) 35 | 36 | 37 | def local_now() -> datetime.datetime: 38 | """Returns the current datetime in local timezone.""" 39 | return datetime.datetime.now().replace(tzinfo=tz.tzlocal()) 40 | 41 | 42 | def to_utc_timestamp(dt: datetime.datetime) -> int: 43 | # return (dt - datetime.datetime(1970, 1, 1).replace(tzinfo=datetime.timezone.utc)).total_seconds() 44 | return calendar.timegm(dt.utctimetuple()) 45 | -------------------------------------------------------------------------------- /basana/core/enums.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import enum 18 | 19 | 20 | # Using these values to avoid misunderstandings with external API values. 21 | @enum.unique 22 | class OrderOperation(enum.Enum): 23 | """Enumeration for order operations.""" 24 | 25 | #: 26 | BUY = 100 27 | #: 28 | SELL = 101 29 | 30 | def __str__(self): 31 | return { 32 | OrderOperation.BUY: "buy", 33 | OrderOperation.SELL: "sell", 34 | }[self] 35 | 36 | 37 | @enum.unique 38 | class Position(enum.Enum): 39 | """Enumeration for positions.""" 40 | 41 | #: 42 | LONG = 200 43 | #: 44 | SHORT = 201 45 | #: 46 | NEUTRAL = 202 47 | 48 | def __str__(self): 49 | return { 50 | Position.LONG: "long", 51 | Position.SHORT: "short", 52 | Position.NEUTRAL: "neutral", 53 | }[self] 54 | -------------------------------------------------------------------------------- /basana/core/errors.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | class Error(Exception): 19 | """Base class for exceptions.""" 20 | pass 21 | -------------------------------------------------------------------------------- /basana/core/event.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from typing import List, Optional 18 | import abc 19 | import datetime 20 | 21 | from . import dt 22 | 23 | 24 | class Producer: 25 | """Base class for producers. 26 | 27 | A producer is the active part of an :class:`basana.EventSource` or a group of them. 28 | Take a look at :meth:`EventDispatcher.run` for details on how producers are used. 29 | 30 | .. note:: 31 | 32 | This is a base class and should not be used directly. 33 | """ 34 | 35 | async def initialize(self): 36 | """Override to perform initialization.""" 37 | pass 38 | 39 | async def main(self): 40 | """Override to run the loop that produces events.""" 41 | pass 42 | 43 | async def finalize(self): 44 | """Override to perform cleanup.""" 45 | pass 46 | 47 | 48 | class Event: 49 | """Base class for events. 50 | 51 | An event is something that occurs at a specific point in time. There are many different types of events: 52 | 53 | * An update to an order book. 54 | * A new trade. 55 | * An order update. 56 | * A new bar (candlestick/ohlc). 57 | * Others 58 | 59 | :param when: The datetime when the event occurred. It must have timezone information set. 60 | 61 | .. note:: 62 | 63 | This is a base class and should not be used directly. 64 | """ 65 | 66 | def __init__(self, when: datetime.datetime): 67 | assert not dt.is_naive(when), f"{when} should have timezone information set" 68 | 69 | #: The datetime when the event occurred. 70 | self.when: datetime.datetime = when 71 | 72 | 73 | class EventSource(metaclass=abc.ABCMeta): 74 | """Base class for event sources. 75 | 76 | This class declares the interface that is required by the :class:`basana.EventDispatcher` to gather events for 77 | processing. 78 | 79 | :param producer: An optional producer associated with this event source. 80 | """ 81 | 82 | def __init__(self, producer: Optional[Producer] = None): 83 | self.producer = producer 84 | 85 | @abc.abstractmethod 86 | def pop(self) -> Optional[Event]: 87 | """Override to return the next event, or None if there are no events available. 88 | 89 | This method is used by the :class:`basana.EventDispatcher` during the event dispatch loop so **it should return 90 | as soon as possible**. 91 | """ 92 | raise NotImplementedError() 93 | 94 | 95 | class FifoQueueEventSource(EventSource): 96 | """A FIFO queue event source. 97 | 98 | :param producer: An optional producer associated with this event source. 99 | :param events: An optional list of initial events. 100 | """ 101 | def __init__(self, producer: Optional[Producer] = None, events: List[Event] = []): 102 | super().__init__(producer) 103 | self._queue: List[Event] = [] 104 | self._queue.extend(events) 105 | 106 | def push(self, event: Event): 107 | """Adds an event to the end of the queue.""" 108 | self._queue.append(event) 109 | 110 | def pop(self) -> Optional[Event]: 111 | """Removes and returns the next event in the queue.""" 112 | ret = None 113 | if self._queue: 114 | ret = self._queue.pop(0) 115 | return ret 116 | -------------------------------------------------------------------------------- /basana/core/event_sources/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /basana/core/event_sources/csv.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from typing import Optional, Sequence 18 | import abc 19 | import codecs 20 | import contextlib 21 | import csv 22 | 23 | from basana.core import event 24 | 25 | # This module is not using async io. Why ? 26 | # asyncio does not support asynchronous operations on the filesystem. 27 | # Check https://github.com/python/asyncio/wiki/ThirdParty#filesystem. 28 | # aiofiles could be used, but given that: 29 | # * aiofiles delegates operations to a separate thread pool 30 | # * this module will be used mostly for backtesting where there is no other IO taking place 31 | # I decided not to support async io initially. 32 | 33 | 34 | @contextlib.contextmanager 35 | def open_file_with_detected_encoding(filename, default_encoding='utf-8'): 36 | with open(filename, 'rb') as file: 37 | raw = file.read(4) # Read enough bytes to detect BOMs 38 | 39 | boms = [ 40 | (codecs.BOM_UTF32_LE, 'utf-32-le'), 41 | (codecs.BOM_UTF32_BE, 'utf-32-be'), 42 | (codecs.BOM_UTF16_LE, "utf-16-le"), 43 | (codecs.BOM_UTF16_BE, "utf-16-be"), 44 | (codecs.BOM_UTF8, "utf-8-sig"), 45 | ] 46 | encoding = default_encoding 47 | offset = 0 48 | for bom, enc in boms: 49 | if raw.startswith(bom): 50 | encoding = enc 51 | offset = len(bom) 52 | break 53 | 54 | # Re-open the file with the detected encoding and skip the bom. 55 | f = open(filename, 'r', encoding=encoding) 56 | if offset: 57 | f.seek(offset) 58 | yield f 59 | 60 | 61 | class RowParser(metaclass=abc.ABCMeta): 62 | @abc.abstractmethod 63 | def parse_row(self, row_dict: dict) -> Sequence[event.Event]: 64 | raise NotImplementedError() 65 | 66 | 67 | def load_sort_and_yield(csv_path: str, row_parser: RowParser, dict_reader_kwargs: dict = {}): 68 | events = [] 69 | 70 | # Load events. 71 | with open_file_with_detected_encoding(csv_path) as f: 72 | dict_reader = csv.DictReader(f, **dict_reader_kwargs) 73 | for row in dict_reader: 74 | for ev in row_parser.parse_row(row): 75 | events.append(ev) 76 | 77 | # Sort them for proper delivery. 78 | events = sorted(events, key=lambda ev: ev.when) 79 | 80 | for ev in events: 81 | yield ev 82 | 83 | 84 | def load_and_yield(csv_path: str, row_parser: RowParser, dict_reader_kwargs: dict = {}): 85 | 86 | # Load events. 87 | with open_file_with_detected_encoding(csv_path) as f: 88 | dict_reader = csv.DictReader(f, **dict_reader_kwargs) 89 | for row in dict_reader: 90 | for ev in row_parser.parse_row(row): 91 | yield ev 92 | 93 | 94 | class EventSource(event.EventSource, event.Producer): 95 | def __init__(self, csv_path: str, row_parser: RowParser, sort: bool = True, dict_reader_kwargs: dict = {}): 96 | super().__init__(producer=self) 97 | self._csv_path = csv_path 98 | self._row_parser = row_parser 99 | self._sort = sort 100 | self._dict_reader_kwargs = dict_reader_kwargs 101 | self._row_it = None 102 | 103 | async def initialize(self): 104 | if self._sort: 105 | self._row_it = load_sort_and_yield(self._csv_path, self._row_parser, self._dict_reader_kwargs) 106 | else: 107 | self._row_it = load_and_yield(self._csv_path, self._row_parser, self._dict_reader_kwargs) 108 | 109 | async def finalize(self): 110 | self._row_it = None 111 | 112 | def pop(self) -> Optional[event.Event]: 113 | ret = None 114 | try: 115 | if self._row_it: 116 | ret = next(self._row_it) 117 | except StopIteration: 118 | self._row_it = None 119 | return ret 120 | -------------------------------------------------------------------------------- /basana/core/logs.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import contextlib 18 | import json 19 | import logging 20 | 21 | from . import dt 22 | 23 | 24 | @contextlib.contextmanager 25 | def backtesting_log_mode(dispatcher): 26 | old_factory = logging.getLogRecordFactory() 27 | 28 | def record_factory(*args, **kwargs): 29 | record_dt = dispatcher.now() 30 | record = old_factory(*args, **kwargs) 31 | record.created = dt.to_utc_timestamp(record_dt) 32 | record.msecs = int(record_dt.microsecond / 1000) 33 | return record 34 | 35 | logging.setLogRecordFactory(record_factory) 36 | yield 37 | logging.setLogRecordFactory(old_factory) 38 | 39 | 40 | # https://docs.python.org/3/howto/logging-cookbook.html#implementing-structured-logging 41 | class StructuredMessage: 42 | def __init__(self, message, /, **kwargs): 43 | self.message = message 44 | self.kwargs = kwargs 45 | 46 | def __str__(self): 47 | return "{} {}".format(self.message, json.dumps(self.kwargs, default=str)) 48 | -------------------------------------------------------------------------------- /basana/core/pair.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import dataclasses 18 | 19 | 20 | @dataclasses.dataclass(frozen=True) 21 | class Pair: 22 | """A trading pair. 23 | 24 | :param base_symbol: The base symbol. It could be a stock, a crypto currency, a currency, etc. 25 | :param quote_symbol: The quote symbol. It could be a stock, a crypto currency, a currency, etc. 26 | """ 27 | 28 | #: The base symbol. 29 | base_symbol: str 30 | 31 | #: The quote symbol. 32 | quote_symbol: str 33 | 34 | def __str__(self): 35 | return "{}/{}".format(self.base_symbol, self.quote_symbol) 36 | 37 | 38 | @dataclasses.dataclass(frozen=True) 39 | class PairInfo: 40 | """Information about a trading pair. 41 | 42 | :param base_precision: The precision for the base symbol. 43 | :param quote_precision: The precision for the quote symbol. 44 | """ 45 | 46 | #: The precision for the base symbol. 47 | base_precision: int 48 | 49 | #: The precision for the quote symbol. 50 | quote_precision: int 51 | -------------------------------------------------------------------------------- /basana/core/token_bucket.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import asyncio 18 | import time 19 | 20 | 21 | class TokenBucketLimiter: 22 | """This class implements a token bucket algorithm, useful for throttling requests. 23 | 24 | :param tokens_per_period: The maximum amount of tokens per perdiod. 25 | :param period_duration: The period duration in seconds. 26 | :param initial_tokens: The initial amount of tokens. 27 | """ 28 | 29 | def __init__(self, tokens_per_period: float, period_duration: int, initial_tokens=0): 30 | assert tokens_per_period > 0 31 | assert period_duration > 0 32 | assert initial_tokens >= 0 33 | 34 | self._tokens_per_period = tokens_per_period 35 | self._period_duration = period_duration 36 | self._tokens = initial_tokens 37 | self._last = time.time() 38 | 39 | @property 40 | def tokens(self) -> int: 41 | return max(int(self._tokens), 0) 42 | 43 | @property 44 | def tokens_per_period(self) -> float: 45 | return self._tokens_per_period 46 | 47 | @property 48 | def period_duration(self) -> int: 49 | return self._period_duration 50 | 51 | def consume(self) -> float: 52 | """Consumes one token and returns the time to wait before using it.""" 53 | 54 | # Refill pool of tokens. 55 | now = time.time() 56 | lapse = now - self._last 57 | self._last = now 58 | self._tokens += lapse / self._period_duration * self._tokens_per_period 59 | if self._tokens > self._tokens_per_period: 60 | self._tokens = self._tokens_per_period 61 | 62 | # Consume one token. 63 | self._tokens -= 1 64 | 65 | if self._tokens >= 0: 66 | return 0.0 67 | else: 68 | return -self._tokens / self._tokens_per_period * self._period_duration 69 | 70 | async def wait(self): 71 | await asyncio.sleep(self.consume()) 72 | -------------------------------------------------------------------------------- /basana/external/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /basana/external/binance/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /basana/external/binance/client/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from typing import Any, Dict, Optional 18 | 19 | import aiohttp 20 | 21 | from . import base, margin, spot 22 | from basana.core import token_bucket 23 | 24 | 25 | Error = base.Error 26 | 27 | 28 | class APIClient: 29 | def __init__( 30 | self, api_key: Optional[str] = None, api_secret: Optional[str] = None, 31 | session: Optional[aiohttp.ClientSession] = None, tb: Optional[token_bucket.TokenBucketLimiter] = None, 32 | config_overrides: dict = {} 33 | ): 34 | self._client = base.BaseClient( 35 | api_key=api_key, api_secret=api_secret, session=session, tb=tb, config_overrides=config_overrides 36 | ) 37 | 38 | async def get_exchange_info(self, symbol: Optional[str] = None) -> dict: 39 | params = {} 40 | if symbol: 41 | params["symbol"] = symbol 42 | return await self._client.make_request("GET", "/api/v3/exchangeInfo", qs_params=params) 43 | 44 | @property 45 | def spot_account(self) -> spot.SpotAccount: 46 | return spot.SpotAccount(self._client) 47 | 48 | @property 49 | def cross_margin_account(self) -> margin.CrossMarginAccount: 50 | return margin.CrossMarginAccount(self._client) 51 | 52 | @property 53 | def isolated_margin_account(self) -> margin.IsolatedMarginAccount: 54 | return margin.IsolatedMarginAccount(self._client) 55 | 56 | async def get_order_book(self, symbol: str, limit: Optional[int] = None) -> dict: 57 | params: Dict[str, Any] = {"symbol": symbol} 58 | if limit is not None: 59 | params["limit"] = limit 60 | return await self._client.make_request("GET", "/api/v3/depth", qs_params=params) 61 | 62 | async def get_candlestick_data( 63 | self, symbol: str, interval: str, start_time: Optional[int] = None, end_time: Optional[int] = None, 64 | limit: Optional[int] = None 65 | ) -> list: 66 | params: Dict[str, Any] = { 67 | "symbol": symbol, 68 | "interval": interval, 69 | } 70 | base.set_optional_params(params, ( 71 | ("startTime", start_time), 72 | ("endTime", end_time), 73 | ("limit", limit), 74 | )) 75 | return await self._client.make_request("GET", "/api/v3/klines", qs_params=params) 76 | -------------------------------------------------------------------------------- /basana/external/binance/config.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | DEFAULTS = { 18 | "api": { 19 | "http": { 20 | "base_url": "https://api.binance.com/", 21 | "timeout": 30, 22 | }, 23 | "websockets": { 24 | "base_url": "wss://stream.binance.com/", 25 | "heartbeat": 30, 26 | "spot": { 27 | "user_data_stream": { 28 | "heartbeat": 15 * 60, 29 | }, 30 | }, 31 | "cross_margin": { 32 | "user_data_stream": { 33 | "heartbeat": 15 * 60, 34 | }, 35 | }, 36 | "isolated_margin": { 37 | "user_data_stream": { 38 | "heartbeat": 15 * 60, 39 | }, 40 | }, 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /basana/external/binance/csv/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # flake8: noqa 18 | 19 | from .bars import ( 20 | BarSource, 21 | ) 22 | -------------------------------------------------------------------------------- /basana/external/binance/csv/bars.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import datetime 18 | 19 | from basana.core import pair 20 | from basana.core.event_sources import csv 21 | from basana.external.binance.tools.download_bars import period_to_step 22 | from basana.external.common.csv.bars import RowParser 23 | 24 | 25 | period_to_timedelta = { 26 | period_str: datetime.timedelta(seconds=period_secs) 27 | for period_str, period_secs in period_to_step.items() 28 | } 29 | 30 | 31 | class BarSource(csv.EventSource): 32 | def __init__( 33 | self, pair: pair.Pair, csv_path: str, period: str, 34 | sort: bool = False, tzinfo: datetime.tzinfo = datetime.timezone.utc, 35 | dict_reader_kwargs: dict = {} 36 | ): 37 | # The datetime in the files are the beginning of the period but we need to generate the event at the period's 38 | # end. 39 | timedelta = period_to_timedelta.get(period) 40 | assert timedelta is not None, "Invalid period" 41 | self.row_parser = RowParser(pair, tzinfo=tzinfo, timedelta=timedelta) 42 | super().__init__(csv_path, self.row_parser, sort=sort, dict_reader_kwargs=dict_reader_kwargs) 43 | -------------------------------------------------------------------------------- /basana/external/binance/helpers.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | from typing import Optional 19 | from urllib.parse import urlencode 20 | import datetime 21 | import hashlib 22 | import hmac 23 | 24 | from basana.core import dt, pair 25 | from basana.core.enums import OrderOperation 26 | 27 | 28 | def get_signature(api_secret: str, qs_params: dict = {}, data: dict = {}) -> str: 29 | total_params = "" 30 | if qs_params: 31 | total_params += urlencode(qs_params) 32 | if data: 33 | total_params += urlencode(data) 34 | return hmac.new(api_secret.encode(), msg=total_params.encode("utf-8"), digestmod=hashlib.sha256).hexdigest() 35 | 36 | 37 | def pair_to_order_book_symbol(pair: pair.Pair) -> str: 38 | return "{}{}".format(pair.base_symbol.upper(), pair.quote_symbol.upper()) 39 | 40 | 41 | def order_operation_to_side(operation: OrderOperation) -> str: 42 | return { 43 | OrderOperation.BUY: "BUY", 44 | OrderOperation.SELL: "SELL", 45 | }[operation] 46 | 47 | 48 | def side_to_order_operation(side: str) -> OrderOperation: 49 | return { 50 | "BUY": OrderOperation.BUY, 51 | "SELL": OrderOperation.SELL, 52 | }[side] 53 | 54 | 55 | def order_status_is_open(order_status: str) -> bool: 56 | # Not doing `order_status in ["NEW", "PARTIALLY_FILLED", "PENDING_CANCEL"]` to detect unkown states. 57 | is_open = { 58 | "NEW": True, 59 | "PARTIALLY_FILLED": True, 60 | "FILLED": False, 61 | "CANCELED": False, 62 | "PENDING_CANCEL": True, 63 | "REJECTED": False, 64 | "EXPIRED": False, 65 | }.get(order_status) 66 | assert is_open is not None, "No mapping for {} order status".format(order_status) 67 | return is_open 68 | 69 | 70 | def oco_order_status_is_open(list_order_status: str) -> bool: 71 | # Not doing `list_order_status in ["EXECUTING"]` to detect unkown states. 72 | is_open = { 73 | "EXECUTING": True, 74 | "ALL_DONE": False, 75 | "REJECT": False, 76 | }.get(list_order_status) 77 | assert is_open is not None, "No mapping for {} list order status".format(list_order_status) 78 | return is_open 79 | 80 | 81 | def get_optional_decimal(mapping: dict, key: str, skip_zero: bool) -> Optional[Decimal]: 82 | ret = None 83 | price = mapping.get(key) 84 | if price is not None: 85 | price = Decimal(price) 86 | ret = None if price == Decimal(0) and skip_zero else price 87 | return ret 88 | 89 | 90 | def timestamp_to_datetime(timestamp: int) -> datetime.datetime: 91 | return datetime.datetime.fromtimestamp(timestamp / 1e3, tz=datetime.timezone.utc) 92 | 93 | 94 | def datetime_to_timestamp(datetime: datetime.datetime) -> int: 95 | return int(dt.to_utc_timestamp(datetime) * 1e3) 96 | -------------------------------------------------------------------------------- /basana/external/binance/klines.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import logging 19 | 20 | from . import helpers 21 | from basana.core import bar, event, websockets as core_ws 22 | from basana.core.pair import Pair 23 | 24 | 25 | logger = logging.getLogger(__name__) 26 | 27 | 28 | class Bar(bar.Bar): 29 | def __init__(self, pair: Pair, json: dict): 30 | super().__init__( 31 | helpers.timestamp_to_datetime(int(json["t"])), pair, Decimal(json["o"]), Decimal(json["h"]), 32 | Decimal(json["l"]), Decimal(json["c"]), Decimal(json["v"]) 33 | ) 34 | self.pair: Pair = pair 35 | self.json: dict = json 36 | 37 | 38 | # Generate BarEvents events from websocket messages. 39 | class WebSocketEventSource(core_ws.ChannelEventSource): 40 | def __init__(self, pair: Pair, producer: event.Producer): 41 | super().__init__(producer=producer) 42 | self._pair: Pair = pair 43 | 44 | async def push_from_message(self, message: dict): 45 | kline_event = message["data"] 46 | kline = kline_event["k"] 47 | # Wait for the last update to the kline. 48 | if kline["x"] is False: 49 | return 50 | self.push(bar.BarEvent( 51 | helpers.timestamp_to_datetime(int(kline_event["E"])), 52 | Bar(self._pair, kline) 53 | )) 54 | 55 | 56 | def get_channel(pair: Pair, interval: str) -> str: 57 | return "{}@kline_{}".format(helpers.pair_to_order_book_symbol(pair).lower(), interval) 58 | -------------------------------------------------------------------------------- /basana/external/binance/tools/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /basana/external/binance/trades.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | from typing import Any, Awaitable, Callable 19 | import datetime 20 | import logging 21 | 22 | from . import helpers 23 | from basana.core import event, websockets as core_ws 24 | from basana.core.pair import Pair 25 | 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | class Trade: 31 | def __init__(self, pair: Pair, json: dict): 32 | assert json["e"] == "trade" 33 | 34 | #: The trading pair. 35 | self.pair: Pair = pair 36 | #: The JSON representation. 37 | self.json: dict = json 38 | 39 | @property 40 | def id(self) -> str: 41 | """The trade id.""" 42 | return str(self.json["t"]) 43 | 44 | @property 45 | def datetime(self) -> datetime.datetime: 46 | """The trade datetime.""" 47 | return helpers.timestamp_to_datetime(int(self.json["T"])) 48 | 49 | @property 50 | def price(self) -> Decimal: 51 | """The price.""" 52 | return Decimal(self.json["p"]) 53 | 54 | @property 55 | def amount(self) -> Decimal: 56 | """The amount.""" 57 | return Decimal(self.json["q"]) 58 | 59 | @property 60 | def buy_order_id(self) -> str: 61 | """The buyer order id.""" 62 | return str(self.json["b"]) 63 | 64 | @property 65 | def sell_order_id(self) -> str: 66 | """The seller order id.""" 67 | return str(self.json["a"]) 68 | 69 | 70 | class TradeEvent(event.Event): 71 | """An event for new trades. 72 | 73 | :param when: The datetime when the event occurred. It must have timezone information set. 74 | :param trade: The trade. 75 | """ 76 | 77 | def __init__(self, when: datetime.datetime, trade: Trade): 78 | super().__init__(when) 79 | #: The trade. 80 | self.trade: Trade = trade 81 | 82 | 83 | # Generate TradeEvent events from websocket messages. 84 | class WebSocketEventSource(core_ws.ChannelEventSource): 85 | def __init__(self, pair: Pair, producer: event.Producer): 86 | super().__init__(producer=producer) 87 | self._pair: Pair = pair 88 | 89 | async def push_from_message(self, message: dict): 90 | event = message["data"] 91 | self.push(TradeEvent( 92 | helpers.timestamp_to_datetime(int(event["E"])), 93 | Trade(self._pair, event) 94 | )) 95 | 96 | 97 | def get_channel(pair: Pair) -> str: 98 | return "{}@trade".format(helpers.pair_to_order_book_symbol(pair).lower()) 99 | 100 | 101 | TradeEventHandler = Callable[[TradeEvent], Awaitable[Any]] 102 | -------------------------------------------------------------------------------- /basana/external/bitstamp/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /basana/external/bitstamp/config.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | DEFAULTS = { 18 | "api": { 19 | "http": { 20 | "base_url": "https://www.bitstamp.net/", 21 | "timeout": 30, 22 | }, 23 | "websockets": { 24 | "base_url": "wss://ws.bitstamp.net/", 25 | "heartbeat": 30, 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /basana/external/bitstamp/csv/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # flake8: noqa 18 | 19 | from .bars import ( 20 | BarSource, 21 | ) 22 | -------------------------------------------------------------------------------- /basana/external/bitstamp/csv/bars.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from typing import Union 18 | import datetime 19 | import enum 20 | 21 | from basana.core import pair 22 | from basana.core.event_sources import csv 23 | from basana.external.bitstamp.tools.download_bars import period_to_step 24 | from basana.external.common.csv.bars import RowParser 25 | 26 | 27 | period_to_timedelta = { 28 | period_str: datetime.timedelta(seconds=period_secs) 29 | for period_str, period_secs in period_to_step.items() 30 | } 31 | 32 | 33 | # TODO: Deprecate at v2. 34 | @enum.unique 35 | class BarPeriod(enum.Enum): 36 | MINUTE = 1 37 | HOUR = 2 38 | DAY = 3 39 | 40 | 41 | class BarSource(csv.EventSource): 42 | def __init__( 43 | self, pair: pair.Pair, csv_path: str, period: Union[str, BarPeriod], 44 | sort: bool = False, tzinfo: datetime.tzinfo = datetime.timezone.utc, 45 | dict_reader_kwargs: dict = {} 46 | ): 47 | # TODO: Deprecate at v2. 48 | if isinstance(period, BarPeriod): 49 | period = { 50 | BarPeriod.MINUTE: "1m", 51 | BarPeriod.HOUR: "1h", 52 | BarPeriod.DAY: "1d", 53 | }[period] 54 | # The datetime in the files are the beginning of the period but we need to generate the event at the period's 55 | # end. 56 | timedelta = period_to_timedelta.get(period) 57 | assert timedelta is not None, "Invalid period" 58 | self.row_parser = RowParser(pair, tzinfo=tzinfo, timedelta=timedelta) 59 | super().__init__(csv_path, self.row_parser, sort=sort, dict_reader_kwargs=dict_reader_kwargs) 60 | -------------------------------------------------------------------------------- /basana/external/bitstamp/helpers.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from typing import Dict 18 | from urllib.parse import urlencode 19 | import hashlib 20 | import hmac 21 | import time 22 | import uuid 23 | 24 | from basana.core import pair 25 | from basana.core.enums import OrderOperation 26 | 27 | 28 | def generate_nonce() -> str: 29 | return str(uuid.uuid4()) 30 | 31 | 32 | def get_auth_headers( 33 | hostname: str, api_key: str, api_secret: str, nonce: str, method: str, path: str, 34 | qs_params: Dict[str, str] = {}, data: Dict[str, str] = {} 35 | ) -> Dict[str, str]: 36 | assert path[0] == "/", "Leading slash is missing from path" 37 | 38 | timestamp = str(int(round(time.time() * 1000))) 39 | headers = { 40 | "X-Auth": "BITSTAMP {}".format(api_key), 41 | "X-Auth-Nonce": nonce, 42 | "X-Auth-Timestamp": timestamp, 43 | "X-Auth-Version": "v2", 44 | } 45 | if data: 46 | headers["Content-Type"] = "application/x-www-form-urlencoded" 47 | 48 | message = headers["X-Auth"] 49 | message += method.upper() 50 | message += hostname 51 | message += path 52 | if qs_params: # pragma: no cover 53 | message += urlencode(qs_params) 54 | message += headers.get("Content-Type", "") 55 | message += headers["X-Auth-Nonce"] 56 | message += headers["X-Auth-Timestamp"] 57 | message += headers["X-Auth-Version"] 58 | if data: 59 | message += urlencode(data) 60 | 61 | headers["X-Auth-Signature"] = hmac.new( 62 | api_secret.encode(), msg=message.encode("utf-8"), digestmod=hashlib.sha256 63 | ).hexdigest() 64 | return headers 65 | 66 | 67 | def pair_to_currency_pair(pair: pair.Pair) -> str: 68 | return "{}{}".format(pair.base_symbol.lower(), pair.quote_symbol.lower()) 69 | 70 | 71 | def order_type_to_order_operation(order_type: int) -> OrderOperation: 72 | ret = { 73 | 0: OrderOperation.BUY, 74 | 1: OrderOperation.SELL, 75 | }.get(order_type) 76 | assert ret is not None, f"Invalid order type {order_type}" 77 | return ret 78 | -------------------------------------------------------------------------------- /basana/external/bitstamp/orders.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import datetime 19 | 20 | from . import helpers 21 | from basana.core import dt, event, websockets as core_ws 22 | from basana.core.enums import OrderOperation 23 | from basana.core.pair import Pair 24 | 25 | 26 | class Order: 27 | def __init__(self, pair: Pair, json: dict): 28 | #: The trading pair. 29 | self.pair: Pair = pair 30 | #: The JSON representation. 31 | self.json: dict = json 32 | 33 | @property 34 | def id(self) -> str: 35 | """The order id.""" 36 | return str(self.json["id"]) 37 | 38 | @property 39 | def datetime(self) -> datetime.datetime: 40 | timestamp = int(self.json["microtimestamp"]) / 1e6 41 | return datetime.datetime.utcfromtimestamp(timestamp).replace(tzinfo=datetime.timezone.utc) 42 | 43 | @property 44 | def amount(self) -> Decimal: 45 | """The order amount.""" 46 | return Decimal(self.json["amount_at_create"]) 47 | 48 | @property 49 | def amount_filled(self) -> Decimal: 50 | """The amount filled.""" 51 | return self.amount - Decimal(self.json["amount_str"]) 52 | 53 | @property 54 | def price(self) -> Decimal: 55 | """The order price.""" 56 | return Decimal(self.json["price_str"]) 57 | 58 | @property 59 | def type(self) -> OrderOperation: 60 | # TODO: Deprecate this property. 61 | return self.operation 62 | 63 | @property 64 | def operation(self) -> OrderOperation: 65 | """The operation.""" 66 | return helpers.order_type_to_order_operation(int(self.json["order_type"])) 67 | 68 | 69 | class OrderEvent(event.Event): 70 | """An event for order updates. 71 | 72 | :param when: The datetime when the event occurred. It must have timezone information set. 73 | :param type: The type of event. One of order_created, order_changed or order_deleted. 74 | :param order: The order. 75 | """ 76 | 77 | def __init__(self, when: datetime.datetime, type: str, order: Order): 78 | super().__init__(when) 79 | #: The event type. One of order_created, order_changed or order_deleted. 80 | self.type: str = type 81 | #: The order. 82 | self.order: Order = order 83 | 84 | 85 | # Generate Order events from websocket messages. 86 | class WebSocketEventSource(core_ws.ChannelEventSource): 87 | def __init__(self, pair: Pair, producer: event.Producer): 88 | super().__init__(producer=producer) 89 | self._pair = pair 90 | 91 | async def push_from_message(self, message: dict): 92 | self.push(OrderEvent(dt.utc_now(), message["event"], Order(self._pair, message["data"]))) 93 | 94 | 95 | def get_public_channel(pair: Pair) -> str: 96 | return "live_orders_{}".format(helpers.pair_to_currency_pair(pair)) 97 | 98 | 99 | def get_private_channel(pair: Pair) -> str: 100 | return "private-my_orders_{}".format(helpers.pair_to_currency_pair(pair)) 101 | -------------------------------------------------------------------------------- /basana/external/bitstamp/tools/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /basana/external/bitstamp/trades.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import datetime 19 | 20 | from . import helpers 21 | from basana.core import dt, event, websockets as core_ws 22 | from basana.core.enums import OrderOperation 23 | from basana.core.pair import Pair 24 | 25 | 26 | class Trade: 27 | def __init__(self, pair: Pair, json: dict): 28 | #: The trading pair. 29 | self.pair: Pair = pair 30 | #: The JSON representation. 31 | self.json: dict = json 32 | 33 | @property 34 | def id(self) -> str: 35 | """The trade id.""" 36 | return str(self.json["id"]) 37 | 38 | @property 39 | def datetime(self) -> datetime.datetime: 40 | """The datetime when the trade occurred.""" 41 | timestamp = int(self.json["microtimestamp"]) / 1e6 42 | return datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc) 43 | 44 | @property 45 | def amount(self) -> Decimal: 46 | """The amount.""" 47 | return Decimal(self.json["amount_str"]) 48 | 49 | @property 50 | def price(self) -> Decimal: 51 | """The price.""" 52 | return Decimal(self.json["price_str"]) 53 | 54 | @property 55 | def type(self) -> OrderOperation: 56 | # TODO: Deprecate this property. 57 | return self.operation 58 | 59 | @property 60 | def operation(self) -> OrderOperation: 61 | """The operation.""" 62 | return helpers.order_type_to_order_operation(int(self.json["type"])) 63 | 64 | @property 65 | def buy_order_id(self) -> str: 66 | """The buy order id.""" 67 | return str(self.json["buy_order_id"]) 68 | 69 | @property 70 | def sell_order_id(self) -> str: 71 | """The sell order id.""" 72 | return str(self.json["sell_order_id"]) 73 | 74 | 75 | class TradeEvent(event.Event): 76 | def __init__(self, when: datetime.datetime, trade: Trade): 77 | super().__init__(when) 78 | #: The trade. 79 | self.trade: Trade = trade 80 | 81 | 82 | # Generate Trade events from websocket messages. 83 | class WebSocketEventSource(core_ws.ChannelEventSource): 84 | def __init__(self, pair: Pair, producer: event.Producer): 85 | super().__init__(producer=producer) 86 | self._pair: Pair = pair 87 | 88 | async def push_from_message(self, message: dict): 89 | self.push(TradeEvent(dt.utc_now(), Trade(self._pair, message["data"]))) 90 | 91 | 92 | def get_public_channel(pair: Pair) -> str: 93 | return "live_trades_{}".format(helpers.pair_to_currency_pair(pair)) 94 | 95 | 96 | def get_private_channel(pair: Pair) -> str: 97 | return "private-my_trades_{}".format(helpers.pair_to_currency_pair(pair)) 98 | -------------------------------------------------------------------------------- /basana/external/common/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /basana/external/common/csv/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /basana/external/common/csv/bars.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | from typing import Sequence 19 | import datetime 20 | 21 | from basana.core import pair, event, bar 22 | from basana.core.event_sources import csv 23 | 24 | 25 | class RowParser(csv.RowParser): 26 | def __init__( 27 | self, pair: pair.Pair, tzinfo: datetime.tzinfo, timedelta: datetime.timedelta 28 | ): 29 | self.pair = pair 30 | self.tzinfo = tzinfo 31 | self.timedelta = timedelta 32 | 33 | def parse_row(self, row_dict: dict) -> Sequence[event.Event]: 34 | # File format: 35 | # 36 | # datetime,open,high,low,close,volume 37 | # 2015-01-01 00:00:00,321,321,321,321,1.73697242 38 | 39 | volume = Decimal(row_dict["volume"]) 40 | # Skip bars with no volume. 41 | if volume == 0: 42 | return [] 43 | 44 | dt = datetime.datetime.strptime(row_dict["datetime"], "%Y-%m-%d %H:%M:%S").replace(tzinfo=self.tzinfo) 45 | return [ 46 | bar.BarEvent( 47 | dt + self.timedelta, 48 | bar.Bar( 49 | dt, self.pair, Decimal(row_dict["open"]), Decimal(row_dict["high"]), Decimal(row_dict["low"]), 50 | Decimal(row_dict["close"]), volume 51 | ) 52 | ) 53 | ] 54 | -------------------------------------------------------------------------------- /basana/external/yahoo/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /basana/external/yahoo/bars.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | from typing import Sequence, Tuple 19 | import datetime 20 | 21 | from dateutil import tz 22 | 23 | from basana.core.event_sources import csv 24 | from basana.core import pair, event, bar 25 | 26 | 27 | ###################################################################### 28 | # Yahoo Finance CSV parser 29 | # 30 | # File format: 31 | # Date,Open,High,Low,Close,Volume,Adj Close 32 | # 33 | # The csv Date column must have the following format: YYYY-MM-DD 34 | 35 | 36 | def adjust_ohlc( 37 | open: Decimal, high: Decimal, low: Decimal, close: Decimal, adj_close: Decimal 38 | ) -> Tuple[Decimal, Decimal, Decimal, Decimal]: 39 | adj_factor = adj_close / close 40 | open *= adj_factor 41 | high *= adj_factor 42 | low *= adj_factor 43 | close = adj_close 44 | return open, high, low, close 45 | 46 | 47 | def sanitize_ohlc(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> Tuple[ 48 | Decimal, Decimal, Decimal, Decimal 49 | ]: 50 | if low > open: 51 | low = open 52 | if low > close: 53 | low = close 54 | if high < open: 55 | high = open 56 | if high < close: 57 | high = close 58 | return open, high, low, close 59 | 60 | 61 | class RowParser(csv.RowParser): 62 | def __init__( 63 | self, pair: pair.Pair, adjust_ohlc: bool = False, tzinfo: datetime.tzinfo = tz.tzlocal(), 64 | timedelta: datetime.timedelta = datetime.timedelta(hours=24) 65 | ): 66 | self.pair = pair 67 | self.tzinfo = tzinfo 68 | self.timedelta = timedelta 69 | self.sanitize = False 70 | self.adjust_ohlc = adjust_ohlc 71 | 72 | def parse_row(self, row_dict: dict) -> Sequence[event.Event]: 73 | dt = datetime.datetime.strptime(row_dict["Date"], "%Y-%m-%d").replace(tzinfo=self.tzinfo) 74 | open, high, low, close = ( 75 | Decimal(row_dict["Open"]), Decimal(row_dict["High"]), Decimal(row_dict["Low"]), Decimal(row_dict["Close"]) 76 | ) 77 | if self.sanitize: 78 | open, high, low, close = sanitize_ohlc(open, high, low, close) 79 | if self.adjust_ohlc: 80 | open, high, low, close = adjust_ohlc(open, high, low, close, Decimal(row_dict["Adj Close"])) 81 | 82 | return [ 83 | bar.BarEvent( 84 | dt + self.timedelta, 85 | bar.Bar(dt, self.pair, open, high, low, close, Decimal(row_dict["Volume"])) 86 | ) 87 | ] 88 | 89 | 90 | class CSVBarSource(csv.EventSource): 91 | def __init__( 92 | self, pair: pair.Pair, csv_path: str, adjust_ohlc: bool = False, sort: bool = True, 93 | tzinfo: datetime.tzinfo = tz.tzlocal(), 94 | timedelta: datetime.timedelta = datetime.timedelta(hours=24), 95 | dict_reader_kwargs: dict = {} 96 | ): 97 | self.row_parser = RowParser(pair, adjust_ohlc=adjust_ohlc, tzinfo=tzinfo, timedelta=timedelta) 98 | super().__init__(csv_path, self.row_parser, sort=sort, dict_reader_kwargs=dict_reader_kwargs) 99 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/backtesting_bbands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbeced/basana/8f70b1ebcf81e5bedefc175048eb434310082c0b/docs/_static/backtesting_bbands.png -------------------------------------------------------------------------------- /docs/_static/readme_pairs_trading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbeced/basana/8f70b1ebcf81e5bedefc175048eb434310082c0b/docs/_static/readme_pairs_trading.png -------------------------------------------------------------------------------- /docs/api.rst: -------------------------------------------------------------------------------- 1 | API Reference 2 | ============= 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | basana 8 | backtesting_exchange 9 | backtesting_charts 10 | backtesting_fees 11 | backtesting_liquidity 12 | backtesting_lending 13 | binance_exchange 14 | binance_order_book 15 | binance_trades 16 | binance_spot 17 | binance_margin 18 | binance_cross 19 | binance_isolated 20 | binance_user_data 21 | bitstamp_exchange 22 | bitstamp_order_book 23 | bitstamp_orders 24 | bitstamp_trades 25 | -------------------------------------------------------------------------------- /docs/backtesting_charts.rst: -------------------------------------------------------------------------------- 1 | basana.backtesting.charts 2 | ========================= 3 | 4 | .. module:: basana.backtesting.charts 5 | 6 | .. autoclass:: basana.backtesting.charts.LineCharts 7 | :members: 8 | .. autoclass:: basana.backtesting.charts.DataPointFromSequence 9 | -------------------------------------------------------------------------------- /docs/backtesting_exchange.rst: -------------------------------------------------------------------------------- 1 | basana.backtesting.exchange 2 | =========================== 3 | 4 | .. module:: basana.backtesting.exchange 5 | 6 | .. autoclass:: basana.backtesting.exchange.Exchange 7 | :members: 8 | .. autoexception:: basana.backtesting.exchange.Error 9 | :show-inheritance: 10 | .. autoclass:: basana.backtesting.exchange.Balance 11 | :members: 12 | .. autoclass:: basana.backtesting.exchange.CreatedOrder 13 | :members: 14 | .. autoclass:: basana.backtesting.exchange.CanceledOrder 15 | :members: 16 | .. autoclass:: basana.backtesting.exchange.Fill 17 | :members: 18 | .. autoclass:: basana.backtesting.exchange.OrderInfo 19 | :members: 20 | .. autoclass:: basana.backtesting.exchange.OpenOrder 21 | :members: 22 | .. autoclass:: basana.backtesting.exchange.OrderEvent 23 | :members: 24 | -------------------------------------------------------------------------------- /docs/backtesting_fees.rst: -------------------------------------------------------------------------------- 1 | basana.backtesting.fees 2 | ======================= 3 | 4 | Fee strategies are used by the backtesting exchange to support different trading fee schemes, or no fees at all. 5 | 6 | .. module:: basana.backtesting.fees 7 | 8 | .. autoclass:: basana.backtesting.fees.NoFee 9 | :show-inheritance: 10 | .. autoclass:: basana.backtesting.fees.Percentage 11 | :show-inheritance: 12 | -------------------------------------------------------------------------------- /docs/backtesting_lending.rst: -------------------------------------------------------------------------------- 1 | basana.backtesting.lending 2 | ========================== 3 | 4 | Lending strategies are used by the backtesting exchange to support different lending schemes, or no lending at all. 5 | 6 | **Margin calls are not yet implemented.** 7 | 8 | .. module:: basana.backtesting.lending 9 | 10 | .. autoclass:: basana.backtesting.lending.LoanInfo 11 | :members: 12 | .. autoclass:: basana.backtesting.lending.NoLoans 13 | :show-inheritance: 14 | .. autoclass:: basana.backtesting.lending.MarginLoanConditions 15 | :members: 16 | .. autoclass:: basana.backtesting.lending.MarginLoans 17 | :show-inheritance: 18 | :members: 19 | -------------------------------------------------------------------------------- /docs/backtesting_liquidity.rst: -------------------------------------------------------------------------------- 1 | basana.backtesting.liquidity 2 | ============================ 3 | 4 | Liquidity strategies are used by the backtesting exchange to determine: 5 | 6 | * How much of a bar's volume can be consumed by an order. 7 | * The price slippage. 8 | 9 | .. module:: basana.backtesting.liquidity 10 | 11 | .. autoclass:: basana.backtesting.liquidity.InfiniteLiquidity 12 | .. autoclass:: basana.backtesting.liquidity.VolumeShareImpact 13 | -------------------------------------------------------------------------------- /docs/basana.rst: -------------------------------------------------------------------------------- 1 | basana 2 | ====== 3 | 4 | This is the core of the event driven architecture. At a high level you have: 5 | 6 | * **Events**. It could be a new bar, a new trade, an order book update, etc. 7 | * **Event sources**, for example a websocket that pushes a new message when an order book is updated. 8 | * **Event handlers** that are connected to certain event sources and are invoked when these generate new events. 9 | * An **event dispatcher** that is responsible for running the event loop and invoking event handlers in the right 10 | order as events from different sources occur. 11 | 12 | The trading signal source implements the set of rules that define when to enter or exit a trade based on the conditions 13 | you define. Take a look at the :doc:`quickstart` section for examples on how to implement trading signal sources. 14 | 15 | .. module:: basana 16 | 17 | .. autoclass:: basana.Event 18 | :members: 19 | .. autoclass:: basana.EventSource 20 | :members: 21 | .. autoclass:: basana.FifoQueueEventSource 22 | :show-inheritance: 23 | :members: 24 | .. autoclass:: basana.Producer 25 | :members: 26 | .. autoclass:: basana.EventDispatcher 27 | :members: 28 | .. autoclass:: basana.BacktestingDispatcher 29 | :show-inheritance: 30 | :members: 31 | .. autoclass:: basana.RealtimeDispatcher 32 | :show-inheritance: 33 | :members: 34 | .. autofunction:: basana.backtesting_dispatcher 35 | .. autofunction:: basana.realtime_dispatcher 36 | 37 | .. autoclass:: basana.Bar 38 | :members: 39 | .. autoclass:: basana.BarEvent 40 | :show-inheritance: 41 | :members: 42 | .. autoclass:: basana.OrderOperation 43 | :members: 44 | .. autoclass:: basana.Pair 45 | :members: 46 | .. autoclass:: basana.PairInfo 47 | :members: 48 | .. autoclass:: basana.Position 49 | :members: 50 | 51 | .. autoclass:: basana.TradingSignal 52 | :show-inheritance: 53 | :inherited-members: 54 | :members: 55 | .. autoclass:: basana.TradingSignalSource 56 | :show-inheritance: 57 | :members: 58 | 59 | .. autoclass:: basana.TokenBucketLimiter 60 | .. autofunction:: basana.round_decimal 61 | .. autofunction:: basana.truncate_decimal 62 | .. autofunction:: basana.local_now 63 | .. autofunction:: basana.utc_now 64 | -------------------------------------------------------------------------------- /docs/binance_cross.rst: -------------------------------------------------------------------------------- 1 | basana.external.binance.cross_margin 2 | ==================================== 3 | 4 | .. module:: basana.external.binance.cross_margin 5 | 6 | .. autoclass:: basana.external.binance.cross_margin.Account 7 | :inherited-members: 8 | :members: 9 | -------------------------------------------------------------------------------- /docs/binance_exchange.rst: -------------------------------------------------------------------------------- 1 | basana.external.binance.exchange 2 | ================================ 3 | 4 | .. module:: basana.external.binance.exchange 5 | 6 | .. autoclass:: basana.external.binance.exchange.Exchange 7 | :members: 8 | .. autoexception:: basana.external.binance.exchange.Error 9 | :members: 10 | .. autoclass:: basana.external.binance.exchange.PairInfoEx 11 | :show-inheritance: 12 | :members: 13 | -------------------------------------------------------------------------------- /docs/binance_isolated.rst: -------------------------------------------------------------------------------- 1 | basana.external.binance.isolated_margin 2 | ======================================= 3 | 4 | .. module:: basana.external.binance.isolated_margin 5 | 6 | .. autoclass:: basana.external.binance.isolated_margin.Account 7 | :inherited-members: 8 | :members: 9 | .. autoclass:: basana.external.binance.isolated_margin.IsolatedBalance 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/binance_margin.rst: -------------------------------------------------------------------------------- 1 | basana.external.binance.margin 2 | ============================== 3 | 4 | .. module:: basana.external.binance.margin 5 | 6 | .. autoclass:: basana.external.binance.margin.Balance 7 | :inherited-members: 8 | :members: 9 | .. autoclass:: basana.external.binance.margin.CreatedOrder 10 | :inherited-members: 11 | :members: 12 | .. autoclass:: basana.external.binance.margin.OrderInfo 13 | :inherited-members: 14 | :members: 15 | .. autoclass:: basana.external.binance.margin.OpenOrder 16 | :inherited-members: 17 | :members: 18 | .. autoclass:: basana.external.binance.margin.CanceledOrder 19 | :inherited-members: 20 | :members: 21 | .. autoclass:: basana.external.binance.margin.CreatedOCOOrder 22 | :inherited-members: 23 | :members: 24 | .. autoclass:: basana.external.binance.margin.OCOOrderInfo 25 | :inherited-members: 26 | :members: 27 | .. autoclass:: basana.external.binance.margin.CanceledOCOOrder 28 | :inherited-members: 29 | :members: 30 | -------------------------------------------------------------------------------- /docs/binance_order_book.rst: -------------------------------------------------------------------------------- 1 | basana.external.binance.order_book 2 | ================================== 3 | 4 | .. module:: basana.external.binance.order_book 5 | 6 | .. autoclass:: basana.external.binance.order_book.OrderBookEvent 7 | :show-inheritance: 8 | :members: 9 | .. autoclass:: basana.external.binance.order_book.OrderBook 10 | :members: 11 | .. autoclass:: basana.external.binance.order_book.Entry 12 | :members: 13 | -------------------------------------------------------------------------------- /docs/binance_spot.rst: -------------------------------------------------------------------------------- 1 | basana.external.binance.spot 2 | ============================ 3 | 4 | .. module:: basana.external.binance.spot 5 | 6 | .. autoclass:: basana.external.binance.spot.Account 7 | :members: 8 | .. autoclass:: basana.external.binance.spot.Balance 9 | :members: 10 | .. autoclass:: basana.external.binance.spot.CreatedOrder 11 | :inherited-members: 12 | :members: 13 | .. autoclass:: basana.external.binance.spot.OrderInfo 14 | :inherited-members: 15 | :members: 16 | .. autoclass:: basana.external.binance.spot.OpenOrder 17 | :inherited-members: 18 | :members: 19 | .. autoclass:: basana.external.binance.spot.CanceledOrder 20 | :inherited-members: 21 | :members: 22 | .. autoclass:: basana.external.binance.spot.CreatedOCOOrder 23 | :inherited-members: 24 | :members: 25 | .. autoclass:: basana.external.binance.spot.OCOOrderInfo 26 | :inherited-members: 27 | :members: 28 | .. autoclass:: basana.external.binance.spot.CanceledOCOOrder 29 | :inherited-members: 30 | :members: 31 | -------------------------------------------------------------------------------- /docs/binance_trades.rst: -------------------------------------------------------------------------------- 1 | basana.external.binance.trades 2 | ============================== 3 | 4 | .. module:: basana.external.binance.trades 5 | 6 | .. autoclass:: basana.external.binance.trades.TradeEvent 7 | :show-inheritance: 8 | :members: 9 | .. autoclass:: basana.external.binance.trades.Trade 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/binance_user_data.rst: -------------------------------------------------------------------------------- 1 | basana.external.binance.user_data 2 | ==================================== 3 | 4 | .. module:: basana.external.binance.user_data 5 | 6 | .. autoclass:: basana.external.binance.user_data.Event 7 | :inherited-members: 8 | :members: 9 | .. autoclass:: basana.external.binance.user_data.OrderEvent 10 | :inherited-members: 11 | :members: 12 | .. autoclass:: basana.external.binance.user_data.OrderUpdate 13 | :members: 14 | -------------------------------------------------------------------------------- /docs/bitstamp_exchange.rst: -------------------------------------------------------------------------------- 1 | basana.external.bitstamp.exchange 2 | ================================= 3 | 4 | .. module:: basana.external.bitstamp.exchange 5 | 6 | .. autoclass:: basana.external.bitstamp.exchange.Exchange 7 | :members: 8 | .. autoclass:: basana.external.bitstamp.exchange.Error 9 | :members: 10 | .. autoclass:: basana.external.bitstamp.exchange.TransactionType 11 | :members: 12 | .. autoclass:: basana.external.bitstamp.exchange.Balance 13 | :members: 14 | .. autoclass:: basana.external.bitstamp.exchange.CreatedOrder 15 | :members: 16 | .. autoclass:: basana.external.bitstamp.exchange.OrderInfo 17 | :members: 18 | .. autoclass:: basana.external.bitstamp.exchange.OpenOrder 19 | :members: 20 | .. autoclass:: basana.external.bitstamp.exchange.CanceledOrder 21 | :members: 22 | -------------------------------------------------------------------------------- /docs/bitstamp_order_book.rst: -------------------------------------------------------------------------------- 1 | basana.external.bitstamp.order_book 2 | =================================== 3 | 4 | .. module:: basana.external.bitstamp.order_book 5 | 6 | .. autoclass:: basana.external.bitstamp.order_book.OrderBookEvent 7 | :show-inheritance: 8 | :members: 9 | .. autoclass:: basana.external.bitstamp.order_book.OrderBook 10 | :members: 11 | .. autoclass:: basana.external.bitstamp.order_book.Entry 12 | :members: 13 | -------------------------------------------------------------------------------- /docs/bitstamp_orders.rst: -------------------------------------------------------------------------------- 1 | basana.external.bitstamp.orders 2 | =============================== 3 | 4 | .. module:: basana.external.bitstamp.orders 5 | 6 | .. autoclass:: basana.external.bitstamp.orders.OrderEvent 7 | :show-inheritance: 8 | :members: 9 | .. autoclass:: basana.external.bitstamp.orders.Order 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/bitstamp_trades.rst: -------------------------------------------------------------------------------- 1 | basana.external.bitstamp.trades 2 | =============================== 3 | 4 | .. module:: basana.external.bitstamp.trades 5 | 6 | .. autoclass:: basana.external.bitstamp.trades.TradeEvent 7 | :show-inheritance: 8 | :members: 9 | .. autoclass:: basana.external.bitstamp.trades.Trade 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = 'Basana' 10 | copyright = '2022, Gabriel Martin Becedillas Ruiz' 11 | author = 'Gabriel Martin Becedillas Ruiz' 12 | 13 | # -- General configuration --------------------------------------------------- 14 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 15 | 16 | extensions = [ 17 | 'sphinx.ext.duration', 18 | 'sphinx.ext.doctest', 19 | 'sphinx.ext.autodoc', 20 | ] 21 | 22 | templates_path = ['_templates'] 23 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 24 | 25 | autodoc_typehints = 'description' 26 | autodoc_typehints_format = 'short' 27 | 28 | # -- Options for HTML output ------------------------------------------------- 29 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 30 | 31 | html_theme = 'alabaster' 32 | html_static_path = ['_static'] 33 | html_theme_options = { 34 | 'github_user': 'gbeced', 35 | 'github_repo': 'basana', 36 | 'github_banner': 'false', 37 | 'github_type': 'star', 38 | 'github_count': 'true', 39 | } 40 | -------------------------------------------------------------------------------- /docs/help.rst: -------------------------------------------------------------------------------- 1 | Help 2 | ==== 3 | 4 | You can ask questions and seek help with using Basana in the `discussions area on GitHub `_. 5 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. Basana documentation master file, created by 2 | sphinx-quickstart on Wed Mar 8 13:07:56 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to Basana's documentation! 7 | ================================== 8 | 9 | **Basana** is a Python **async and event driven** framework for **algorithmic trading**, with a focus on **crypto currencies**. 10 | 11 | The framework has 3 main components: 12 | 13 | * The core, where basic abstractions like events, event sources and the event dispatcher live. 14 | * A backtesting exchange that you can use to validate your strategies before using real money. 15 | * External integrations, where you'll find support for live trading at `Binance `_ and 16 | `Bitstamp `_ crypto currency exchanges. 17 | 18 | Basana doesn't ship with technical indicators. The `examples at GitHub `_ 19 | take advantage of `TALIpp `_ which is a good fit for event driven and real time applications, 20 | but you're free to use any other library that you prefer. 21 | 22 | Check out the :doc:`quickstart` section for further information, including how to :ref:`install ` the package, 23 | do a :ref:`backtest ` and :ref:`live trade `. 24 | 25 | Contents 26 | -------- 27 | 28 | .. toctree:: 29 | :maxdepth: 2 30 | 31 | quickstart 32 | api 33 | help 34 | 35 | Indices and tables 36 | ------------------ 37 | 38 | * :ref:`genindex` 39 | * :ref:`modindex` 40 | * :ref:`search` 41 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "basana" 3 | version = "1.7.1" 4 | homepage = "https://github.com/gbeced/basana" 5 | repository = "https://github.com/gbeced/basana" 6 | documentation = "https://basana.readthedocs.io/en/latest/" 7 | description = "A Python async and event driven framework for algorithmic trading, with a focus on crypto currencies." 8 | authors = ["Gabriel Becedillas "] 9 | license = "Apache-2.0" 10 | packages = [{include = "basana"}] 11 | 12 | [tool.poetry.dependencies] 13 | python = "^3.9" 14 | aiohttp = {extras = ["speedups"], version = "^3.10"} 15 | python-dateutil = "^2.9" 16 | # Optional dependencies, some of which are included in the below `extras`. They can be opted into by apps. 17 | plotly = {version = "^5.14.1", optional = true} 18 | kaleido = {version = "0.2.1", optional = true} 19 | 20 | [tool.poetry.extras] 21 | charts = ["plotly", "kaleido"] 22 | 23 | [tool.poetry.group.dev.dependencies] 24 | aioresponses = "^0.7.7" 25 | flake8 = "^7.1" 26 | mypy = "^1.11" 27 | pytest = "^8.3" 28 | pytest-cov = "^5.0" 29 | pytest-mock = "^3.14" 30 | talipp = "^2.4" 31 | types-python-dateutil = "^2.9" 32 | websockets = "^13" 33 | pandas = "^2.2" 34 | statsmodels = "^0.14" 35 | 36 | [tool.poetry.group.docs.dependencies] 37 | sphinx = {version = "^8.1", markers = "python_version >= '3.10'"} 38 | sphinx-rtd-theme = {version = "^3.0", markers = "python_version >= '3.10'"} 39 | 40 | [build-system] 41 | requires = ["poetry-core"] 42 | build-backend = "poetry.core.masonry.api" 43 | 44 | -------------------------------------------------------------------------------- /samples/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /samples/backtest_bbands.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Bars can be downloaded using this command: 18 | # python -m basana.external.binance.tools.download_bars -c BTC/USDT -p 1d -s 2021-01-01 -e 2021-12-31 \ 19 | # -o binance_btcusdt_day.csv 20 | 21 | from decimal import Decimal 22 | import asyncio 23 | import datetime 24 | import logging 25 | 26 | from basana.backtesting import charts, lending 27 | from basana.core.logs import StructuredMessage 28 | from basana.external.binance import csv 29 | import basana as bs 30 | import basana.backtesting.exchange as backtesting_exchange 31 | 32 | from samples.backtesting import position_manager 33 | from samples.strategies import bbands 34 | 35 | 36 | async def main(): 37 | logging.basicConfig(level=logging.INFO, format="[%(asctime)s %(levelname)s] %(message)s") 38 | 39 | event_dispatcher = bs.backtesting_dispatcher() 40 | pair = bs.Pair("BTC", "USDT") 41 | position_amount = Decimal(1000) 42 | stop_loss_pct = Decimal(5) 43 | 44 | # We'll be opening short positions so we need to set a lending strategy when initializing the exchange. 45 | lending_strategy = lending.MarginLoans(pair.quote_symbol, default_conditions=lending.MarginLoanConditions( 46 | interest_symbol=pair.quote_symbol, interest_percentage=Decimal("7"), 47 | interest_period=datetime.timedelta(days=365), min_interest=Decimal("0.01"), 48 | margin_requirement=Decimal("0.5") 49 | )) 50 | exchange = backtesting_exchange.Exchange( 51 | event_dispatcher, 52 | initial_balances={pair.quote_symbol: Decimal(1200)}, 53 | lending_strategy=lending_strategy, 54 | ) 55 | exchange.set_symbol_precision(pair.base_symbol, 8) 56 | exchange.set_symbol_precision(pair.quote_symbol, 2) 57 | exchange.add_bar_source(csv.BarSource(pair, "binance_btcusdt_day.csv", "1d")) 58 | 59 | # Connect the strategy to the bar events from the exchange. 60 | strategy = bbands.Strategy(event_dispatcher, period=30, std_dev=2) 61 | exchange.subscribe_to_bar_events(pair, strategy.on_bar_event) 62 | 63 | # Connect the position manager to different types of events. 64 | position_mgr = position_manager.PositionManager( 65 | exchange, position_amount, pair.quote_symbol, stop_loss_pct 66 | ) 67 | strategy.subscribe_to_trading_signals(position_mgr.on_trading_signal) 68 | exchange.subscribe_to_bar_events(pair, position_mgr.on_bar_event) 69 | exchange.subscribe_to_order_events(position_mgr.on_order_event) 70 | 71 | # Setup chart. 72 | chart = charts.LineCharts(exchange) 73 | chart.add_pair(pair) 74 | chart.add_pair_indicator( 75 | "Upper", pair, lambda _: strategy.bb[-1].ub if len(strategy.bb) and strategy.bb[-1] else None 76 | ) 77 | chart.add_pair_indicator( 78 | "Central", pair, lambda _: strategy.bb[-1].cb if len(strategy.bb) and strategy.bb[-1] else None 79 | ) 80 | chart.add_pair_indicator( 81 | "Lower", pair, lambda _: strategy.bb[-1].lb if len(strategy.bb) and strategy.bb[-1] else None 82 | ) 83 | chart.add_balance(pair.base_symbol) 84 | chart.add_balance(pair.quote_symbol) 85 | chart.add_portfolio_value(pair.quote_symbol) 86 | 87 | # Run the backtest. 88 | await event_dispatcher.run() 89 | 90 | # Log balances. 91 | balances = await exchange.get_balances() 92 | for currency, balance in balances.items(): 93 | logging.info(StructuredMessage(f"{currency} balance", available=balance.available)) 94 | 95 | chart.show() 96 | 97 | 98 | if __name__ == "__main__": 99 | asyncio.run(main()) 100 | -------------------------------------------------------------------------------- /samples/backtest_rsi.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Bars can be downloaded using this command: 18 | # python -m basana.external.bitstamp.tools.download_bars -c BTC/USD -p 1d -s 2021-01-01 -e 2021-12-31 \ 19 | # -o bitstamp_btcusd_day.csv 20 | 21 | from decimal import Decimal 22 | import asyncio 23 | import logging 24 | 25 | from basana.backtesting import charts 26 | from basana.external.bitstamp import csv 27 | import basana as bs 28 | import basana.backtesting.exchange as backtesting_exchange 29 | 30 | from samples.backtesting import position_manager 31 | from samples.strategies import rsi 32 | 33 | 34 | async def main(): 35 | logging.basicConfig(level=logging.INFO, format="[%(asctime)s %(levelname)s] %(message)s") 36 | 37 | event_dispatcher = bs.backtesting_dispatcher() 38 | pair = bs.Pair("BTC", "USD") 39 | exchange = backtesting_exchange.Exchange( 40 | event_dispatcher, 41 | initial_balances={"BTC": Decimal(0), "USD": Decimal(1200)} 42 | ) 43 | exchange.set_symbol_precision(pair.base_symbol, 8) 44 | exchange.set_symbol_precision(pair.quote_symbol, 2) 45 | 46 | # Connect the strategy to the bar events from the exchange. 47 | oversold_level = 30 48 | overbought_level = 70 49 | strategy = rsi.Strategy(event_dispatcher, 7, oversold_level, overbought_level) 50 | exchange.subscribe_to_bar_events(pair, strategy.on_bar_event) 51 | 52 | # Connect the position manager to different types of events. Borrowing is disabled in this example. 53 | position_mgr = position_manager.PositionManager( 54 | exchange, position_amount=Decimal(1000), quote_symbol=pair.quote_symbol, stop_loss_pct=Decimal(6), 55 | borrowing_disabled=True 56 | ) 57 | strategy.subscribe_to_trading_signals(position_mgr.on_trading_signal) 58 | exchange.subscribe_to_bar_events(pair, position_mgr.on_bar_event) 59 | exchange.subscribe_to_order_events(position_mgr.on_order_event) 60 | 61 | # Load bars from the CSV file. 62 | exchange.add_bar_source(csv.BarSource(pair, "bitstamp_btcusd_day.csv", "1d")) 63 | 64 | # Setup chart. 65 | chart = charts.LineCharts(exchange) 66 | chart.add_pair(pair) 67 | chart.add_portfolio_value(pair.quote_symbol) 68 | chart.add_custom("RSI", "RSI", charts.DataPointFromSequence(strategy.rsi)) 69 | chart.add_custom("RSI", "Overbought", lambda _: overbought_level) 70 | chart.add_custom("RSI", "Oversold", lambda _: oversold_level) 71 | 72 | # Run the backtest. 73 | await event_dispatcher.run() 74 | 75 | # Log balances. 76 | balances = await exchange.get_balances() 77 | for currency, balance in balances.items(): 78 | logging.info("%s balance: %s", currency, balance.available) 79 | 80 | chart.show() 81 | 82 | if __name__ == "__main__": 83 | asyncio.run(main()) 84 | -------------------------------------------------------------------------------- /samples/backtest_sma.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | # Bars can be downloaded using this command: 18 | # python -m basana.external.binance.tools.download_bars -c BTC/USDT -p 1d -s 2021-01-01 -e 2021-12-31 \ 19 | # -o binance_btcusdt_day.csv 20 | 21 | from decimal import Decimal 22 | import asyncio 23 | import logging 24 | 25 | from basana.backtesting import charts 26 | from basana.core.logs import StructuredMessage 27 | from basana.external.binance import csv 28 | import basana as bs 29 | import basana.backtesting.exchange as backtesting_exchange 30 | 31 | from samples.backtesting import position_manager 32 | from samples.strategies import sma 33 | 34 | 35 | async def main(): 36 | logging.basicConfig(level=logging.INFO, format="[%(asctime)s %(levelname)s] %(message)s") 37 | 38 | event_dispatcher = bs.backtesting_dispatcher() 39 | pair = bs.Pair("BTC", "USDT") 40 | exchange = backtesting_exchange.Exchange( 41 | event_dispatcher, 42 | initial_balances={pair.quote_symbol: Decimal(1200)}, 43 | ) 44 | exchange.set_symbol_precision(pair.base_symbol, 8) 45 | exchange.set_symbol_precision(pair.quote_symbol, 2) 46 | 47 | # Connect the strategy to the bar events from the exchange. 48 | strategy = sma.Strategy(event_dispatcher, period=12) 49 | exchange.subscribe_to_bar_events(pair, strategy.on_bar_event) 50 | 51 | # Connect the position manager to different types of events. Borrowing is disabled in this example. 52 | position_mgr = position_manager.PositionManager( 53 | exchange, position_amount=Decimal(1000), quote_symbol=pair.quote_symbol, stop_loss_pct=Decimal(5), 54 | borrowing_disabled=True 55 | ) 56 | strategy.subscribe_to_trading_signals(position_mgr.on_trading_signal) 57 | exchange.subscribe_to_bar_events(pair, position_mgr.on_bar_event) 58 | exchange.subscribe_to_order_events(position_mgr.on_order_event) 59 | 60 | # Load bars from the CSV file. 61 | exchange.add_bar_source(csv.BarSource(pair, "binance_btcusdt_day.csv", "1d")) 62 | 63 | # Setup chart. 64 | chart = charts.LineCharts(exchange) 65 | chart.add_pair(pair) 66 | chart.add_pair_indicator("SMA", pair, charts.DataPointFromSequence(strategy.sma)) 67 | chart.add_portfolio_value(pair.quote_symbol) 68 | 69 | # Run the backtest. 70 | await event_dispatcher.run() 71 | 72 | # Log balances. 73 | balances = await exchange.get_balances() 74 | for currency, balance in balances.items(): 75 | logging.info(StructuredMessage( 76 | f"{currency} balance", available=balance.available 77 | )) 78 | 79 | chart.show() 80 | 81 | 82 | if __name__ == "__main__": 83 | asyncio.run(main()) 84 | -------------------------------------------------------------------------------- /samples/backtesting/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /samples/binance/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /samples/binance_bbands.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import asyncio 19 | import logging 20 | 21 | from basana.external.binance import exchange as binance_exchange 22 | import basana as bs 23 | 24 | from samples.binance import position_manager 25 | from samples.strategies import bbands 26 | 27 | 28 | async def main(): 29 | logging.basicConfig(level=logging.INFO, format="[%(asctime)s %(levelname)s] %(message)s") 30 | 31 | event_dispatcher = bs.realtime_dispatcher() 32 | pair = bs.Pair("ETH", "USDT") 33 | position_amount = Decimal(100) 34 | stop_loss_pct = Decimal(5) 35 | checkpoint_fname = "binance_bbands_positions.json" 36 | api_key = "YOUR_API_KEY" 37 | api_secret = "YOUR_API_SECRET" 38 | 39 | exchange = binance_exchange.Exchange(event_dispatcher, api_key=api_key, api_secret=api_secret) 40 | 41 | # Connect the strategy to the bar events from the exchange. 42 | strategy = bbands.Strategy(event_dispatcher, period=20, std_dev=1.5) 43 | exchange.subscribe_to_bar_events(pair, "1m", strategy.on_bar_event) 44 | 45 | # We'll be using the spot account, so there will be no short positions opened. 46 | position_mgr = position_manager.SpotAccountPositionManager( 47 | exchange, position_amount, pair.quote_symbol, stop_loss_pct, checkpoint_fname 48 | ) 49 | # Connect the position manager to the strategy signals and to bar events just for logging. 50 | strategy.subscribe_to_trading_signals(position_mgr.on_trading_signal) 51 | exchange.subscribe_to_bar_events(pair, "1m", position_mgr.on_bar_event) 52 | 53 | await event_dispatcher.run() 54 | 55 | 56 | if __name__ == "__main__": 57 | asyncio.run(main()) 58 | -------------------------------------------------------------------------------- /samples/binance_websockets.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import asyncio 18 | import logging 19 | 20 | from basana.external.binance import exchange as binance_exchange 21 | import basana as bs 22 | 23 | 24 | async def on_bar_event(bar_event: bs.BarEvent): 25 | logging.info( 26 | "Bar event: pair=%s open=%s high=%s low=%s close=%s volume=%s", 27 | bar_event.bar.pair, bar_event.bar.open, bar_event.bar.high, bar_event.bar.low, bar_event.bar.close, 28 | bar_event.bar.volume 29 | ) 30 | 31 | 32 | async def on_order_book_event(order_book_event: binance_exchange.OrderBookEvent): 33 | logging.info( 34 | "Order book event: pair=%s bid=%s ask=%s", 35 | order_book_event.order_book.pair, order_book_event.order_book.bids[0].price, 36 | order_book_event.order_book.asks[0].price 37 | ) 38 | 39 | 40 | async def on_trade_event(trade_event: binance_exchange.TradeEvent): 41 | logging.info( 42 | "Trade event: pair=%s price=%s amount=%s", 43 | trade_event.trade.pair, trade_event.trade.price, trade_event.trade.amount 44 | ) 45 | 46 | 47 | async def on_order_event(event): 48 | logging.info( 49 | "Order event: id=%s status=%s amount_filled=%s fees=%s", 50 | event.order_update.id, event.order_update.status, event.order_update.amount_filled, event.order_update.fees 51 | ) 52 | 53 | 54 | async def main(): 55 | logging.basicConfig(level=logging.INFO, format="[%(asctime)s %(levelname)s] %(message)s") 56 | event_dispatcher = bs.realtime_dispatcher() 57 | exchange = binance_exchange.Exchange( 58 | event_dispatcher, 59 | # api_key="YOUR_API_KEY", 60 | # api_secret="YOUR_API_SECRET" 61 | ) 62 | 63 | pairs = [ 64 | bs.Pair("BTC", "USDT"), 65 | bs.Pair("ETH", "USDT"), 66 | ] 67 | for pair in pairs: 68 | exchange.subscribe_to_bar_events(pair, "1m", on_bar_event) 69 | exchange.subscribe_to_order_book_events(pair, on_order_book_event) 70 | exchange.subscribe_to_trade_events(pair, on_trade_event) 71 | 72 | # Uncomment the following lines if you want to subscribe to order events. This requires the API key and secret to 73 | # be set. 74 | # exchange.spot_account.subscribe_to_order_events(on_order_event) 75 | # exchange.cross_margin_account.subscribe_to_order_events(on_order_event) 76 | # for pair in pairs: 77 | # exchange.isolated_margin_account.subscribe_to_order_events(pair, on_order_event) 78 | 79 | await event_dispatcher.run() 80 | 81 | 82 | if __name__ == "__main__": 83 | asyncio.run(main()) 84 | -------------------------------------------------------------------------------- /samples/bitstamp_websockets.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import asyncio 18 | import logging 19 | 20 | from basana.external.bitstamp import exchange as bitstamp_exchange 21 | import basana as bs 22 | 23 | 24 | async def on_bar_event(bar_event: bs.BarEvent): 25 | logging.info( 26 | "Bar event: pair=%s open=%s high=%s low=%s close=%s volume=%s", 27 | bar_event.bar.pair, bar_event.bar.open, bar_event.bar.high, bar_event.bar.low, bar_event.bar.close, 28 | bar_event.bar.volume 29 | ) 30 | 31 | 32 | async def on_order_book_event(order_book_event: bitstamp_exchange.OrderBookEvent): 33 | logging.info( 34 | "Order book event: pair=%s bid=%s ask=%s", 35 | order_book_event.order_book.pair, order_book_event.order_book.bids[0].price, 36 | order_book_event.order_book.asks[0].price 37 | ) 38 | 39 | 40 | async def on_trade_event(trade_event: bitstamp_exchange.TradeEvent): 41 | logging.info( 42 | "Trade event: pair=%s price=%s amount=%s", 43 | trade_event.trade.pair, trade_event.trade.price, trade_event.trade.amount 44 | ) 45 | 46 | 47 | async def on_order_event(event: bitstamp_exchange.OrderEvent): 48 | logging.info( 49 | "Order event: id=%s amount_filled=%s json=%s", 50 | event.order.id, event.order.amount_filled, event.order.json 51 | ) 52 | 53 | 54 | async def main(): 55 | logging.basicConfig(level=logging.INFO, format="[%(asctime)s %(levelname)s] %(message)s") 56 | event_dispatcher = bs.realtime_dispatcher() 57 | exchange = bitstamp_exchange.Exchange(event_dispatcher) 58 | 59 | pairs = [ 60 | bs.Pair("BTC", "USD"), 61 | bs.Pair("ETH", "USD"), 62 | ] 63 | for pair in pairs: 64 | exchange.subscribe_to_bar_events(pair, 60, on_bar_event) 65 | exchange.subscribe_to_order_book_events(pair, on_order_book_event) 66 | exchange.subscribe_to_public_trade_events(pair, on_trade_event) 67 | exchange.subscribe_to_public_order_events(pair, on_order_event) 68 | 69 | await event_dispatcher.run() 70 | 71 | 72 | if __name__ == "__main__": 73 | asyncio.run(main()) 74 | -------------------------------------------------------------------------------- /samples/strategies/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /samples/strategies/bbands.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from talipp.indicators import BB 18 | 19 | import basana as bs 20 | 21 | 22 | # Strategy based on Bollinger Bands: https://www.investopedia.com/articles/trading/07/bollinger.asp 23 | class Strategy(bs.TradingSignalSource): 24 | def __init__(self, dispatcher: bs.EventDispatcher, period: int, std_dev: float): 25 | super().__init__(dispatcher) 26 | self.bb = BB(period, std_dev) 27 | self._values = (None, None) 28 | 29 | async def on_bar_event(self, bar_event: bs.BarEvent): 30 | # Feed the technical indicator. 31 | value = float(bar_event.bar.close) 32 | self.bb.add(value) 33 | 34 | # Keep the last two values to check if there is a crossover. 35 | self._values = (self._values[-1], value) 36 | 37 | # Is the indicator ready ? 38 | if len(self.bb) < 2 or self.bb[-2] is None: 39 | return 40 | 41 | # Go long when price moves below lower band. 42 | if self._values[-2] >= self.bb[-2].lb and self._values[-1] < self.bb[-1].lb: 43 | self.push(bs.TradingSignal(bar_event.when, bs.Position.LONG, bar_event.bar.pair)) 44 | # Go short when price moves above upper band. 45 | elif self._values[-2] <= self.bb[-2].ub and self._values[-1] > self.bb[-1].ub: 46 | self.push(bs.TradingSignal(bar_event.when, bs.Position.SHORT, bar_event.bar.pair)) 47 | # Go neutral when the price touches the middle band. 48 | elif self._values[-2] < self.bb[-2].cb and self._values[-1] >= self.bb[-1].cb \ 49 | or self._values[-2] > self.bb[-2].cb and self._values[-1] <= self.bb[-1].cb: 50 | self.push(bs.TradingSignal(bar_event.when, bs.Position.NEUTRAL, bar_event.bar.pair)) 51 | -------------------------------------------------------------------------------- /samples/strategies/dmac.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from talipp.indicators import EMA 18 | 19 | import basana as bs 20 | 21 | 22 | # Strategy based on Dual Moving Average Crossover. 23 | class Strategy(bs.TradingSignalSource): 24 | def __init__(self, dispatcher: bs.EventDispatcher, short_term_period: int, long_term_period: int): 25 | super().__init__(dispatcher) 26 | self._st_sma = EMA(period=short_term_period) 27 | self._lt_sma = EMA(period=long_term_period) 28 | 29 | async def on_bar_event(self, bar_event: bs.BarEvent): 30 | # Feed the technical indicators. 31 | value = float(bar_event.bar.close) 32 | self._st_sma.add(value) 33 | self._lt_sma.add(value) 34 | 35 | # Are MAs ready ? 36 | if len(self._st_sma) < 2 or len(self._lt_sma) < 2 \ 37 | or self._st_sma[-2] is None or self._lt_sma[-2] is None: 38 | return 39 | 40 | # Go long when short-term MA crosses above long-term MA. 41 | if self._st_sma[-2] <= self._lt_sma[-2] and self._st_sma[-1] > self._lt_sma[-1]: 42 | self.push(bs.TradingSignal(bar_event.when, bs.Position.LONG, bar_event.bar.pair)) 43 | # Go short when short-term MA crosses below long-term MA. 44 | elif self._st_sma[-2] >= self._lt_sma[-2] and self._st_sma[-1] < self._lt_sma[-1]: 45 | self.push(bs.TradingSignal(bar_event.when, bs.Position.SHORT, bar_event.bar.pair)) 46 | -------------------------------------------------------------------------------- /samples/strategies/rsi.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from talipp.indicators import RSI 18 | 19 | import basana as bs 20 | 21 | 22 | # Strategy based on RSI: https://www.investopedia.com/terms/r/rsi.asp 23 | class Strategy(bs.TradingSignalSource): 24 | def __init__(self, dispatcher: bs.EventDispatcher, period: int, oversold_level: float, overbought_level: float): 25 | super().__init__(dispatcher) 26 | self._oversold_level = oversold_level 27 | self._overbought_level = overbought_level 28 | self.rsi = RSI(period=period) 29 | 30 | async def on_bar_event(self, bar_event: bs.BarEvent): 31 | # Feed the technical indicator. 32 | self.rsi.add(float(bar_event.bar.close)) 33 | 34 | # Is the indicator ready ? 35 | if len(self.rsi) < 2 or self.rsi[-2] is None: 36 | return 37 | 38 | # Go long when RSI crosses below oversold level. 39 | if self.rsi[-2] >= self._oversold_level and self.rsi[-1] < self._oversold_level: 40 | self.push(bs.TradingSignal(bar_event.when, bs.Position.LONG, bar_event.bar.pair)) 41 | # Go short when RSI crosses above overbought level. 42 | elif self.rsi[-2] <= self._overbought_level and self.rsi[-1] > self._overbought_level: 43 | self.push(bs.TradingSignal(bar_event.when, bs.Position.SHORT, bar_event.bar.pair)) 44 | -------------------------------------------------------------------------------- /samples/strategies/sma.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from talipp.indicators import SMA 18 | 19 | import basana as bs 20 | 21 | 22 | class Strategy(bs.TradingSignalSource): 23 | def __init__(self, dispatcher: bs.EventDispatcher, period: int): 24 | super().__init__(dispatcher) 25 | self.sma = SMA(period) 26 | self._values = (None, None) 27 | 28 | async def on_bar_event(self, bar_event: bs.BarEvent): 29 | # Feed the technical indicator. 30 | value = float(bar_event.bar.close) 31 | self.sma.add(value) 32 | 33 | # Keep a small window of values to check if there is a crossover. 34 | self._values = (self._values[-1], value) 35 | 36 | # Is the indicator ready ? 37 | if len(self.sma) < 2 or self.sma[-2] is None: 38 | return 39 | 40 | # Go short if price crosses below SMA. 41 | if self._values[-2] >= self.sma[-2] and self._values[-1] < self.sma[-1]: 42 | self.push(bs.TradingSignal(bar_event.when, bs.Position.SHORT, bar_event.bar.pair)) 43 | # Go long if price crosses above SMA. 44 | elif self._values[-2] <= self.sma[-2] and self._values[-1] > self.sma[-1]: 45 | self.push(bs.TradingSignal(bar_event.when, bs.Position.LONG, bar_event.bar.pair)) 46 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [tool:pytest] 2 | testpaths = tests 3 | 4 | [flake8] 5 | max-line-length=120 6 | # E126: continuation line over-indented for hanging indent. Ignored because imports look bad when not over-indented. 7 | # E221: multiple spaces before cursor. Ignored to allow aligning '=' signs. 8 | # E241: multiple spaces after ':' or ','. Ignored to allow aligning values in dicts. 9 | # W503: line break before binary operator 10 | ignore=E126,E221,E241,W503 11 | exclude= 12 | ./.venv 13 | ./docs 14 | ./pocs 15 | ./venv 16 | 17 | [coverage:report] 18 | fail_under = 100 19 | show_missing = True 20 | skip_covered = True 21 | include = 22 | basana/* 23 | exclude_lines = 24 | raise NotImplementedError() 25 | pragma: no cover 26 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | from invoke import task 2 | 3 | cmd_echo = True 4 | 5 | 6 | @task 7 | def clean(c): 8 | patterns = [ 9 | "__pycache__", 10 | ".coverage", 11 | ".pytest_cache", 12 | ".mypy_cache", 13 | "*.pyc", 14 | ] 15 | for pattern in patterns: 16 | c.run("find . -d -name '{}' -exec rm -rf {{}} \\;".format(pattern), pty=True, echo=cmd_echo) 17 | 18 | with c.cd("docs"): 19 | c.run("poetry run -- make clean", pty=True, echo=cmd_echo) 20 | 21 | 22 | @task 23 | def lint(c): 24 | c.run("poetry run -- mypy basana", pty=True, echo=cmd_echo) 25 | c.run("poetry run -- flake8", pty=True, echo=cmd_echo) 26 | 27 | 28 | @task(lint) 29 | def test(c, html_report=False): 30 | # Execute testcases. 31 | cmd = "poetry run -- pytest -vv --cov --cov-config=setup.cfg --durations=10" 32 | if html_report: 33 | cmd += " --cov-report=html:cov_html" 34 | c.run(cmd, pty=True, echo=cmd_echo) 35 | 36 | 37 | @task 38 | def create_virtualenv(c, all_extras=True): 39 | cmd = ["poetry", "install"] 40 | if all_extras: 41 | cmd.append("--all-extras") 42 | c.run(" ".join(cmd), pty=True, echo=cmd_echo) 43 | 44 | 45 | @task 46 | def build_docs(c): 47 | with c.cd("docs"): 48 | c.run("poetry run -- make html", pty=True, echo=cmd_echo) 49 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from tests.fixtures.binance import * # noqa: F401,F403 18 | from tests.fixtures.bitstamp import * # noqa: F401,F403 19 | from tests.fixtures.dispatcher import * # noqa: F401,F403 20 | -------------------------------------------------------------------------------- /tests/data/binance_btc_usdt_exchange_info.json: -------------------------------------------------------------------------------- 1 | { 2 | "timezone": "UTC", 3 | "serverTime": 1670769464853, 4 | "rateLimits": [ 5 | { 6 | "rateLimitType": "REQUEST_WEIGHT", 7 | "interval": "MINUTE", 8 | "intervalNum": 1, 9 | "limit": 1200 10 | }, 11 | { 12 | "rateLimitType": "ORDERS", 13 | "interval": "SECOND", 14 | "intervalNum": 10, 15 | "limit": 50 16 | }, 17 | { 18 | "rateLimitType": "ORDERS", 19 | "interval": "DAY", 20 | "intervalNum": 1, 21 | "limit": 160000 22 | }, 23 | { 24 | "rateLimitType": "RAW_REQUESTS", 25 | "interval": "MINUTE", 26 | "intervalNum": 5, 27 | "limit": 6100 28 | } 29 | ], 30 | "exchangeFilters": [], 31 | "symbols": [ 32 | { 33 | "symbol": "BTCUSDT", 34 | "status": "TRADING", 35 | "baseAsset": "BTC", 36 | "baseAssetPrecision": 8, 37 | "quoteAsset": "USDT", 38 | "quotePrecision": 8, 39 | "quoteAssetPrecision": 8, 40 | "baseCommissionPrecision": 8, 41 | "quoteCommissionPrecision": 8, 42 | "orderTypes": [ 43 | "LIMIT", 44 | "LIMIT_MAKER", 45 | "MARKET", 46 | "STOP_LOSS_LIMIT", 47 | "TAKE_PROFIT_LIMIT" 48 | ], 49 | "icebergAllowed": true, 50 | "ocoAllowed": true, 51 | "quoteOrderQtyMarketAllowed": true, 52 | "allowTrailingStop": true, 53 | "cancelReplaceAllowed": true, 54 | "isSpotTradingAllowed": true, 55 | "isMarginTradingAllowed": true, 56 | "filters": [ 57 | { 58 | "filterType": "PRICE_FILTER", 59 | "minPrice": "0.01000000", 60 | "maxPrice": "1000000.00000000", 61 | "tickSize": "0.01000000" 62 | }, 63 | { 64 | "filterType": "LOT_SIZE", 65 | "minQty": "0.00001000", 66 | "maxQty": "9000.00000000", 67 | "stepSize": "0.00001000" 68 | }, 69 | { 70 | "filterType": "MIN_NOTIONAL", 71 | "minNotional": "10.00000000", 72 | "applyToMarket": true, 73 | "avgPriceMins": 5 74 | }, 75 | { 76 | "filterType": "ICEBERG_PARTS", 77 | "limit": 10 78 | }, 79 | { 80 | "filterType": "MARKET_LOT_SIZE", 81 | "minQty": "0.00000000", 82 | "maxQty": "328.37710865", 83 | "stepSize": "0.00000000" 84 | }, 85 | { 86 | "filterType": "TRAILING_DELTA", 87 | "minTrailingAboveDelta": 10, 88 | "maxTrailingAboveDelta": 2000, 89 | "minTrailingBelowDelta": 10, 90 | "maxTrailingBelowDelta": 2000 91 | }, 92 | { 93 | "filterType": "PERCENT_PRICE_BY_SIDE", 94 | "bidMultiplierUp": "5", 95 | "bidMultiplierDown": "0.2", 96 | "askMultiplierUp": "5", 97 | "askMultiplierDown": "0.2", 98 | "avgPriceMins": 5 99 | }, 100 | { 101 | "filterType": "MAX_NUM_ORDERS", 102 | "maxNumOrders": 200 103 | }, 104 | { 105 | "filterType": "MAX_NUM_ALGO_ORDERS", 106 | "maxNumAlgoOrders": 5 107 | } 108 | ], 109 | "permissions": [ 110 | "SPOT", 111 | "MARGIN", 112 | "TRD_GRP_004", 113 | "TRD_GRP_005" 114 | ] 115 | } 116 | ] 117 | } 118 | -------------------------------------------------------------------------------- /tests/data/bitstamp_btcusd_day_2015.csv.utf16: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbeced/basana/8f70b1ebcf81e5bedefc175048eb434310082c0b/tests/data/bitstamp_btcusd_day_2015.csv.utf16 -------------------------------------------------------------------------------- /tests/fixtures/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gbeced/basana/8f70b1ebcf81e5bedefc175048eb434310082c0b/tests/fixtures/__init__.py -------------------------------------------------------------------------------- /tests/fixtures/binance.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import aioresponses 18 | import pytest 19 | 20 | from basana.core import token_bucket 21 | from basana.external.binance import exchange 22 | 23 | 24 | @pytest.fixture() 25 | def binance_http_api_mock(): 26 | with aioresponses.aioresponses(passthrough_unmatched=True) as m: 27 | yield m 28 | 29 | 30 | @pytest.fixture() 31 | def binance_exchange(realtime_dispatcher): 32 | return exchange.Exchange( 33 | realtime_dispatcher, "api_key", "api_secret", tb=token_bucket.TokenBucketLimiter(10, 1, 0), 34 | config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}} 35 | ) 36 | -------------------------------------------------------------------------------- /tests/fixtures/bitstamp.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import aioresponses 18 | import pytest 19 | 20 | 21 | @pytest.fixture() 22 | def bitstamp_http_api_mock(): 23 | with aioresponses.aioresponses(passthrough=["ws://127.0.0.1"]) as m: 24 | yield m 25 | -------------------------------------------------------------------------------- /tests/fixtures/dispatcher.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import pytest 18 | 19 | from basana.core import dispatcher 20 | 21 | 22 | @pytest.fixture() 23 | def realtime_dispatcher(): 24 | return dispatcher.realtime_dispatcher() 25 | 26 | 27 | @pytest.fixture() 28 | def backtesting_dispatcher(): 29 | return dispatcher.backtesting_dispatcher() 30 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import asyncio 18 | import contextlib 19 | import json 20 | import os 21 | import tempfile 22 | import time 23 | 24 | from basana.core import helpers 25 | 26 | 27 | def abs_data_path(filename): 28 | return os.path.join(os.path.split(__file__)[0], "data", filename) 29 | 30 | 31 | def load_json(filename): 32 | return json.load(open(abs_data_path(filename))) 33 | 34 | 35 | def safe_round(v, precision): 36 | if v is not None: 37 | v = helpers.round_decimal(v, precision) 38 | return v 39 | 40 | 41 | async def wait_until(condition, timeout=10, retry_after=0.25): 42 | begin = time.time() 43 | ret = condition() 44 | while not ret and (time.time() - begin) < timeout: 45 | await asyncio.sleep(retry_after) 46 | ret = condition() 47 | return ret 48 | 49 | 50 | async def wait_caplog(text, caplog, timeout=10, retry_after=0.25): 51 | return await wait_until(lambda: text in caplog.text, timeout=timeout, retry_after=retry_after) 52 | 53 | 54 | def assert_expected_attrs(object, expected): 55 | for key, expected_value in expected.items(): 56 | actual_value = getattr(object, key) 57 | assert actual_value == expected_value, "Mismatch in {}. {} != {}".format(key, actual_value, expected_value) 58 | 59 | 60 | def is_sorted(seq): 61 | return all(seq[i] <= seq[i + 1] for i in range(len(seq) - 1)) 62 | 63 | 64 | @contextlib.contextmanager 65 | def temp_file_name(suffix: str = None, delete: bool = True) -> str: 66 | # On Windows the name can't used to open the file a second time. That is why we're using this only to generate 67 | # the file name. 68 | with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp_file: 69 | pass 70 | try: 71 | yield tmp_file.name 72 | finally: 73 | if delete: 74 | os.remove(tmp_file.name) 75 | -------------------------------------------------------------------------------- /tests/test_backtesting_charts.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import asyncio 19 | import datetime 20 | import os 21 | 22 | import pytest 23 | 24 | from . import helpers 25 | from basana.backtesting import charts, exchange 26 | from basana.core.pair import Pair 27 | from basana.external.yahoo import bars 28 | 29 | 30 | @pytest.mark.parametrize("order_plan", [ 31 | { 32 | datetime.date(2000, 1, 4): [ 33 | # Buy market. 34 | lambda e: e.create_market_order(exchange.OrderOperation.BUY, Pair("ORCL", "USD"), Decimal("2")), 35 | ], 36 | datetime.date(2000, 1, 14): [ 37 | # Sell market. 38 | lambda e: e.create_market_order(exchange.OrderOperation.SELL, Pair("ORCL", "USD"), Decimal("1")), 39 | ], 40 | }, 41 | ]) 42 | def test_save_line_chart(order_plan, backtesting_dispatcher, caplog): 43 | e = exchange.Exchange( 44 | backtesting_dispatcher, 45 | { 46 | "USD": Decimal("1e6"), 47 | "BTC": Decimal("0"), 48 | }, 49 | ) 50 | pair = Pair("ORCL", "USD") 51 | line_charts = charts.LineCharts(e) 52 | line_charts.add_pair(pair) 53 | line_charts.add_balance("USD") 54 | line_charts.add_pair_indicator("CONSTANT", pair, charts.DataPointFromSequence([100])) 55 | line_charts.add_portfolio_value("USD") 56 | line_charts.add_portfolio_value("INVALID") 57 | line_charts.add_custom("CUSTOM", "line_name", lambda _: 3) 58 | 59 | async def on_bar(bar_event): 60 | order_requests = order_plan.get(bar_event.when.date(), []) 61 | for create_order_fun in order_requests: 62 | created_order = await create_order_fun(e) 63 | assert created_order is not None 64 | 65 | async def impl(): 66 | e.add_bar_source(bars.CSVBarSource(pair, helpers.abs_data_path("orcl-2000-yahoo-sorted.csv"))) 67 | e.subscribe_to_bar_events(pair, on_bar) 68 | 69 | await backtesting_dispatcher.run() 70 | 71 | with helpers.temp_file_name(suffix=".png") as tmp_file_name: 72 | line_charts.save(tmp_file_name) 73 | assert os.stat(tmp_file_name).st_size > 100 74 | 75 | asyncio.run(impl()) 76 | -------------------------------------------------------------------------------- /tests/test_backtesting_config.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import pytest 18 | 19 | from basana.backtesting.config import Config, SymbolInfo 20 | from basana.core.pair import Pair, PairInfo 21 | 22 | 23 | @pytest.fixture 24 | def config(): 25 | ret = Config() 26 | ret.set_symbol_info("USD", SymbolInfo(precision=2)) 27 | ret.set_pair_info(Pair("BTC", "USD"), PairInfo(base_precision=8, quote_precision=2)) 28 | return ret 29 | 30 | 31 | def test_pair_info(config): 32 | pair_info = config.get_pair_info(Pair("BTC", "USD")) 33 | assert pair_info.base_precision == 8 34 | with pytest.raises(Exception, match="No config"): 35 | config.get_pair_info(Pair("BTC", "USDT")) 36 | 37 | 38 | def test_symbol_info(config): 39 | symbol_info = config.get_symbol_info("USD") 40 | assert symbol_info.precision == 2 41 | with pytest.raises(Exception, match="No config"): 42 | config.get_symbol_info("BTC") 43 | -------------------------------------------------------------------------------- /tests/test_backtesting_fees.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | 19 | from basana.backtesting import fees, orders 20 | from basana.backtesting.exchange import OrderOperation 21 | from basana.core import dt 22 | from basana.core.pair import Pair 23 | 24 | 25 | def test_percentage_fee_with_partial_fills(): 26 | fee_strategy = fees.Percentage(Decimal("1")) 27 | order = orders.MarketOrder("1", OrderOperation.BUY, Pair("BTC", "USD"), Decimal("0.01"), orders.OrderState.OPEN) 28 | 29 | # Fill #1 - A 0.009 fee gets rounded to 0.01 30 | balance_updates = { 31 | "BTC": Decimal("0.001"), 32 | "USD": Decimal("-0.9"), 33 | } 34 | assert fee_strategy.calculate_fees(order, balance_updates) == {"USD": Decimal("-0.009")} 35 | order.add_fill(dt.utc_now(), balance_updates, {"USD": Decimal("-0.01")}) 36 | 37 | # Fill #2 - A 0.008 fee gets rounded to 0.01 38 | balance_updates = { 39 | "BTC": Decimal("0.001"), 40 | "USD": Decimal("-0.9"), 41 | } 42 | assert fee_strategy.calculate_fees(order, balance_updates) == {"USD": Decimal("-0.008")} 43 | order.add_fill(dt.utc_now(), balance_updates, {"USD": Decimal("-0.01")}) 44 | 45 | # Fill #3 - Final fill. Total fees, prior to rounding, should be 0.118, but we charged 0.02 already, so the last 46 | # chunk, prior to rounding, should be 0.098. 47 | balance_updates = { 48 | "BTC": Decimal("0.008"), 49 | "USD": Decimal("-10"), 50 | } 51 | assert fee_strategy.calculate_fees(order, balance_updates) == {"USD": Decimal("-0.098")} 52 | order.add_fill(dt.utc_now(), balance_updates, {"USD": Decimal("-0.1")}) 53 | 54 | 55 | def test_percentage_fee_with_minium(): 56 | fee_strategy = fees.Percentage(Decimal("1"), min_fee=Decimal("5")) 57 | order = orders.MarketOrder("1", OrderOperation.BUY, Pair("BTC", "USD"), Decimal("0.1"), orders.OrderState.OPEN) 58 | 59 | balance_updates = { 60 | "BTC": Decimal("0.1"), 61 | "USD": Decimal("-50.15"), 62 | } 63 | assert fee_strategy.calculate_fees(order, balance_updates) == {"USD": Decimal("-5")} 64 | -------------------------------------------------------------------------------- /tests/test_backtesting_prices.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | 19 | import pytest 20 | 21 | from basana.backtesting import config, errors, prices 22 | from basana.core import dt 23 | from basana.core.bar import Bar, BarEvent 24 | from basana.core.pair import Pair, PairInfo 25 | 26 | 27 | def test_no_prices(): 28 | conf = config.Config() 29 | p = prices.Prices(bid_ask_spread_pct=Decimal("0.1"), config=conf) 30 | 31 | with pytest.raises(errors.NoPrice): 32 | p.get_bid_ask(Pair("BTC", "USDT")) 33 | with pytest.raises(errors.NoPrice): 34 | p.get_price(Pair("BTC", "USDT")) 35 | with pytest.raises(errors.NoPrice): 36 | p.convert(Decimal(1), "BTC", "USDT") 37 | 38 | 39 | def test_prices(): 40 | pair = Pair("BTC", "USDT") 41 | conf = config.Config() 42 | conf.set_pair_info(pair, PairInfo(8, 2)) 43 | p = prices.Prices(bid_ask_spread_pct=Decimal("1"), config=conf) 44 | 45 | now = dt.local_now() 46 | p.on_bar_event( 47 | BarEvent(now, Bar(now, pair, Decimal(10), Decimal(10), Decimal(10), Decimal(10), Decimal(10))) 48 | ) 49 | 50 | assert p.get_bid_ask(pair) == (Decimal("9.95"), Decimal("10.05")) 51 | assert p.get_price(pair) == Decimal(10) 52 | assert p.convert(Decimal(100), pair.base_symbol, pair.quote_symbol) == Decimal(1000) 53 | assert p.convert(Decimal(1000), pair.quote_symbol, pair.base_symbol) == Decimal(100) 54 | -------------------------------------------------------------------------------- /tests/test_backtesting_value_map.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | from decimal import Decimal 19 | 20 | import pytest 21 | 22 | from basana.backtesting.value_map import ValueMap 23 | 24 | 25 | @pytest.mark.parametrize("lhs, rhs, expected_result", [ 26 | ({}, {}, {}), 27 | ( 28 | {"BTC": Decimal("1.1"), "USD": Decimal("1")}, 29 | {"BTC": Decimal("1.1"), "ETH": Decimal("3")}, 30 | {"BTC": Decimal("2.2"), "USD": Decimal("1"), "ETH": Decimal("3")}, 31 | ), 32 | ]) 33 | def test_add(lhs, rhs, expected_result): 34 | assert (ValueMap(lhs) + rhs) == expected_result 35 | assert (lhs + ValueMap(rhs)) == expected_result 36 | 37 | res = ValueMap(lhs) 38 | res += rhs 39 | assert res == expected_result 40 | 41 | 42 | @pytest.mark.parametrize("lhs, rhs, expected_result", [ 43 | ({}, {}, {}), 44 | ( 45 | {"BTC": Decimal("1.1"), "USD": Decimal("1")}, 46 | {"BTC": Decimal("1.1"), "ETH": Decimal("3")}, 47 | {"BTC": Decimal("0"), "USD": Decimal("1"), "ETH": Decimal("-3")}, 48 | ), 49 | ]) 50 | def test_sub(lhs, rhs, expected_result): 51 | assert (ValueMap(lhs) - rhs) == expected_result 52 | assert (lhs - ValueMap(rhs)) == expected_result 53 | 54 | res = ValueMap(lhs) 55 | res -= rhs 56 | assert res == expected_result 57 | 58 | 59 | @pytest.mark.parametrize("lhs, rhs, expected_result", [ 60 | ({}, {}, {}), 61 | ( 62 | {"BTC": Decimal("-1.1"), "USD": Decimal("1")}, 63 | {"BTC": Decimal("3"), "ETH": Decimal("3")}, 64 | {"BTC": Decimal("-3.3"), "USD": Decimal("0"), "ETH": Decimal("0")}, 65 | ), 66 | ]) 67 | def test_mul(lhs, rhs, expected_result): 68 | assert (ValueMap(lhs) * rhs) == expected_result 69 | assert (lhs * ValueMap(rhs)) == expected_result 70 | 71 | res = ValueMap(lhs) 72 | res *= rhs 73 | assert res == expected_result 74 | 75 | 76 | def test_prune(): 77 | values = ValueMap({ 78 | "BTC": Decimal(1), 79 | "USD": Decimal(1), 80 | "ETH": Decimal(1), 81 | }) 82 | 83 | values.prune() 84 | assert values == { 85 | "BTC": Decimal(1), 86 | "USD": Decimal(1), 87 | "ETH": Decimal(1), 88 | } 89 | 90 | values["ETH"] = Decimal(0) 91 | assert values == { 92 | "BTC": Decimal(1), 93 | "USD": Decimal(1), 94 | "ETH": Decimal(0), 95 | } 96 | 97 | values.prune() 98 | assert values == { 99 | "BTC": Decimal(1), 100 | "USD": Decimal(1), 101 | } 102 | -------------------------------------------------------------------------------- /tests/test_binance_bars.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import asyncio 19 | import json 20 | import time 21 | 22 | import websockets 23 | 24 | from basana.core import pair 25 | from basana.external.binance import exchange 26 | 27 | 28 | def test_bars(realtime_dispatcher): 29 | p = pair.Pair("BNB", "BTC") 30 | last_bar = None 31 | 32 | async def on_bar_event(bar_event): 33 | nonlocal last_bar 34 | 35 | last_bar = bar_event.bar 36 | realtime_dispatcher.stop() 37 | 38 | async def server_main(websocket): 39 | message = json.loads(await websocket.recv()) 40 | assert message["method"] == "SUBSCRIBE" 41 | await websocket.send(json.dumps({"result": None, "id": message["id"]})) 42 | 43 | kline_event = { 44 | "e": "kline", # Event type 45 | "E": 123456789, # Event time 46 | "s": "BNBBTC", # Symbol 47 | "k": { 48 | "t": 123400000, # Kline start time 49 | "T": 123460000, # Kline close time 50 | "s": "BNBBTC", # Symbol 51 | "i": "1m", # Interval 52 | "f": 100, # First trade ID 53 | "L": 200, # Last trade ID 54 | "o": "0.0010", # Open price 55 | "c": "0.0020", # Close price 56 | "h": "0.0025", # High price 57 | "l": "0.0005", # Low price 58 | "v": "1000", # Base asset volume 59 | "n": 100, # Number of trades 60 | "x": False, # Is this kline closed? 61 | "q": "1.0000", # Quote asset volume 62 | "V": "500", # Taker buy base asset volume 63 | "Q": "0.500", # Taker buy quote asset volume 64 | "B": "123456" # Ignore 65 | } 66 | } 67 | 68 | while websocket.state == websockets.protocol.State.OPEN: 69 | timestamp = time.time() 70 | for kline_closed in (False, True): 71 | kline_event["E"] = int(timestamp * 1e3) 72 | kline_event["k"]["x"] = kline_closed 73 | await websocket.send(json.dumps({ 74 | "stream": "bnbbtc@kline_1s", 75 | "data": kline_event, 76 | })) 77 | await asyncio.sleep(0.4) 78 | 79 | async def test_main(): 80 | async with websockets.serve(server_main, "127.0.0.1", 0) as server: 81 | ws_uri = "ws://{}:{}/".format(*server.sockets[0].getsockname()) 82 | config_overrides = {"api": {"websockets": {"base_url": ws_uri}}} 83 | e = exchange.Exchange(realtime_dispatcher, config_overrides=config_overrides) 84 | e.subscribe_to_bar_events(p, 1, on_bar_event) 85 | 86 | await realtime_dispatcher.run() 87 | 88 | asyncio.run(asyncio.wait_for(test_main(), 5)) 89 | 90 | assert last_bar is not None 91 | assert last_bar.pair == p 92 | assert last_bar.datetime is not None 93 | assert last_bar.open == Decimal("0.001") 94 | assert last_bar.high == Decimal("0.0025") 95 | assert last_bar.low == Decimal("0.0005") 96 | assert last_bar.close == Decimal("0.002") 97 | assert last_bar.volume == Decimal(1000) 98 | -------------------------------------------------------------------------------- /tests/test_binance_client.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import asyncio 18 | import re 19 | 20 | import aioresponses 21 | import pytest 22 | 23 | from basana.external.binance import client 24 | 25 | 26 | @pytest.fixture() 27 | def binance_http_api_mock(): 28 | with aioresponses.aioresponses() as m: 29 | yield m 30 | 31 | 32 | @pytest.mark.parametrize("status_code, response_body, expected", [ 33 | (403, {}, "403 Forbidden"), 34 | ( 35 | 400, 36 | {"code": -1022, "msg": "Signature for this request is not valid."}, 37 | "Signature for this request is not valid." 38 | ), 39 | ]) 40 | def test_error_parsing(status_code, response_body, expected, binance_http_api_mock): 41 | binance_http_api_mock.get( 42 | re.compile(r"http://binance.mock/api/v3/account\\?.*"), status=status_code, payload=response_body 43 | ) 44 | 45 | async def test_main(): 46 | c = client.APIClient( 47 | api_key="key", api_secret="secret", config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}} 48 | ) 49 | with pytest.raises(client.Error) as excinfo: 50 | await c.spot_account.get_account_information() 51 | assert str(excinfo.value) == expected 52 | 53 | asyncio.run(test_main()) 54 | -------------------------------------------------------------------------------- /tests/test_binance_csv_bars.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import asyncio 19 | import datetime 20 | 21 | from .helpers import abs_data_path 22 | from basana.core.pair import Pair 23 | from basana.external.binance.csv import bars as csv_bars 24 | 25 | 26 | def test_daily_bars_from_csv(backtesting_dispatcher): 27 | bars = [] 28 | events = [] 29 | 30 | async def on_bar(bar_event): 31 | bars.append(bar_event.bar) 32 | events.append(bar_event) 33 | 34 | async def impl(): 35 | pair = Pair("BTC", "USDT") 36 | src = csv_bars.BarSource(pair, abs_data_path("binance_btcusdt_day_2020.csv"), "1d") 37 | backtesting_dispatcher.subscribe(src, on_bar) 38 | await backtesting_dispatcher.run() 39 | 40 | assert len(bars) == 365 # Removed Feb-29 just to get coverage over a 0 volume condition in the row parser. 41 | 42 | assert bars[0].open == Decimal("7195.24") 43 | assert bars[0].high == Decimal("7255") 44 | assert bars[0].low == Decimal("7175.15") 45 | assert bars[0].close == Decimal("7200.85") 46 | assert bars[0].volume == Decimal("16792.388165") 47 | assert bars[-1].datetime == datetime.datetime(2020, 12, 31, tzinfo=datetime.timezone.utc) 48 | assert bars[-1].open == Decimal("28875.55") 49 | assert events[-1].when == datetime.datetime(2021, 1, 1, tzinfo=datetime.timezone.utc) 50 | 51 | asyncio.run(impl()) 52 | -------------------------------------------------------------------------------- /tests/test_binance_exchange.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import asyncio 19 | import re 20 | 21 | import aiohttp 22 | 23 | from . import helpers 24 | from basana.core import pair 25 | from basana.external.binance import exchange 26 | 27 | 28 | DEPTH_RESPONSE = { 29 | "lastUpdateId": 27229732069, 30 | "bids": [ 31 | [ 32 | "16757.47000000", 33 | "0.04893000" 34 | ], 35 | [ 36 | "16757.41000000", 37 | "0.00073000" 38 | ], 39 | [ 40 | "16756.52000000", 41 | "0.00690000" 42 | ] 43 | ], 44 | "asks": [ 45 | [ 46 | "16758.13000000", 47 | "0.00682000" 48 | ], 49 | [ 50 | "16758.55000000", 51 | "0.04963000" 52 | ], 53 | [ 54 | "16759.25000000", 55 | "0.00685000" 56 | ] 57 | ] 58 | } 59 | 60 | 61 | def test_bid_ask(binance_http_api_mock, binance_exchange): 62 | binance_http_api_mock.get( 63 | re.compile(r"http://binance.mock/api/v3/depth\\?.*"), status=200, 64 | payload=DEPTH_RESPONSE 65 | ) 66 | 67 | async def test_main(): 68 | bid, ask = await binance_exchange.get_bid_ask(pair.Pair("BTC", "USDT")) 69 | assert bid == Decimal("16757.47") 70 | assert ask == Decimal("16758.13") 71 | 72 | asyncio.run(test_main()) 73 | 74 | 75 | def test_pair_info_explicit_session(binance_http_api_mock, realtime_dispatcher): 76 | binance_http_api_mock.get( 77 | re.compile(r"http://binance.mock/api/v3/exchangeInfo\\?.*"), status=200, 78 | payload=helpers.load_json("binance_btc_usdt_exchange_info.json") 79 | ) 80 | 81 | async def test_main(): 82 | async with aiohttp.ClientSession() as session: 83 | e = exchange.Exchange( 84 | realtime_dispatcher, "api_key", "api_secret", session=session, 85 | config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}} 86 | ) 87 | 88 | pair_info = await e.get_pair_info(pair.Pair("BTC", "USDT")) 89 | assert pair_info.base_precision == 5 90 | assert pair_info.quote_precision == 2 91 | assert "SPOT" in pair_info.permissions 92 | 93 | asyncio.run(test_main()) 94 | -------------------------------------------------------------------------------- /tests/test_binance_tools.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import asyncio 18 | import re 19 | 20 | import aioresponses 21 | import pytest 22 | 23 | from basana.external.binance.tools import download_bars 24 | 25 | 26 | @pytest.fixture() 27 | def binance_http_api_mock(): 28 | with aioresponses.aioresponses() as m: 29 | yield m 30 | 31 | 32 | def test_download_ohlc(binance_http_api_mock, capsys): 33 | binance_http_api_mock.get( 34 | re.compile(r"http://binance.mock/api/v3/klines\\?.*"), 35 | status=200, 36 | payload=[ 37 | [ 38 | 1577836800000, 39 | "7195.24000000", 40 | "7255.00000000", 41 | "7175.15000000", 42 | "7200.85000000", 43 | "16792.38816500", 44 | 1577923199999, 45 | "121214452.11606228", 46 | 194010, 47 | "8946.95553500", 48 | "64597785.21233434", 49 | "0" 50 | ], 51 | [ 52 | 1577923200000, 53 | "7200.77000000", 54 | "7212.50000000", 55 | "6924.74000000", 56 | "6965.71000000", 57 | "31951.48393200", 58 | 1578009599999, 59 | "225982341.30114030", 60 | 302667, 61 | "15141.61134000", 62 | "107060829.07806464", 63 | "0" 64 | ], 65 | # This one closes in the future and should be skipped. 66 | [ 67 | 1577836800000, 68 | "7195.24000000", 69 | "7255.00000000", 70 | "7175.15000000", 71 | "7200.85000000", 72 | "16792.38816500", 73 | 2538693277999, 74 | "121214452.11606228", 75 | 194010, 76 | "8946.95553500", 77 | "64597785.21233434", 78 | "0" 79 | ], 80 | 81 | ] 82 | ) 83 | 84 | async def test_main(): 85 | await download_bars.main( 86 | params=["-c", "BTCUSDT", "-p", "1d", "-s", "2020-01-01", "-e", "2020-01-01"], 87 | config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}} 88 | ) 89 | assert capsys.readouterr().out == """datetime,open,high,low,close,volume 90 | 2020-01-01 00:00:00,7195.24000000,7255.00000000,7175.15000000,7200.85000000,16792.38816500 91 | """ 92 | 93 | asyncio.run(test_main()) 94 | -------------------------------------------------------------------------------- /tests/test_binance_trades.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import asyncio 19 | import datetime 20 | import json 21 | 22 | import websockets 23 | 24 | from basana.core import pair 25 | from basana.external.binance import exchange 26 | 27 | 28 | TRADE_MSG = { 29 | "stream": "btcusdt@trade", 30 | "data": { 31 | "e": "trade", 32 | "E": 1669932275175, 33 | "s": "BTCUSDT", 34 | "t": 2275696344, 35 | "p": "16930.90000000", 36 | "q": "0.05097000", 37 | "b": 16081955917, 38 | "a": 16081955890, 39 | "T": 1669932275174, 40 | "m": False, 41 | "M": True 42 | } 43 | } 44 | 45 | 46 | def test_websocket_ok(realtime_dispatcher): 47 | p = pair.Pair("BTC", "USDT") 48 | last_trade = None 49 | 50 | async def on_trade_event(trade_event): 51 | nonlocal last_trade 52 | 53 | last_trade = trade_event.trade 54 | realtime_dispatcher.stop() 55 | 56 | async def server_main(websocket): 57 | message = json.loads(await websocket.recv()) 58 | assert message["method"] == "SUBSCRIBE" 59 | await websocket.send(json.dumps({"result": None, "id": message["id"]})) 60 | 61 | while websocket.state == websockets.protocol.State.OPEN: 62 | await websocket.send(json.dumps(TRADE_MSG)) 63 | await asyncio.sleep(0.1) 64 | 65 | async def test_main(): 66 | async with websockets.serve(server_main, "127.0.0.1", 0) as server: 67 | ws_uri = "ws://{}:{}/".format(*server.sockets[0].getsockname()) 68 | config_overrides = {"api": {"websockets": {"base_url": ws_uri}}} 69 | e = exchange.Exchange(realtime_dispatcher, config_overrides=config_overrides) 70 | e.subscribe_to_trade_events(p, on_trade_event) 71 | 72 | await realtime_dispatcher.run() 73 | 74 | asyncio.run(asyncio.wait_for(test_main(), 5)) 75 | 76 | assert last_trade is not None 77 | assert last_trade.pair == p 78 | assert last_trade.datetime == datetime.datetime(2022, 12, 1, 22, 4, 35, 174000, tzinfo=datetime.timezone.utc) 79 | assert last_trade.id == "2275696344" 80 | assert last_trade.buy_order_id == "16081955917" 81 | assert last_trade.sell_order_id == "16081955890" 82 | assert last_trade.price == Decimal("16930.9") 83 | assert last_trade.amount == Decimal("0.05097") 84 | -------------------------------------------------------------------------------- /tests/test_bitstamp_bars.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import asyncio 19 | import json 20 | import time 21 | 22 | import websockets 23 | 24 | from basana.core import pair 25 | from basana.external.bitstamp import exchange 26 | 27 | 28 | def test_bars_from_trades(realtime_dispatcher): 29 | p = pair.Pair("BTC", "USD") 30 | last_bar = None 31 | 32 | async def on_bar_event(bar_event): 33 | nonlocal last_bar 34 | 35 | last_bar = bar_event.bar 36 | realtime_dispatcher.stop() 37 | 38 | async def server_main(websocket): 39 | # We expect to receive a subscription request to start. 40 | message = json.loads(await websocket.recv()) 41 | if message.get("event") == "bts:subscribe": 42 | channel = message["data"]["channel"] 43 | await websocket.send(json.dumps({"event": "bts:subscription_succeeded"})) 44 | # Keep on sending trade events while the connection is open. 45 | while websocket.state == websockets.protocol.State.OPEN: 46 | timestamp = time.time() 47 | await websocket.send(json.dumps({ 48 | "event": "trade", 49 | "channel": channel, 50 | "data": { 51 | "id": 246612672, 52 | "timestamp": str(int(timestamp)), 53 | "amount": 1, 54 | "amount_str": "1", 55 | "price": 1000, 56 | "price_str": "1000", 57 | "type": 0, 58 | "microtimestamp": str(int(timestamp * 1e6)), 59 | "buy_order_id": 1530834271539201, 60 | "sell_order_id": 1530834150440960 61 | } 62 | })) 63 | await asyncio.sleep(0.4) 64 | 65 | async def test_main(): 66 | async with websockets.serve(server_main, "127.0.0.1", 0) as server: 67 | ws_uri = "ws://{}:{}/".format(*server.sockets[0].getsockname()) 68 | e = exchange.Exchange( 69 | realtime_dispatcher, "key", "secret", 70 | config_overrides={ 71 | "api": { 72 | "http": {"base_url": "http://bitstamp.mock/"}, 73 | "websockets": {"base_url": ws_uri} 74 | } 75 | } 76 | ) 77 | e.subscribe_to_bar_events(p, 1, on_bar_event) 78 | 79 | await realtime_dispatcher.run() 80 | 81 | asyncio.run(asyncio.wait_for(test_main(), 5)) 82 | 83 | assert last_bar is not None 84 | assert last_bar.pair == p 85 | assert last_bar.datetime is not None 86 | assert last_bar.open == Decimal(1000) 87 | assert last_bar.high == Decimal(1000) 88 | assert last_bar.low == Decimal(1000) 89 | assert last_bar.close == Decimal(1000) 90 | assert last_bar.volume >= Decimal(2) and last_bar.volume <= Decimal(3) 91 | -------------------------------------------------------------------------------- /tests/test_bitstamp_client.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import asyncio 18 | 19 | import aioresponses 20 | import pytest 21 | 22 | from basana.external.bitstamp import client 23 | 24 | 25 | @pytest.fixture() 26 | def bitstamp_http_api_mock(): 27 | with aioresponses.aioresponses() as m: 28 | yield m 29 | 30 | 31 | def test_get_ohlc_data_using_end(bitstamp_http_api_mock): 32 | bitstamp_http_api_mock.get( 33 | "http://bitstamp.mock/api/v2/ohlc/btcusd/?end=1451606400&limit=1&step=60", status=200, 34 | payload={ 35 | "data": { 36 | "ohlc": [ 37 | { 38 | "close": "430.89", 39 | "high": "430.89", 40 | "low": "430.89", 41 | "open": "430.89", 42 | "timestamp": "1451606400", 43 | "volume": "0.00000000" 44 | } 45 | ], 46 | "pair": "BTC/USD" 47 | } 48 | } 49 | ) 50 | 51 | async def test_main(): 52 | c = client.APIClient( 53 | config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}} 54 | ) 55 | response = await c.get_ohlc_data("btcusd", 60, end=1451606400, limit=1) 56 | assert len(response["data"]["ohlc"]) == 1 57 | 58 | asyncio.run(test_main()) 59 | 60 | 61 | @pytest.mark.parametrize("status_code, response_body, expected", [ 62 | (403, {}, "403 Forbidden"), 63 | (200, {"status": "error", "reason": "Order not found"}, "Order not found"), 64 | (200, {"error": "Order not found"}, "Order not found"), 65 | (200, {"code": "Order not found", "errors": "blabla"}, "Order not found"), 66 | ]) 67 | def test_error_parsing(status_code, response_body, expected, bitstamp_http_api_mock): 68 | bitstamp_http_api_mock.get( 69 | "http://bitstamp.mock/api/v2/order_book/btcusd/", status=status_code, payload=response_body 70 | ) 71 | 72 | async def test_main(): 73 | c = client.APIClient( 74 | config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}} 75 | ) 76 | with pytest.raises(client.Error) as excinfo: 77 | await c.get_order_book("btcusd") 78 | assert str(excinfo.value) == expected 79 | 80 | asyncio.run(test_main()) 81 | -------------------------------------------------------------------------------- /tests/test_bitstamp_csv_bars.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import asyncio 19 | import datetime 20 | 21 | import pytest 22 | 23 | from .helpers import abs_data_path 24 | from basana.core.pair import Pair 25 | from basana.external.bitstamp.csv import bars as csv_bars 26 | 27 | 28 | @pytest.mark.parametrize("filename", [ 29 | "bitstamp_btcusd_day_2015.csv", 30 | "bitstamp_btcusd_day_2015.csv.utf16", 31 | ]) 32 | def test_daily_bars_from_csv(filename, backtesting_dispatcher): 33 | bars = [] 34 | events = [] 35 | 36 | async def on_bar(bar_event): 37 | bars.append(bar_event.bar) 38 | events.append(bar_event) 39 | 40 | async def impl(): 41 | pair = Pair("BTC", "USD") 42 | src = csv_bars.BarSource(pair, abs_data_path(filename), "1d") 43 | backtesting_dispatcher.subscribe(src, on_bar) 44 | await backtesting_dispatcher.run() 45 | 46 | assert len(bars) == 365 - 3 # There are 3 bars with no volume that are skipped. 47 | 48 | assert bars[0].open == Decimal(321) 49 | assert bars[0].high == Decimal(321) 50 | assert bars[0].low == Decimal("312.6") 51 | assert bars[0].close == Decimal("313.81") 52 | assert bars[0].volume == Decimal("3087.43655395") 53 | assert bars[-1].datetime == datetime.datetime(2015, 12, 31, tzinfo=datetime.timezone.utc) 54 | assert bars[-1].open == Decimal("426.09") 55 | assert events[-1].when == datetime.datetime(2016, 1, 1, tzinfo=datetime.timezone.utc) 56 | 57 | asyncio.run(impl()) 58 | 59 | 60 | def test_daily_bars_from_utf_16_csv(backtesting_dispatcher): 61 | bars = [] 62 | events = [] 63 | 64 | async def on_bar(bar_event): 65 | bars.append(bar_event.bar) 66 | events.append(bar_event) 67 | 68 | async def impl(): 69 | pair = Pair("BTC", "USD") 70 | src = csv_bars.BarSource(pair, abs_data_path("bitstamp_btcusd_day_2015.csv"), "1d") 71 | backtesting_dispatcher.subscribe(src, on_bar) 72 | await backtesting_dispatcher.run() 73 | 74 | assert len(bars) == 365 - 3 # There are 3 bars with no volume that are skipped. 75 | 76 | assert bars[0].open == Decimal(321) 77 | assert bars[0].high == Decimal(321) 78 | assert bars[0].low == Decimal("312.6") 79 | assert bars[0].close == Decimal("313.81") 80 | assert bars[0].volume == Decimal("3087.43655395") 81 | assert bars[-1].datetime == datetime.datetime(2015, 12, 31, tzinfo=datetime.timezone.utc) 82 | assert bars[-1].open == Decimal("426.09") 83 | assert events[-1].when == datetime.datetime(2016, 1, 1, tzinfo=datetime.timezone.utc) 84 | 85 | asyncio.run(impl()) 86 | 87 | 88 | def test_minute_bars_from_csv_using_deprecated_period_format(backtesting_dispatcher): 89 | bars = [] 90 | 91 | async def on_bar(bar_event): 92 | bars.append(bar_event.bar) 93 | 94 | async def impl(): 95 | pair = Pair("BTC", "USD") 96 | src = csv_bars.BarSource(pair, abs_data_path("bitstamp_btcusd_min_2020_01_01.csv"), csv_bars.BarPeriod.MINUTE) 97 | backtesting_dispatcher.subscribe(src, on_bar) 98 | await backtesting_dispatcher.run() 99 | 100 | assert len(bars) == 1440 - 45 # There are 45 bars with no volume that are skipped. 101 | 102 | assert bars[0].open == Decimal("7160.69") 103 | assert bars[0].high == Decimal("7160.69") 104 | assert bars[0].low == Decimal("7159.64") 105 | assert bars[0].close == Decimal("7159.64") 106 | assert bars[0].volume == Decimal("5.50169101") 107 | assert bars[-1].datetime == datetime.datetime(2020, 1, 1, 23, 59, tzinfo=datetime.timezone.utc) 108 | assert bars[-1].open == Decimal("7178.68") 109 | 110 | asyncio.run(impl()) 111 | -------------------------------------------------------------------------------- /tests/test_bitstamp_tools.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import asyncio 18 | import re 19 | 20 | import aioresponses 21 | import pytest 22 | 23 | from basana.external.bitstamp.tools import download_bars 24 | 25 | 26 | @pytest.fixture() 27 | def bitstamp_http_api_mock(): 28 | with aioresponses.aioresponses() as m: 29 | m.get(re.compile(r'http://bitstamp.mock/api/v2/ohlc/btcusd/.*'), status=200, payload={ 30 | "data": { 31 | "ohlc": [ 32 | { 33 | "close": "433.82", "high": "436", "low": "427.2", "open": "430.89", 34 | "timestamp": "1451606400", "volume": "3788.11117403" 35 | }, 36 | { 37 | "close": "433.55", "high": "435.99", "low": "430.42", "open": "434.87", 38 | "timestamp": "1451692800", "volume": "2972.06344935" 39 | }, 40 | { 41 | "close": "431.04", "high": "434.09", "low": "424.06", "open": "433.2", 42 | "timestamp": "1451779200", "volume": "4571.09703841" 43 | } 44 | ], 45 | "pair": "BTC/USD" 46 | } 47 | }) 48 | yield m 49 | 50 | 51 | def test_download_ohlc(bitstamp_http_api_mock, capsys): 52 | async def test_main(): 53 | await download_bars.main( 54 | params=["-c", "btcusd", "-p", "day", "-s", "2016-01-01", "-e", "2016-01-01"], 55 | config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}} 56 | ) 57 | assert capsys.readouterr().out == """datetime,open,high,low,close,volume 58 | 2016-01-01 00:00:00,430.89,436,427.2,433.82,3788.11117403 59 | """ 60 | 61 | asyncio.run(test_main()) 62 | -------------------------------------------------------------------------------- /tests/test_bitstamp_trades.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from decimal import Decimal 18 | import asyncio 19 | import json 20 | 21 | import pytest 22 | import websockets 23 | 24 | from basana.core import pair 25 | from basana.core.enums import OrderOperation 26 | from basana.external.bitstamp import exchange 27 | 28 | 29 | @pytest.mark.parametrize("public_events", [True, False]) 30 | def test_websocket_ok(public_events, bitstamp_http_api_mock, realtime_dispatcher): 31 | if not public_events: 32 | bitstamp_http_api_mock.post( 33 | "http://bitstamp.mock/api/v2/websockets_token/", status=200, payload={"user_id": "1234", "token": "1234"} 34 | ) 35 | 36 | p = pair.Pair("BTC", "USD") 37 | last_trade = None 38 | 39 | async def on_trade_event(trade_event): 40 | nonlocal last_trade 41 | 42 | last_trade = trade_event.trade 43 | realtime_dispatcher.stop() 44 | 45 | async def server_main(websocket): 46 | # We expect to receive a subscription request to start. 47 | message = json.loads(await websocket.recv()) 48 | if message.get("event") == "bts:subscribe": 49 | # Remove the '-[user-id]' that was used for channel subscription. 50 | channel = message["data"]["channel"].rstrip("-1234") 51 | await websocket.send(json.dumps({"event": "bts:subscription_succeeded"})) 52 | # Keep on sending trade events while the connection is open. 53 | while websocket.state == websockets.protocol.State.OPEN: 54 | await websocket.send(json.dumps({ 55 | "event": "trade", 56 | "channel": channel, 57 | "data": { 58 | "id": 246612672, 59 | "timestamp": "1662573810", 60 | "amount": 0.374, 61 | "amount_str": "0.37400000", 62 | "price": 19034, 63 | "price_str": "19034", 64 | "type": 0, 65 | "microtimestamp": "1662573810482000", 66 | "buy_order_id": 1530834271539201, 67 | "sell_order_id": 1530834150440960 68 | } 69 | })) 70 | await asyncio.sleep(0.1) 71 | 72 | async def test_main(): 73 | async with websockets.serve(server_main, "127.0.0.1", 0) as server: 74 | ws_uri = "ws://{}:{}/".format(*server.sockets[0].getsockname()) 75 | e = exchange.Exchange( 76 | realtime_dispatcher, "key", "secret", 77 | config_overrides={ 78 | "api": { 79 | "http": {"base_url": "http://bitstamp.mock/"}, 80 | "websockets": {"base_url": ws_uri} 81 | } 82 | } 83 | ) 84 | if public_events: 85 | e.subscribe_to_public_trade_events(p, on_trade_event) 86 | else: 87 | e.subscribe_to_private_trade_events(p, on_trade_event) 88 | 89 | await realtime_dispatcher.run() 90 | 91 | asyncio.run(asyncio.wait_for(test_main(), 5)) 92 | 93 | assert last_trade is not None 94 | assert last_trade.pair == p 95 | assert last_trade.datetime is not None 96 | assert last_trade.id == "246612672" 97 | assert last_trade.type == OrderOperation.BUY 98 | assert last_trade.operation == OrderOperation.BUY 99 | assert last_trade.buy_order_id == "1530834271539201" 100 | assert last_trade.sell_order_id == "1530834150440960" 101 | assert last_trade.price == Decimal("19034") 102 | assert last_trade.amount == Decimal("0.374") 103 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import pytest 18 | 19 | from basana.core.config import get_config_value 20 | 21 | 22 | CONFIG_VALUES = { 23 | "api": { 24 | "http": { 25 | "base_url": "https://www.bitstamp.net/", 26 | }, 27 | "websockets": { 28 | "base_url": "wss://ws.bitstamp.net/", 29 | } 30 | } 31 | } 32 | 33 | 34 | @pytest.mark.parametrize("path, default, overrides, expected", [ 35 | ("api.http", None, {}, {"base_url": "https://www.bitstamp.net/"}), 36 | ("api.http.base_url", "http://google.com/", {}, "https://www.bitstamp.net/"), 37 | ("api.websockets.timeout", 10, {}, 10), 38 | ("api.timeout", 10, {}, 10), 39 | ("api.timeout", 10, {"api": {"timeout": 50}}, 50), 40 | ]) 41 | def test_get_config_value(path, default, overrides, expected): 42 | assert get_config_value(CONFIG_VALUES, path, default=default, overrides=overrides) == expected 43 | -------------------------------------------------------------------------------- /tests/test_enums.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from basana.core import enums 18 | 19 | 20 | def test_order_operation(): 21 | assert str(enums.OrderOperation.BUY) == "buy" 22 | assert str(enums.OrderOperation.SELL) == "sell" 23 | 24 | 25 | def test_position(): 26 | assert str(enums.Position.LONG) == "long" 27 | assert str(enums.Position.NEUTRAL) == "neutral" 28 | assert str(enums.Position.SHORT) == "short" 29 | -------------------------------------------------------------------------------- /tests/test_event.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from typing import Optional 18 | import asyncio 19 | import datetime 20 | import random 21 | 22 | import pytest 23 | 24 | from basana.core import dt, event 25 | 26 | 27 | class Number(event.Event): 28 | def __init__(self, when: datetime.datetime, number: float): 29 | super().__init__(when) 30 | self.number = number 31 | 32 | 33 | class RandIntSource(event.EventSource): 34 | def __init__(self, a: int, b: int): 35 | super().__init__() 36 | self.a = a 37 | self.b = b 38 | 39 | def pop(self) -> Optional[event.Event]: 40 | return Number(dt.utc_now(), random.randint(self.a, self.b)) 41 | 42 | 43 | class FailingEventSource(event.EventSource): 44 | def pop(self) -> Optional[event.Event]: 45 | raise Exception("Error during pop") 46 | 47 | 48 | class HeadEventSource(event.EventSource): 49 | def __init__(self, event_source: event.EventSource, count: int): 50 | super().__init__() 51 | assert count > 0 52 | self.event_source = event_source 53 | self.count = count 54 | 55 | def pop(self) -> Optional[event.Event]: 56 | if self.count: 57 | self.count -= 1 58 | return self.event_source.pop() 59 | return None 60 | 61 | 62 | def test_mutiple_sources(backtesting_dispatcher): 63 | event_count = 0 64 | 65 | async def on_event(event): 66 | nonlocal event_count 67 | event_count += 1 68 | 69 | src1 = HeadEventSource(RandIntSource(1000, 10000), 1500) 70 | src2 = HeadEventSource(RandIntSource(1, 50), 1300) 71 | 72 | backtesting_dispatcher.subscribe(src1, on_event) 73 | backtesting_dispatcher.subscribe(src2, on_event) 74 | asyncio.run(backtesting_dispatcher.run()) 75 | 76 | assert event_count == 2800 77 | 78 | 79 | def test_unhandled_exception_during_pop(backtesting_dispatcher): 80 | event_count = 0 81 | 82 | async def on_event(event): 83 | nonlocal event_count 84 | event_count += 1 85 | 86 | src1 = FailingEventSource() 87 | backtesting_dispatcher.subscribe(src1, on_event) 88 | with pytest.raises(Exception, match="Error during pop"): 89 | asyncio.run(backtesting_dispatcher.run()) 90 | 91 | assert event_count == 0 92 | -------------------------------------------------------------------------------- /tests/test_pair.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | from basana.core import pair 18 | 19 | 20 | def test_str(): 21 | assert str(pair.Pair("BTC", "USD")) == "BTC/USD" 22 | 23 | 24 | def test_eq(): 25 | assert pair.Pair("BTC", "USD") == pair.Pair("BTC", "USD") 26 | assert pair.Pair("BTC", "USD") != pair.Pair("ARS", "USD") 27 | -------------------------------------------------------------------------------- /tests/test_token_bucket.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import asyncio 18 | import time 19 | 20 | import pytest 21 | 22 | from basana.core.token_bucket import TokenBucketLimiter 23 | 24 | 25 | @pytest.mark.parametrize("tokens_per_period, period_duration, initial_tokens, expected_wait", [ 26 | (1, 1, 0, 1), 27 | (1, 1, 1, 0), 28 | (10, 1, 0, 0.1), 29 | (10, 7, 0, 0.7), 30 | (1, 2, 0, 2), 31 | (0.5, 1, 0, 2), 32 | ]) 33 | def test_token_consume(tokens_per_period, period_duration, initial_tokens, expected_wait): 34 | limiter = TokenBucketLimiter(tokens_per_period, period_duration, initial_tokens=initial_tokens) 35 | assert limiter.tokens == initial_tokens 36 | assert limiter.tokens_per_period == tokens_per_period 37 | assert limiter.period_duration == period_duration 38 | assert round(limiter.consume(), 2) == expected_wait 39 | 40 | 41 | def test_token_wait(): 42 | limiter = TokenBucketLimiter(2, 1) 43 | begin = time.time() 44 | asyncio.run(limiter.wait()) 45 | assert round(time.time() - begin, 1) == 0.5 46 | 47 | 48 | def test_dont_accumulate(): 49 | limiter = TokenBucketLimiter(100, 1, 100) 50 | time.sleep(0.1) 51 | assert limiter.consume() == 0 52 | -------------------------------------------------------------------------------- /tests/test_trading_signal.py: -------------------------------------------------------------------------------- 1 | # Basana 2 | # 3 | # Copyright 2022 Gabriel Martin Becedillas Ruiz 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | import asyncio 18 | 19 | import pytest 20 | 21 | from basana.core import dispatcher, dt, enums, errors 22 | from basana.core.event_sources import trading_signal 23 | from basana.core.pair import Pair 24 | 25 | 26 | class TradingSignalSource(trading_signal.TradingSignalSource): 27 | def __init__(self, dispatcher: dispatcher.EventDispatcher): 28 | super().__init__(dispatcher) 29 | 30 | 31 | def test_trading_signal_op(backtesting_dispatcher): 32 | trading_signals = [] 33 | 34 | async def on_trading_signal(trading_signal: trading_signal.TradingSignal): 35 | trading_signals.append(trading_signal) 36 | 37 | async def impl(): 38 | source = TradingSignalSource(backtesting_dispatcher) 39 | source.push(trading_signal.TradingSignal(dt.local_now(), enums.OrderOperation.BUY, Pair("BTC", "USDT"))) 40 | source.subscribe_to_trading_signals(on_trading_signal) 41 | await backtesting_dispatcher.run() 42 | 43 | asyncio.run(impl()) 44 | assert len(trading_signals) == 1 45 | for signal in trading_signals: 46 | assert signal.operation == enums.OrderOperation.BUY 47 | assert signal.position == enums.Position.LONG 48 | 49 | 50 | def test_trading_signal_pos(backtesting_dispatcher): 51 | trading_signals = [] 52 | 53 | async def on_trading_signal(trading_signal: trading_signal.TradingSignal): 54 | trading_signals.append(trading_signal) 55 | 56 | async def impl(): 57 | source = TradingSignalSource(backtesting_dispatcher) 58 | source.push(trading_signal.TradingSignal(dt.local_now(), enums.Position.SHORT, Pair("BTC", "USDT"))) 59 | source.subscribe_to_trading_signals(on_trading_signal) 60 | await backtesting_dispatcher.run() 61 | 62 | asyncio.run(impl()) 63 | assert len(trading_signals) == 1 64 | for signal in trading_signals: 65 | assert signal.operation == enums.OrderOperation.SELL 66 | assert signal.position == enums.Position.SHORT 67 | 68 | 69 | def test_neutral_position_cant_be_mapped_to_operation(backtesting_dispatcher): 70 | trading_signals = [] 71 | 72 | async def on_trading_signal(trading_signal: trading_signal.TradingSignal): 73 | trading_signals.append(trading_signal) 74 | 75 | async def impl(): 76 | source = TradingSignalSource(backtesting_dispatcher) 77 | source.push(trading_signal.TradingSignal(dt.local_now(), enums.Position.NEUTRAL, Pair("BTC", "USDT"))) 78 | source.subscribe_to_trading_signals(on_trading_signal) 79 | await backtesting_dispatcher.run() 80 | 81 | asyncio.run(impl()) 82 | assert len(trading_signals) == 1 83 | for signal in trading_signals: 84 | assert signal.position == enums.Position.NEUTRAL 85 | with pytest.raises(errors.Error): 86 | assert signal.operation 87 | --------------------------------------------------------------------------------