From bc034c118c047e26e89425acac079c9e25161bb7 Mon Sep 17 00:00:00 2001 From: Christian Pojoni Date: Mon, 9 Mar 2026 19:35:23 +0000 Subject: [PATCH 01/17] chore: add Python QA pre-commit hooks --- .github/workflows/runtests.yml | 2 +- .pre-commit-config.yaml | 40 + DEVELOPERS.md | 2 +- LICENSE | 1 - basana/__init__.py | 1 - basana/backtesting/account_balances.py | 51 +- basana/backtesting/charts.py | 47 +- basana/backtesting/errors.py | 2 + basana/backtesting/exchange.py | 157 ++-- basana/backtesting/fees.py | 4 +- basana/backtesting/helpers.py | 2 +- basana/backtesting/lending/base.py | 4 +- basana/backtesting/lending/margin.py | 27 +- basana/backtesting/loan_mgr.py | 25 +- basana/backtesting/order_mgr.py | 100 +-- basana/backtesting/orders.py | 104 ++- basana/backtesting/prices.py | 8 +- basana/backtesting/requests.py | 80 +- basana/core/bar.py | 28 +- basana/core/config.py | 1 + basana/core/dispatcher.py | 62 +- basana/core/errors.py | 1 + basana/core/event.py | 7 +- basana/core/event_sources/csv.py | 10 +- basana/core/event_sources/trading_signal.py | 11 +- basana/core/helpers.py | 6 +- basana/core/websockets.py | 23 +- basana/external/binance/client/__init__.py | 30 +- basana/external/binance/client/base.py | 22 +- basana/external/binance/client/margin.py | 142 ++-- basana/external/binance/client/spot.py | 139 ++-- basana/external/binance/config.py | 2 +- basana/external/binance/cross_margin.py | 9 +- basana/external/binance/csv/bars.py | 13 +- basana/external/binance/exchange.py | 30 +- basana/external/binance/isolated_margin.py | 9 +- basana/external/binance/klines.py | 20 +- basana/external/binance/margin.py | 161 ++-- basana/external/binance/margin_requests.py | 116 ++- basana/external/binance/order_book.py | 40 +- basana/external/binance/order_book_diff.py | 20 +- basana/external/binance/spot.py | 148 ++-- basana/external/binance/spot_requests.py | 103 ++- .../external/binance/tools/download_bars.py | 31 +- basana/external/binance/trades.py | 10 +- basana/external/binance/user_data.py | 2 +- basana/external/binance/websocket_mgr.py | 37 +- basana/external/binance/websockets.py | 39 +- basana/external/bitstamp/client.py | 81 +- basana/external/bitstamp/config.py | 2 +- basana/external/bitstamp/csv/bars.py | 13 +- basana/external/bitstamp/exchange.py | 118 ++- basana/external/bitstamp/helpers.py | 10 +- basana/external/bitstamp/order_book.py | 18 +- basana/external/bitstamp/requests.py | 57 +- .../external/bitstamp/tools/download_bars.py | 22 +- basana/external/bitstamp/websockets.py | 63 +- basana/external/common/csv/bars.py | 16 +- basana/external/yahoo/bars.py | 34 +- docs/backtesting_lending.rst | 2 +- docs/backtesting_liquidity.rst | 2 +- docs/conf.py | 34 +- docs/quickstart.rst | 2 +- pyproject.toml | 3 + samples/backtest_bbands.py | 17 +- samples/backtest_pairs_trading.py | 27 +- samples/backtest_rsi.py | 12 +- samples/backtest_sma.py | 15 +- samples/backtesting/position_manager.py | 80 +- samples/binance/order_book_mirror.py | 105 +-- samples/binance/position_manager.py | 92 ++- samples/binance_bbands.py | 5 +- samples/binance_order_book_mirror.py | 13 +- samples/binance_websockets.py | 27 +- samples/bitstamp_websockets.py | 20 +- samples/strategies/bbands.py | 8 +- samples/strategies/dmac.py | 3 +- samples/strategies/pairs_trading.py | 17 +- setup.cfg | 2 +- .../backtesting_exchange_orders_test_data.py | 180 ++--- tests/data/binance_depth_update.json | 2 +- tests/data/bitstamp_btcusd_day_2015.csv | 2 +- tests/fixtures/binance.py | 7 +- tests/test_backtesting_account_balances.py | 131 ++-- tests/test_backtesting_charts.py | 16 +- tests/test_backtesting_dispatcher.py | 72 +- tests/test_backtesting_exchange.py | 42 +- .../test_backtesting_exchange_auto_lending.py | 155 ++-- tests/test_backtesting_exchange_loans.py | 164 ++-- tests/test_backtesting_exchange_orders.py | 333 ++++---- tests/test_backtesting_fees.py | 12 +- tests/test_backtesting_liquidity.py | 44 +- tests/test_backtesting_margin.py | 52 +- tests/test_backtesting_orders.py | 693 ++++++++-------- tests/test_backtesting_prices.py | 52 +- tests/test_backtesting_value_map.py | 126 +-- tests/test_binance_bars.py | 50 +- tests/test_binance_client.py | 19 +- tests/test_binance_exchange.py | 59 +- tests/test_binance_exchange_cross_margin.py | 586 +++++++------- .../test_binance_exchange_isolated_margin.py | 77 +- tests/test_binance_exchange_spot.py | 742 +++++++++--------- tests/test_binance_order_book.py | 54 +- tests/test_binance_tools.py | 16 +- tests/test_binance_trades.py | 4 +- tests/test_binance_user_data.py | 28 +- tests/test_bitstamp_bars.py | 47 +- tests/test_bitstamp_client.py | 32 +- tests/test_bitstamp_csv_bars.py | 11 +- tests/test_bitstamp_exchange.py | 233 +++--- tests/test_bitstamp_order_book.py | 125 +-- tests/test_bitstamp_orders.py | 85 +- tests/test_bitstamp_tools.py | 61 +- tests/test_bitstamp_trades.py | 47 +- tests/test_config.py | 19 +- tests/test_core_helpers.py | 53 +- tests/test_dispatcher_core.py | 2 +- tests/test_realtime_dispatcher.py | 53 +- tests/test_samples_backtesting_pos_info.py | 181 +++-- .../test_samples_binance_order_book_mirror.py | 56 +- tests/test_token_bucket.py | 19 +- tests/test_yahoo.py | 10 +- 122 files changed, 4404 insertions(+), 3197 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/runtests.yml b/.github/workflows/runtests.yml index 3d11781..a206675 100644 --- a/.github/workflows/runtests.yml +++ b/.github/workflows/runtests.yml @@ -15,7 +15,7 @@ on: - issues/* pull_request: branches: - - master + - master - develop jobs: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..d176632 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,40 @@ +default_stages: [pre-commit] +fail_fast: false + +repos: + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + args: + - --exclude-files + - ^(poetry\.lock|tests/.*)$ + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: detect-private-key + - id: check-added-large-files + args: ["--maxkb=1024"] + - id: check-merge-conflict + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-json + - id: check-yaml + - id: check-toml + - id: debug-statements + - id: check-ast + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.5 + hooks: + - id: ruff-check + args: [--fix] + - id: ruff-format + + - repo: https://github.com/PyCQA/bandit + rev: 1.8.6 + hooks: + - id: bandit + args: [-c, pyproject.toml, -q, -r, basana, samples] + pass_filenames: false diff --git a/DEVELOPERS.md b/DEVELOPERS.md index 2d19332..a362f15 100644 --- a/DEVELOPERS.md +++ b/DEVELOPERS.md @@ -39,7 +39,7 @@ Instead of running those commands manually, a couple of Invoke tasks are provide $ inv create-virtualenv ``` -1. Execute static checks and testcases +1. Execute static checks and testcases ``` $ inv test diff --git a/LICENSE b/LICENSE index 7b78d33..743735e 100644 --- a/LICENSE +++ b/LICENSE @@ -13,4 +13,3 @@ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - diff --git a/basana/__init__.py b/basana/__init__.py index 3a41f20..cd34a03 100644 --- a/basana/__init__.py +++ b/basana/__init__.py @@ -64,4 +64,3 @@ from .core.token_bucket import ( TokenBucketLimiter, ) - diff --git a/basana/backtesting/account_balances.py b/basana/backtesting/account_balances.py index 5dbdb4a..af1808e 100644 --- a/basana/backtesting/account_balances.py +++ b/basana/backtesting/account_balances.py @@ -26,16 +26,26 @@ class UpdateRule(metaclass=abc.ABCMeta): @abc.abstractmethod def check( - self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap, - delta_balances: ValueMap, delta_holds: ValueMap, delta_borrowed: ValueMap + self, + updated_balances: ValueMap, + updated_holds: ValueMap, + updated_borrowed: ValueMap, + delta_balances: ValueMap, + delta_holds: ValueMap, + delta_borrowed: ValueMap, ): raise NotImplementedError() class NonZero(UpdateRule): def check( - self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap, - delta_balances: ValueMap, delta_holds: ValueMap, delta_borrowed: ValueMap + self, + updated_balances: ValueMap, + updated_holds: ValueMap, + updated_borrowed: ValueMap, + delta_balances: ValueMap, + delta_holds: ValueMap, + delta_borrowed: ValueMap, ): # balance >= 0 for symbol, value in updated_balances.items(): @@ -54,8 +64,13 @@ def check( class ValidHold(UpdateRule): # * hold <= balance def check( - self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap, - delta_balances: ValueMap, delta_holds: ValueMap, delta_borrowed: ValueMap + self, + updated_balances: ValueMap, + updated_holds: ValueMap, + updated_borrowed: ValueMap, + delta_balances: ValueMap, + delta_holds: ValueMap, + delta_borrowed: ValueMap, ): symbols = set(itertools.chain(updated_holds.keys(), updated_balances.keys())) for symbol in symbols: @@ -67,24 +82,16 @@ def check( class AccountBalances: def __init__(self, initial_balances: ValueMapDict): - self.balances = ValueMap({ - symbol: balance for symbol, balance in initial_balances.items() if balance >= 0 - }) + self.balances = ValueMap({symbol: balance for symbol, balance in initial_balances.items() if balance >= 0}) self.holds = ValueMap() - self.borrowed = ValueMap({ - symbol: -balance for symbol, balance in initial_balances.items() if balance < 0 - }) - self._update_rules: List[UpdateRule] = [ - NonZero(), - ValidHold() - ] + self.borrowed = ValueMap({symbol: -balance for symbol, balance in initial_balances.items() if balance < 0}) + self._update_rules: List[UpdateRule] = [NonZero(), ValidHold()] def push_update_rule(self, update_rule: UpdateRule): self._update_rules.append(update_rule) def update( - self, balance_updates: ValueMapDict = {}, hold_updates: ValueMapDict = {}, - borrowed_updates: ValueMapDict = {} + self, balance_updates: ValueMapDict = {}, hold_updates: ValueMapDict = {}, borrowed_updates: ValueMapDict = {} ): updated_balances = self.balances + balance_updates updated_holds = self.holds + hold_updates @@ -92,8 +99,12 @@ def update( for rule in self._update_rules: rule.check( - updated_balances, updated_holds, updated_borrowed, - ValueMap(balance_updates), ValueMap(hold_updates), ValueMap(borrowed_updates) + updated_balances, + updated_holds, + updated_borrowed, + ValueMap(balance_updates), + ValueMap(hold_updates), + ValueMap(borrowed_updates), ) # Update if no error ocurred. diff --git a/basana/backtesting/charts.py b/basana/backtesting/charts.py index 2ba51fb..5fe7aac 100644 --- a/basana/backtesting/charts.py +++ b/basana/backtesting/charts.py @@ -40,6 +40,7 @@ class DataPointFromSequence: :param seq: The sequence that will be used to get the value. """ + def __init__(self, seq: Sequence[Any]): self._seq = seq @@ -102,10 +103,16 @@ def add_traces(self, figure: go.Figure, row: int): close_values = list(self._close_values.get_x_y()) figure.add_trace( go.Candlestick( - x=open_values[0], open=open_values[1], high=high_values[1], low=low_values[1], - close=close_values[1], name=str(self._pair), xperiodalignment="start" + x=open_values[0], + open=open_values[1], + high=high_values[1], + low=low_values[1], + close=close_values[1], + name=str(self._pair), + xperiodalignment="start", ), - row=row, col=1 + row=row, + col=1, ) else: # Add a trace just with the closing values. @@ -117,10 +124,14 @@ def add_traces(self, figure: go.Figure, row: int): x, y = self._get_order_fills(OrderOperation.BUY).get_x_y() figure.add_trace( go.Scatter( - x=x, y=y, name="Buy", mode="markers", - marker=dict(symbol="arrow-up", color="green", size=self.buy_marker_size) + x=x, + y=y, + name="Buy", + mode="markers", + marker=dict(symbol="arrow-up", color="green", size=self.buy_marker_size), ), - row=row, col=1 + row=row, + col=1, ) # Add a trace with sell prices. @@ -128,10 +139,14 @@ def add_traces(self, figure: go.Figure, row: int): x, y = self._get_order_fills(OrderOperation.SELL).get_x_y() figure.add_trace( go.Scatter( - x=x, y=y, name="Sell", mode="markers", - marker=dict(symbol="arrow-down", color="red", size=self.sell_marker_size) + x=x, + y=y, + name="Sell", + mode="markers", + marker=dict(symbol="arrow-down", color="red", size=self.sell_marker_size), ), - row=row, col=1 + row=row, + col=1, ) # Add one trace per indicator. @@ -154,8 +169,7 @@ def add_indicator(self, name: str, get_data_point: ChartDataPointFn, marker: dic def _get_order_fills(self, op: OrderOperation) -> TimeSeries: ret = TimeSeries() orders = filter( - lambda order: order.pair == self._pair and order.operation == op, - self._exchange._get_all_orders() + lambda order: order.pair == self._pair and order.operation == op, self._exchange._get_all_orders() ) pair_info = self._exchange._get_pair_info(self._pair) for order in orders: @@ -286,6 +300,7 @@ class LineCharts: :param exchange: The backtesting exchange. """ + def __init__(self, exchange: Exchange): self._exchange = exchange self._balance_charts: Dict[str, AccountBalanceLineChart] = collections.OrderedDict() @@ -360,8 +375,12 @@ def show(self, show_legend: bool = True): # pragma: no cover fig.show() def save( - self, path: str, width: Optional[int] = None, height: Optional[int] = None, - scale: Optional[Union[int, float]] = None, show_legend: bool = True + self, + path: str, + width: Optional[int] = None, + height: Optional[int] = None, + scale: Optional[Union[int, float]] = None, + show_legend: bool = True, ): """Saves the chart to a file. @@ -396,7 +415,7 @@ def _build_figure(self, show_legend: bool = True) -> Optional[go.Figure]: row = 1 for chart in charts: chart.add_traces(figure, row) - figure.update_xaxes(rangeslider={'visible':False}, row=row, col=1) + figure.update_xaxes(rangeslider={"visible": False}, row=row, col=1) row += 1 figure.layout.update(showlegend=show_legend) diff --git a/basana/backtesting/errors.py b/basana/backtesting/errors.py index 1145b99..b853947 100644 --- a/basana/backtesting/errors.py +++ b/basana/backtesting/errors.py @@ -19,11 +19,13 @@ class Error(errors.Error): """Base class for backtesting exceptions.""" + pass class NotEnoughBalance(Error): """Not enough balance.""" + pass diff --git a/basana/backtesting/exchange.py b/basana/backtesting/exchange.py index 28661de..0c4c8b1 100644 --- a/basana/backtesting/exchange.py +++ b/basana/backtesting/exchange.py @@ -21,8 +21,19 @@ import logging import uuid -from basana.backtesting import account_balances, config, errors, fees, lending, loan_mgr, liquidity, \ - orders, order_mgr, prices, requests +from basana.backtesting import ( + account_balances, + config, + errors, + fees, + lending, + loan_mgr, + liquidity, + orders, + order_mgr, + prices, + requests, +) from basana.core import bar, dispatcher, enums, event, logs from basana.core.pair import Pair, PairInfo from basana.backtesting.lending import base as lending_base @@ -95,16 +106,17 @@ class Exchange: :param immediate_order_processing: If True, orders will be processed immediately after being added, using the closing price of the last bar available. If False, orders will be processed in the next bar event. """ + def __init__( - self, - dispatcher: dispatcher.BacktestingDispatcher, - initial_balances: Dict[str, Decimal], - liquidity_strategy_factory: LiquidityStrategyFactory = liquidity.VolumeShareImpact, - fee_strategy: fees.FeeStrategy = fees.NoFee(), - default_pair_info: Optional[PairInfo] = PairInfo(base_precision=0, quote_precision=2), - bid_ask_spread: Decimal = Decimal("0.5"), - lending_strategy: lending.LendingStrategy = lending.NoLoans(), - immediate_order_processing: bool = False + self, + dispatcher: dispatcher.BacktestingDispatcher, + initial_balances: Dict[str, Decimal], + liquidity_strategy_factory: LiquidityStrategyFactory = liquidity.VolumeShareImpact, + fee_strategy: fees.FeeStrategy = fees.NoFee(), + default_pair_info: Optional[PairInfo] = PairInfo(base_precision=0, quote_precision=2), + bid_ask_spread: Decimal = Decimal("0.5"), + lending_strategy: lending.LendingStrategy = lending.NoLoans(), + immediate_order_processing: bool = False, ): self._dispatcher = dispatcher self._balances = account_balances.AccountBalances(initial_balances) @@ -114,19 +126,20 @@ def __init__( self._loan_mgr = loan_mgr.LoanManager( lending_strategy, lending_base.ExchangeContext( - dispatcher=dispatcher, - account_balances=self._balances, - prices=self._prices, - config=self._config - ) + dispatcher=dispatcher, account_balances=self._balances, prices=self._prices, config=self._config + ), ) self._order_mgr = order_mgr.OrderManager( order_mgr.ExchangeContext( - dispatcher=dispatcher, account_balances=self._balances, prices=self._prices, - fee_strategy=fee_strategy, liquidity_strategy_factory=liquidity_strategy_factory, - loan_mgr=self._loan_mgr, config=self._config + dispatcher=dispatcher, + account_balances=self._balances, + prices=self._prices, + fee_strategy=fee_strategy, + liquidity_strategy_factory=liquidity_strategy_factory, + loan_mgr=self._loan_mgr, + config=self._config, ), - immediate_order_processing=immediate_order_processing + immediate_order_processing=immediate_order_processing, ) async def get_balance(self, symbol: str) -> Balance: @@ -167,16 +180,28 @@ async def create_order(self, order_request: requests.ExchangeOrder) -> CreatedOr logger.debug(logs.StructuredMessage("Request accepted", order_id=order.id)) order_info = order.get_order_info() return CreatedOrder( - id=order_info.id, pair=order_info.pair, is_open=order_info.is_open, operation=order_info.operation, - amount=order_info.amount, amount_filled=order_info.amount_filled, - amount_remaining=order_info.amount_remaining, quote_amount_filled=order_info.quote_amount_filled, - fees=order_info.fees, limit_price=order_info.limit_price, stop_price=order_info.stop_price, - loan_ids=order_info.loan_ids, fills=order_info.fills, + id=order_info.id, + pair=order_info.pair, + is_open=order_info.is_open, + operation=order_info.operation, + amount=order_info.amount, + amount_filled=order_info.amount_filled, + amount_remaining=order_info.amount_remaining, + quote_amount_filled=order_info.quote_amount_filled, + fees=order_info.fees, + limit_price=order_info.limit_price, + stop_price=order_info.stop_price, + loan_ids=order_info.loan_ids, + fills=order_info.fills, ) async def create_market_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, auto_borrow: bool = False, - auto_repay: bool = False + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ) -> CreatedOrder: """ Creates a market order. @@ -195,13 +220,18 @@ async def create_market_order( :param auto_repay: Automatically repay open loans once the order gets filled. """ pair_info = self._get_pair_info(pair) - return await self.create_order(requests.MarketOrder( - operation, pair, pair_info, amount, auto_borrow=auto_borrow, auto_repay=auto_repay - )) + return await self.create_order( + requests.MarketOrder(operation, pair, pair_info, amount, auto_borrow=auto_borrow, auto_repay=auto_repay) + ) async def create_limit_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, limit_price: Decimal, - auto_borrow: bool = False, auto_repay: bool = False + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + limit_price: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ) -> CreatedOrder: """ Creates a limit order. @@ -220,13 +250,20 @@ async def create_limit_order( :param auto_repay: Automatically repay open loans once the order gets filled. """ pair_info = self._get_pair_info(pair) - return await self.create_order(requests.LimitOrder( - operation, pair, pair_info, amount, limit_price, auto_borrow=auto_borrow, auto_repay=auto_repay - )) + return await self.create_order( + requests.LimitOrder( + operation, pair, pair_info, amount, limit_price, auto_borrow=auto_borrow, auto_repay=auto_repay + ) + ) async def create_stop_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, stop_price: Decimal, - auto_borrow: bool = False, auto_repay: bool = False + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + stop_price: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ) -> CreatedOrder: """ Creates a stop order. @@ -250,13 +287,21 @@ async def create_stop_order( :param auto_repay: Automatically repay open loans once the order gets filled. """ pair_info = self._get_pair_info(pair) - return await self.create_order(requests.StopOrder( - operation, pair, pair_info, amount, stop_price, auto_borrow=auto_borrow, auto_repay=auto_repay - )) + return await self.create_order( + requests.StopOrder( + operation, pair, pair_info, amount, stop_price, auto_borrow=auto_borrow, auto_repay=auto_repay + ) + ) async def create_stop_limit_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, stop_price: Decimal, limit_price: Decimal, - auto_borrow: bool = False, auto_repay: bool = False + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + stop_price: Decimal, + limit_price: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ) -> CreatedOrder: """ Creates a stop limit order. @@ -276,9 +321,18 @@ async def create_stop_limit_order( :param auto_repay: Automatically repay open loans once the order gets filled. """ pair_info = self._get_pair_info(pair) - return await self.create_order(requests.StopLimitOrder( - operation, pair, pair_info, amount, stop_price, limit_price, auto_borrow=auto_borrow, auto_repay=auto_repay - )) + return await self.create_order( + requests.StopLimitOrder( + operation, + pair, + pair_info, + amount, + stop_price, + limit_price, + auto_borrow=auto_borrow, + auto_repay=auto_repay, + ) + ) async def cancel_order(self, order_id: str) -> CanceledOrder: """ @@ -312,12 +366,7 @@ async def get_open_orders(self, pair: Optional[Pair] = None) -> List[OpenOrder]: returned. """ return [ - OpenOrder( - id=order.id, - operation=order.operation, - amount=order.amount, - amount_filled=order.amount_filled - ) + OpenOrder(id=order.id, operation=order.operation, amount=order.amount, amount_filled=order.amount_filled) for order in self._order_mgr.get_open_orders() if pair is None or order.pair == pair ] @@ -403,7 +452,7 @@ async def create_loan(self, symbol: str, amount: Decimal) -> lending.LoanInfo: return self._loan_mgr.create_loan(symbol, amount) async def get_loans( - self, borrowed_symbol: Optional[str] = None, is_open: Optional[bool] = None + self, borrowed_symbol: Optional[str] = None, is_open: Optional[bool] = None ) -> List[lending.LoanInfo]: """ Returns loans filtered by various conditions. @@ -456,6 +505,4 @@ def _get_balance(self, symbol: str) -> Balance: available = self._balances.get_available_balance(symbol) hold = self._balances.get_balance_on_hold(symbol) borrowed = self._balances.get_borrowed_balance(symbol) - return Balance( - available=available, hold=hold, borrowed=borrowed - ) + return Balance(available=available, hold=hold, borrowed=borrowed) diff --git a/basana/backtesting/fees.py b/basana/backtesting/fees.py index 689901e..743f42d 100644 --- a/basana/backtesting/fees.py +++ b/basana/backtesting/fees.py @@ -30,9 +30,7 @@ class FeeStrategy(metaclass=abc.ABCMeta): """ @abc.abstractmethod - def calculate_fees( - self, order: orders.Order, balance_updates: Dict[str, Decimal] - ) -> Dict[str, Decimal]: + def calculate_fees(self, order: orders.Order, balance_updates: Dict[str, Decimal]) -> Dict[str, Decimal]: raise NotImplementedError() diff --git a/basana/backtesting/helpers.py b/basana/backtesting/helpers.py index b4c4cbd..2077135 100644 --- a/basana/backtesting/helpers.py +++ b/basana/backtesting/helpers.py @@ -39,7 +39,7 @@ def is_open(self) -> bool: # pragma: no cover ... -TExchangeObject = TypeVar('TExchangeObject', bound=ExchangeObjectProto) +TExchangeObject = TypeVar("TExchangeObject", bound=ExchangeObjectProto) class ExchangeObjectContainer(Generic[TExchangeObject]): diff --git a/basana/backtesting/lending/base.py b/basana/backtesting/lending/base.py index 347f9cc..d8f8288 100644 --- a/basana/backtesting/lending/base.py +++ b/basana/backtesting/lending/base.py @@ -42,9 +42,7 @@ class LoanInfo: class Loan(metaclass=abc.ABCMeta): - def __init__( - self, id: str, borrowed_symbol: str, borrowed_amount: Decimal, created_at: datetime.datetime - ): + def __init__(self, id: str, borrowed_symbol: str, borrowed_amount: Decimal, created_at: datetime.datetime): assert borrowed_amount > Decimal(0), f"Invalid amount {borrowed_amount}" self._id = id diff --git a/basana/backtesting/lending/margin.py b/basana/backtesting/lending/margin.py index 4d15c96..8dab372 100644 --- a/basana/backtesting/lending/margin.py +++ b/basana/backtesting/lending/margin.py @@ -40,8 +40,12 @@ class MarginLoanConditions: class MarginLoan(base.Loan): def __init__( - self, id: str, borrowed_symbol: str, borrowed_amount: Decimal, created_at: datetime.datetime, - conditions: MarginLoanConditions + self, + id: str, + borrowed_symbol: str, + borrowed_amount: Decimal, + created_at: datetime.datetime, + conditions: MarginLoanConditions, ): super().__init__(id, borrowed_symbol, borrowed_amount, created_at) self._conditions = conditions @@ -74,9 +78,9 @@ class MarginLoans(base.LendingStrategy): :param margin_requirement: Minimum threshold for the value of the collateral relative to the total position. :param default_conditions: The default margin loan conditions. """ + def __init__( - self, quote_symbol: str, margin_requirement: Decimal, - default_conditions: Optional[MarginLoanConditions] = None + self, quote_symbol: str, margin_requirement: Decimal, default_conditions: Optional[MarginLoanConditions] = None ): assert margin_requirement > 0, "Margin requirement must be greater than zero" @@ -123,12 +127,10 @@ def margin_level(self) -> Decimal: """ assert self._exchange_ctx, "Not yet connected with the exchange" acc_balances = self._exchange_ctx.account_balances - return self.calculate_margin_level( - acc_balances.balances, acc_balances.holds, acc_balances.borrowed - ) + return self.calculate_margin_level(acc_balances.balances, acc_balances.holds, acc_balances.borrowed) def calculate_margin_level( - self, updated_balances: ValueMapDict, updated_holds: ValueMapDict, updated_borrowed: ValueMapDict + self, updated_balances: ValueMapDict, updated_holds: ValueMapDict, updated_borrowed: ValueMapDict ) -> Decimal: assert self._exchange_ctx and self._loan_mgr, "Not yet connected with the exchange" @@ -159,8 +161,13 @@ def __init__(self, margin_loans: MarginLoans): self._threshold = Decimal(100) def check( - self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap, - delta_balances: ValueMap, delta_holds: ValueMap, delta_borrowed: ValueMap + self, + updated_balances: ValueMap, + updated_holds: ValueMap, + updated_borrowed: ValueMap, + delta_balances: ValueMap, + delta_holds: ValueMap, + delta_borrowed: ValueMap, ): # If we're increasing any borrowed amount we need to check the margin level. if any(v > 0 for v in delta_borrowed.values()): diff --git a/basana/backtesting/loan_mgr.py b/basana/backtesting/loan_mgr.py index 9ca4ba5..cc33a6f 100644 --- a/basana/backtesting/loan_mgr.py +++ b/basana/backtesting/loan_mgr.py @@ -24,9 +24,7 @@ class LoanManager: - def __init__( - self, lending_strategy: lending_base.LendingStrategy, exchange_ctx: lending_base.ExchangeContext - ): + def __init__(self, lending_strategy: lending_base.LendingStrategy, exchange_ctx: lending_base.ExchangeContext): self._loans = bt_helpers.ExchangeObjectContainer[lending_base.Loan]() self._ctx = exchange_ctx self._lending_strategy = lending_strategy @@ -43,7 +41,7 @@ def create_loan(self, symbol: str, amount: Decimal) -> lending_base.LoanInfo: self._ctx.account_balances.update( balance_updates={loan.borrowed_symbol: loan.borrowed_amount}, borrowed_updates={loan.borrowed_symbol: loan.borrowed_amount}, - hold_updates=required_collateral + hold_updates=required_collateral, ) # Save the loan now that balance updates succeeded. @@ -53,7 +51,7 @@ def create_loan(self, symbol: str, amount: Decimal) -> lending_base.LoanInfo: return self._build_loan_info(loan) def get_loans( - self, borrowed_symbol: Optional[str] = None, is_open: Optional[bool] = None + self, borrowed_symbol: Optional[str] = None, is_open: Optional[bool] = None ) -> List[lending_base.LoanInfo]: loans = self._loans.get_all() if borrowed_symbol: @@ -81,7 +79,7 @@ def repay_loan(self, loan_id: str): self._ctx.account_balances.update( balance_updates=balance_updates, borrowed_updates={loan.borrowed_symbol: -loan.borrowed_amount}, - hold_updates={symbol: -amount for symbol, amount in collateral.items()} + hold_updates={symbol: -amount for symbol, amount in collateral.items()}, ) # Close the loan now that balance updates succeeded. @@ -101,7 +99,7 @@ def cancel_loan(self, loan_id: str): self._ctx.account_balances.update( balance_updates=balance_updates, borrowed_updates=balance_updates, - hold_updates={symbol: -amount for symbol, amount in collateral.items()} + hold_updates={symbol: -amount for symbol, amount in collateral.items()}, ) # Close the loan now that balance updates succeeded. @@ -119,14 +117,15 @@ def _get_open_loan(self, loan_id: str) -> lending_base.Loan: def _build_loan_info(self, loan: lending_base.Loan) -> lending_base.LoanInfo: outstanding_interest = ValueMap() if loan.is_open: - outstanding_interest += loan.calculate_interest( - self._ctx.dispatcher.now(), self._ctx.prices - ) + outstanding_interest += loan.calculate_interest(self._ctx.dispatcher.now(), self._ctx.prices) outstanding_interest.truncate(self._ctx.config) outstanding_interest.prune() return lending_base.LoanInfo( - id=loan.id, is_open=loan.is_open, borrowed_symbol=loan.borrowed_symbol, - borrowed_amount=loan.borrowed_amount, outstanding_interest=outstanding_interest, - paid_interest=copy.copy(loan.paid_interest) + id=loan.id, + is_open=loan.is_open, + borrowed_symbol=loan.borrowed_symbol, + borrowed_amount=loan.borrowed_amount, + outstanding_interest=outstanding_interest, + paid_interest=copy.copy(loan.paid_interest), ) diff --git a/basana/backtesting/order_mgr.py b/basana/backtesting/order_mgr.py index a51f70b..17f8af7 100644 --- a/basana/backtesting/order_mgr.py +++ b/basana/backtesting/order_mgr.py @@ -103,9 +103,9 @@ def add_order(self, order: Order): # The order got accepted. self._orders.add(order) except errors.NotEnoughBalance as e: - logger.debug(logs.StructuredMessage( - "Not enough balance to accept order", order=order.get_debug_info(), error=str(e) - )) + logger.debug( + logs.StructuredMessage("Not enough balance to accept order", order=order.get_debug_info(), error=str(e)) + ) raise # If immediate order processing is enabled we process the order using the last bar available. @@ -115,16 +115,23 @@ def add_order(self, order: Order): try: last_bar = self._ctx.prices.get_last_bar(order.pair) except errors.NotFound: - logger.debug(logs.StructuredMessage( - "No price available for immediate order processing", order=order.get_debug_info() - )) + logger.debug( + logs.StructuredMessage( + "No price available for immediate order processing", order=order.get_debug_info() + ) + ) push_order_update = not self._order_not_filled(order) else: now = self._ctx.dispatcher.now() bar = Bar( - begin=now, pair=order.pair, - open=last_bar.close, high=last_bar.close, low=last_bar.close, close=last_bar.close, - volume=last_bar.volume, duration=datetime.timedelta(milliseconds=1) + begin=now, + pair=order.pair, + open=last_bar.close, + high=last_bar.close, + low=last_bar.close, + close=last_bar.close, + volume=last_bar.volume, + duration=datetime.timedelta(milliseconds=1), ) liquidity_strategy = self._liquidity_strategies[order.pair] # If the order is not updated during processing, we push an update for the order that is being added. @@ -190,23 +197,23 @@ def _borrow(self, required_balances: ValueMap, order: Order): symbol: self._ctx.account_balances.get_available_balance(symbol) - required_amount for symbol, required_amount in required_balances.items() } - balances_short = { - symbol: -amount for symbol, amount in post_hold.items() if amount < Decimal(0) - } + balances_short = {symbol: -amount for symbol, amount in post_hold.items() if amount < Decimal(0)} loan_ids: List[str] = [] try: # Create a loan for every balance that we're short on. for symbol, amount in balances_short.items(): - logger.debug(logs.StructuredMessage( - "Borrowing for order", order_id=order.id, symbol=symbol, amount=amount - )) + logger.debug( + logs.StructuredMessage("Borrowing for order", order_id=order.id, symbol=symbol, amount=amount) + ) loan = self._ctx.loan_mgr.create_loan(symbol, amount) loan_ids.append(loan.id) except Exception as e: - logger.debug(logs.StructuredMessage( - "Failed to borrow", order_id=order.id, symbol=symbol, amount=amount, error=str(e) - )) + logger.debug( + logs.StructuredMessage( + "Failed to borrow", order_id=order.id, symbol=symbol, amount=amount, error=str(e) + ) + ) # If there are any errors borrowing money we'll rollback everything before propagating the exception. for loan_id in loan_ids: logger.debug(logs.StructuredMessage("Canceling loan", order_id=order.id, loan_id=loan_id)) @@ -224,8 +231,7 @@ def _repay_loans(self, order: Order): credit_symbol = order.pair.quote_symbol candidate_loans = [ - loan for loan in self._ctx.loan_mgr.get_loans(is_open=True) - if loan.borrowed_symbol == credit_symbol + loan for loan in self._ctx.loan_mgr.get_loans(is_open=True) if loan.borrowed_symbol == credit_symbol ] # Try to cancel bigger loans first. candidate_loans.sort(key=lambda loan: loan.borrowed_amount, reverse=True) @@ -263,15 +269,16 @@ def _order_not_filled(self, order: Order) -> bool: ret = True return ret - def _process_order( - self, order: Order, bar: Bar, liquidity_strategy: liquidity.LiquidityStrategy - ) -> bool: + def _process_order(self, order: Order, bar: Bar, liquidity_strategy: liquidity.LiquidityStrategy) -> bool: # Try to fill the order. - logger.debug(logs.StructuredMessage( - "Processing order", order=order.get_debug_info(), - bar={"open": bar.open, "high": bar.high, "low": bar.low, "close": bar.close, "volume": bar.volume} - )) + logger.debug( + logs.StructuredMessage( + "Processing order", + order=order.get_debug_info(), + bar={"open": bar.open, "high": bar.high, "low": bar.low, "close": bar.close, "volume": bar.volume}, + ) + ) fill = order.try_fill(bar, liquidity_strategy) if fill is None: return self._order_not_filled(order) @@ -279,11 +286,14 @@ def _process_order( # Round fill data. balance_updates = ValueMap(fill.balance_updates) balance_updates.prune() - logger.debug(logs.StructuredMessage( - "Order fill details before fees", order_id=order.id, fill_price=fill.fill_price, - balance_updates=balance_updates - - )) + logger.debug( + logs.StructuredMessage( + "Order fill details before fees", + order_id=order.id, + fill_price=fill.fill_price, + balance_updates=balance_updates, + ) + ) # Base and quote symbols must be present in the balance updates, otherwise the order can't be filled. if order.pair.base_symbol not in balance_updates or order.pair.quote_symbol not in balance_updates: return self._order_not_filled(order) @@ -304,18 +314,20 @@ def _process_order( # Update the order and release any pending balance on hold if the order is now closed. fill.fees = fees order.add_fill(fill) - logger.debug(logs.StructuredMessage( - "Order updated", order_id=order.id, final_updates=final_updates, order_state=order.state - )) + logger.debug( + logs.StructuredMessage( + "Order updated", order_id=order.id, final_updates=final_updates, order_state=order.state + ) + ) if not order.is_open: self._order_closed(order) self._push_order_update(order) ret = True except errors.NotEnoughBalance as e: - logger.debug(logs.StructuredMessage( - "Balance short processing order", order=order.get_debug_info(), error=str(e) - )) + logger.debug( + logs.StructuredMessage("Balance short processing order", order=order.get_debug_info(), error=str(e)) + ) ret = self._order_not_filled(order) return ret @@ -330,9 +342,7 @@ def _estimate_required_balances(self, order: Order) -> ValueMap: # Build a dictionary of balance updates suitable for calculating fees. base_sign = helpers.get_base_sign_for_operation(order.operation) - estimated_balance_updates = ValueMap({ - order.pair.base_symbol: order.amount * base_sign - }) + estimated_balance_updates = ValueMap({order.pair.base_symbol: order.amount * base_sign}) if estimated_fill_price: estimated_balance_updates[order.pair.quote_symbol] = order.amount * estimated_fill_price * -base_sign round_balance_updates(order, estimated_balance_updates) @@ -345,10 +355,9 @@ def _estimate_required_balances(self, order: Order) -> ValueMap: estimated_balance_updates += fees # Return only negative balance updates as required balances. - return ValueMap({ - symbol: -amount for symbol, amount in estimated_balance_updates.items() - if amount < Decimal(0) - }) + return ValueMap( + {symbol: -amount for symbol, amount in estimated_balance_updates.items() if amount < Decimal(0)} + ) def _push_order_update(self, order: Order): # Checking dispatcher.now_available is necessary to avoid calling dispatcher.now() when no events have been @@ -384,4 +393,3 @@ def round_fees(order: Order, fees: ValueMap): fees[symbol] = core_helpers.round_decimal(amount, precision, rounding=decimal.ROUND_UP) fees.prune() - diff --git a/basana/backtesting/orders.py b/basana/backtesting/orders.py index 71f5da7..9e642c3 100644 --- a/basana/backtesting/orders.py +++ b/basana/backtesting/orders.py @@ -94,8 +94,14 @@ def fill_price(self) -> Optional[Decimal]: # This is an internal abstraction to be used by the exchange. class Order(metaclass=abc.ABCMeta): def __init__( - self, id: str, operation: OrderOperation, pair: Pair, pair_info: PairInfo, - amount: Decimal, auto_borrow: bool = False, auto_repay: bool = False + self, + id: str, + operation: OrderOperation, + pair: Pair, + pair_info: PairInfo, + amount: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ): assert amount > Decimal(0), f"Invalid amount {amount}" @@ -188,11 +194,17 @@ def add_loan(self, loan_id: str): def get_order_info(self) -> OrderInfo: return OrderInfo( - id=self.id, pair=self.pair, is_open=self._state == OrderState.OPEN, operation=self.operation, - amount=self.amount, amount_filled=self.amount_filled, amount_remaining=self.amount_pending, + id=self.id, + pair=self.pair, + is_open=self._state == OrderState.OPEN, + operation=self.operation, + amount=self.amount, + amount_filled=self.amount_filled, + amount_remaining=self.amount_pending, quote_amount_filled=self.quote_amount_filled, fees={symbol: -amount for symbol, amount in self._fees.items() if amount}, - loan_ids=[loan_id for loan_id in self._loan_ids], fills=copy.copy(self.fills) + loan_ids=[loan_id for loan_id in self._loan_ids], + fills=copy.copy(self.fills), ) @abc.abstractmethod @@ -236,8 +248,14 @@ def get_debug_info(self) -> dict: class MarketOrder(Order): def __init__( - self, id: str, operation: OrderOperation, pair: Pair, pair_info: PairInfo, - amount: Decimal, auto_borrow: bool = False, auto_repay: bool = False + self, + id: str, + operation: OrderOperation, + pair: Pair, + pair_info: PairInfo, + amount: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ): super().__init__(id, operation, pair, pair_info, amount, auto_borrow=auto_borrow, auto_repay=auto_repay) @@ -257,17 +275,24 @@ def try_fill(self, bar: bar.Bar, liquidity_strategy: liquidity.LiquidityStrategy when=bar.begin, balance_updates={ self.pair.base_symbol: amount * base_sign, - self.pair.quote_symbol: round_decimal(price * amount * -base_sign, self.pair_info.quote_precision) + self.pair.quote_symbol: round_decimal(price * amount * -base_sign, self.pair_info.quote_precision), }, fees={}, - fill_price=price + fill_price=price, ) class LimitOrder(Order): def __init__( - self, id: str, operation: OrderOperation, pair: Pair, pair_info: PairInfo, - amount: Decimal, limit_price: Decimal, auto_borrow: bool = False, auto_repay: bool = False + self, + id: str, + operation: OrderOperation, + pair: Pair, + pair_info: PairInfo, + amount: Decimal, + limit_price: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ): assert limit_price > Decimal(0), "Invalid limit_price {limit_price}" @@ -309,10 +334,10 @@ def try_fill(self, bar: bar.Bar, liquidity_strategy: liquidity.LiquidityStrategy when=fill_dt, balance_updates={ self.pair.base_symbol: amount * base_sign, - self.pair.quote_symbol: round_decimal(price * amount * -base_sign, self.pair_info.quote_precision) + self.pair.quote_symbol: round_decimal(price * amount * -base_sign, self.pair_info.quote_precision), }, fees={}, - fill_price=price + fill_price=price, ) return ret @@ -333,8 +358,15 @@ def get_order_info(self) -> OrderInfo: class StopOrder(Order): def __init__( - self, id: str, operation: OrderOperation, pair: Pair, pair_info: PairInfo, - amount: Decimal, stop_price: Decimal, auto_borrow: bool = False, auto_repay: bool = False + self, + id: str, + operation: OrderOperation, + pair: Pair, + pair_info: PairInfo, + amount: Decimal, + stop_price: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ): assert stop_price > Decimal(0), "Invalid stop_price {stop_price}" @@ -386,10 +418,10 @@ def try_fill(self, bar: bar.Bar, liquidity_strategy: liquidity.LiquidityStrategy when=fill_dt, balance_updates={ self.pair.base_symbol: amount * base_sign, - self.pair.quote_symbol: round_decimal(price * amount * -base_sign, self.pair_info.quote_precision) + self.pair.quote_symbol: round_decimal(price * amount * -base_sign, self.pair_info.quote_precision), }, fees={}, - fill_price=price + fill_price=price, ) return ret @@ -410,9 +442,16 @@ def get_order_info(self) -> OrderInfo: class StopLimitOrder(Order): def __init__( - self, id: str, operation: OrderOperation, pair: Pair, pair_info: PairInfo, - amount: Decimal, stop_price: Decimal, limit_price: Decimal, auto_borrow: bool = False, - auto_repay: bool = False + self, + id: str, + operation: OrderOperation, + pair: Pair, + pair_info: PairInfo, + amount: Decimal, + stop_price: Decimal, + limit_price: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ): assert stop_price > Decimal(0), "Invalid stop_price {stop_price}" assert limit_price > Decimal(0), "Invalid limit_price {limit_price}" @@ -423,7 +462,7 @@ def __init__( self._stop_price_hit = False def try_fill_before_stop_hit( - self, bar: bar.Bar, liquidity_strategy: liquidity.LiquidityStrategy, amount: Decimal + self, bar: bar.Bar, liquidity_strategy: liquidity.LiquidityStrategy, amount: Decimal ) -> Optional[Fill]: assert not self._stop_price_hit @@ -482,15 +521,15 @@ def try_fill_before_stop_hit( when=fill_dt, balance_updates={ self.pair.base_symbol: amount * base_sign, - self.pair.quote_symbol: round_decimal(price * amount * -base_sign, self.pair_info.quote_precision) + self.pair.quote_symbol: round_decimal(price * amount * -base_sign, self.pair_info.quote_precision), }, fees={}, - fill_price=price + fill_price=price, ) return ret def try_fill_after_stop_hit( - self, bar: bar.Bar, liquidity_strategy: liquidity.LiquidityStrategy, amount: Decimal + self, bar: bar.Bar, liquidity_strategy: liquidity.LiquidityStrategy, amount: Decimal ) -> Optional[Fill]: price = None fill_dt = None @@ -521,10 +560,10 @@ def try_fill_after_stop_hit( when=fill_dt, balance_updates={ self.pair.base_symbol: amount * base_sign, - self.pair.quote_symbol: round_decimal(price * amount * -base_sign, self.pair_info.quote_precision) + self.pair.quote_symbol: round_decimal(price * amount * -base_sign, self.pair_info.quote_precision), }, fees={}, - fill_price=price + fill_price=price, ) return ret @@ -558,15 +597,18 @@ def get_order_info(self) -> OrderInfo: def slipped_price( - price: Decimal, amount: Decimal, - order: Order, liquidity_strategy: liquidity.LiquidityStrategy, - cap_low: Optional[Decimal] = None, cap_high: Optional[Decimal] = None + price: Decimal, + amount: Decimal, + order: Order, + liquidity_strategy: liquidity.LiquidityStrategy, + cap_low: Optional[Decimal] = None, + cap_high: Optional[Decimal] = None, ) -> Decimal: price_impact = liquidity_strategy.calculate_price_impact(amount) if order.operation == OrderOperation.BUY: - price *= (Decimal(1) + price_impact) + price *= Decimal(1) + price_impact else: - price *= (Decimal(1) - price_impact) + price *= Decimal(1) - price_impact if cap_low is not None: price = max(price, cap_low) diff --git a/basana/backtesting/prices.py b/basana/backtesting/prices.py index 29d89ce..1891f3b 100644 --- a/basana/backtesting/prices.py +++ b/basana/backtesting/prices.py @@ -27,6 +27,7 @@ class Prices: """ This class provides access to the last known prices of pairs. """ + def __init__(self, bid_ask_spread_pct: Decimal, config: config.Config): assert bid_ask_spread_pct > Decimal(0) @@ -45,8 +46,7 @@ def get_bid_ask(self, pair: Pair) -> Tuple[Decimal, Decimal]: last_price = last_bar.close pair_info = self._config.get_pair_info(pair) half_spread = core_helpers.truncate_decimal( - (last_price * self._bid_ask_spread_pct / Decimal("100")) / Decimal(2), - pair_info.quote_precision + (last_price * self._bid_ask_spread_pct / Decimal("100")) / Decimal(2), pair_info.quote_precision ) bid = last_price - half_spread ask = last_price + half_spread @@ -69,8 +69,8 @@ def convert(self, amount: Decimal, from_symbol: str, to_symbol: str) -> Decimal: return Decimal(0) for pair, price_fun in [ - (Pair(from_symbol, to_symbol), lambda price: price), - (Pair(to_symbol, from_symbol), lambda price: Decimal(1) / price), + (Pair(from_symbol, to_symbol), lambda price: price), + (Pair(to_symbol, from_symbol), lambda price: Decimal(1) / price), ]: last_bar = self._last_bars.get(pair) if last_bar: diff --git a/basana/backtesting/requests.py b/basana/backtesting/requests.py index ff3d67c..995b854 100644 --- a/basana/backtesting/requests.py +++ b/basana/backtesting/requests.py @@ -25,8 +25,13 @@ class ExchangeOrder(metaclass=abc.ABCMeta): def __init__( - self, operation: OrderOperation, pair: Pair, pair_info: PairInfo, - amount: Decimal, auto_borrow: bool = False, auto_repay: bool = False + self, + operation: OrderOperation, + pair: Pair, + pair_info: PairInfo, + amount: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ): self._operation = operation self._pair = pair @@ -85,8 +90,13 @@ def validate(self, pair_info: PairInfo): def create_order(self, id: str) -> orders.Order: return orders.MarketOrder( - id, self.operation, self.pair, self.pair_info, self.amount, auto_borrow=self.auto_borrow, - auto_repay=self.auto_repay + id, + self.operation, + self.pair, + self.pair_info, + self.amount, + auto_borrow=self.auto_borrow, + auto_repay=self.auto_repay, ) @@ -99,8 +109,14 @@ class LimitOrder(ExchangeOrder): """ def __init__( - self, operation: OrderOperation, pair: Pair, pair_info: PairInfo, amount: Decimal, limit_price: Decimal, - auto_borrow: bool = False, auto_repay: bool = False + self, + operation: OrderOperation, + pair: Pair, + pair_info: PairInfo, + amount: Decimal, + limit_price: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ): super().__init__(operation, pair, pair_info, amount, auto_borrow=auto_borrow, auto_repay=auto_repay) self._limit_price = limit_price @@ -120,8 +136,14 @@ def validate(self, pair_info: PairInfo): def create_order(self, id: str) -> orders.Order: return orders.LimitOrder( - id, self.operation, self.pair, self.pair_info, self.amount, self._limit_price, - auto_borrow=self.auto_borrow, auto_repay=self.auto_repay + id, + self.operation, + self.pair, + self.pair_info, + self.amount, + self._limit_price, + auto_borrow=self.auto_borrow, + auto_repay=self.auto_repay, ) @@ -138,8 +160,14 @@ class StopOrder(ExchangeOrder): """ def __init__( - self, operation: OrderOperation, pair: Pair, pair_info: PairInfo, amount: Decimal, stop_price: Decimal, - auto_borrow: bool = False, auto_repay: bool = False + self, + operation: OrderOperation, + pair: Pair, + pair_info: PairInfo, + amount: Decimal, + stop_price: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ): super().__init__(operation, pair, pair_info, amount, auto_borrow=auto_borrow, auto_repay=auto_repay) self._stop_price = stop_price @@ -159,8 +187,14 @@ def validate(self, pair_info: PairInfo): def create_order(self, id: str) -> orders.Order: return orders.StopOrder( - id, self.operation, self.pair, self.pair_info, self.amount, self._stop_price, - auto_borrow=self.auto_borrow, auto_repay=self.auto_repay + id, + self.operation, + self.pair, + self.pair_info, + self.amount, + self._stop_price, + auto_borrow=self.auto_borrow, + auto_repay=self.auto_repay, ) @@ -175,8 +209,15 @@ class StopLimitOrder(ExchangeOrder): """ def __init__( - self, operation: OrderOperation, pair: Pair, pair_info: PairInfo, amount: Decimal, stop_price: Decimal, - limit_price: Decimal, auto_borrow: bool = False, auto_repay: bool = False + self, + operation: OrderOperation, + pair: Pair, + pair_info: PairInfo, + amount: Decimal, + stop_price: Decimal, + limit_price: Decimal, + auto_borrow: bool = False, + auto_repay: bool = False, ): super().__init__(operation, pair, pair_info, amount, auto_borrow=auto_borrow, auto_repay=auto_repay) self._stop_price = stop_price @@ -204,6 +245,13 @@ def validate(self, pair_info: PairInfo): def create_order(self, id: str) -> orders.Order: return orders.StopLimitOrder( - id, self.operation, self.pair, self.pair_info, self.amount, self._stop_price, self._limit_price, - auto_borrow=self.auto_borrow, auto_repay=self.auto_repay + id, + self.operation, + self.pair, + self.pair_info, + self.amount, + self._stop_price, + self._limit_price, + auto_borrow=self.auto_borrow, + auto_repay=self.auto_repay, ) diff --git a/basana/core/bar.py b/basana/core/bar.py index 6ba80cc..32e1405 100644 --- a/basana/core/bar.py +++ b/basana/core/bar.py @@ -45,9 +45,15 @@ class Bar: """ def __init__( - self, begin: datetime.datetime, pair: pair.Pair, - open: Decimal, high: Decimal, low: Decimal, close: Decimal, volume: Decimal, - duration: datetime.timedelta + self, + begin: datetime.datetime, + pair: pair.Pair, + open: Decimal, + high: Decimal, + low: Decimal, + close: Decimal, + volume: Decimal, + duration: datetime.timedelta, ): if high < low: raise InvalidBar(f"high < low on {begin}") @@ -114,9 +120,11 @@ def on_error(self, error: Any): def push_trade(self, when: datetime.datetime, price: Decimal, amount: Decimal): # Trades must arrive in order. if self._next_trade_ge and when < self._next_trade_ge: - self.on_error(logs.StructuredMessage( - "Trade pushed out of order", last=self._next_trade_ge, current=when, pair=self._pair - )) + self.on_error( + logs.StructuredMessage( + "Trade pushed out of order", last=self._next_trade_ge, current=when, pair=self._pair + ) + ) return self._trades.append((when, price, amount)) @@ -137,9 +145,11 @@ def _flush(self, begin: datetime.datetime, end: datetime.datetime): # Calculate open, high, low, close and volume in the given window. for i, (when, price, amount) in enumerate(self._trades): if when < begin: - self.on_error(logs.StructuredMessage( - "Trade is out of order", datetime=when, begin=begin, end=end, pair=self._pair - )) + self.on_error( + logs.StructuredMessage( + "Trade is out of order", datetime=when, begin=begin, end=end, pair=self._pair + ) + ) continue # If the trade belongs to a future window, then we're done processing the current window. if when > end: diff --git a/basana/core/config.py b/basana/core/config.py index e55fd26..3928478 100644 --- a/basana/core/config.py +++ b/basana/core/config.py @@ -14,6 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. + class Missing: pass diff --git a/basana/core/dispatcher.py b/basana/core/dispatcher.py index 44337e5..73d495d 100644 --- a/basana/core/dispatcher.py +++ b/basana/core/dispatcher.py @@ -84,6 +84,7 @@ class EventMultiplexer: """ A multiplexer that manages multiple event sources and provides methods to retrieve events in chronological order. """ + def __init__(self) -> None: self._prefetched_events: Dict[event.EventSource, Optional[event.Event]] = {} @@ -93,10 +94,7 @@ def add(self, source: event.EventSource): def peek_next_event_dt(self) -> Optional[datetime.datetime]: self._prefetch() - return min( - [evnt.when for evnt in self._prefetched_events.values() if evnt], - default=None - ) + return min([evnt.when for evnt in self._prefetched_events.values() if evnt], default=None) def pop(self, max_dt: datetime.datetime) -> Tuple[Optional[event.EventSource], Optional[event.Event]]: ret_source: Optional[event.EventSource] = None @@ -123,7 +121,7 @@ def pop(self, max_dt: datetime.datetime) -> Tuple[Optional[event.EventSource], O ret_source = source ret_event = evnt when_upper = evnt.when - + # Consume the event. if ret_source: self._prefetched_events[ret_source] = None @@ -135,9 +133,7 @@ def pop_while(self, max_dt: datetime.datetime) -> Generator[Tuple[event.EventSou yield (cast(event.EventSource, src_and_event[0]), cast(event.Event, src_and_event[1])) def _prefetch(self): - sources_to_pop = [ - source for source, event in self._prefetched_events.items() if event is None - ] + sources_to_pop = [source for source, event in self._prefetched_events.items() if event is None] for source in sources_to_pop: if event := source.pop(): self._prefetched_events[source] = event @@ -293,9 +289,11 @@ async def _core_task_group(self): self._core_tasks = None async def _dispatch_event(self, event_dispatch: EventDispatch): - logger.debug(logs.StructuredMessage( - "Dispatching event", when=event_dispatch.event.when, type=helpers.classpath(event_dispatch.event) - )) + logger.debug( + logs.StructuredMessage( + "Dispatching event", when=event_dispatch.event.when, type=helpers.classpath(event_dispatch.event) + ) + ) if self._sniffers_pre: await asyncio.gather( *[self._call_event_handler(event_dispatch.event, handler) for handler in self._sniffers_pre] @@ -313,10 +311,14 @@ async def _call_event_handler(self, event: event.Event, handler: EventHandler): try: return await handler(event) except Exception as e: - logger.exception(logs.StructuredMessage( - "Unhandled exception in event handler", error=e, event=dict(type=type(event), when=event.when), - handler=handler - )) + logger.exception( + logs.StructuredMessage( + "Unhandled exception in event handler", + error=e, + event=dict(type=type(event), when=event.when), + handler=handler, + ) + ) if self.stop_on_handler_exceptions: self.stop() @@ -326,9 +328,9 @@ async def _execute_scheduled(self, dt: datetime.datetime, job: SchedulerJob): try: await job() except Exception as e: - logger.exception(logs.StructuredMessage( - "Unhandled exception in handler", error=e, dt=dt, scheduler_job=job - )) + logger.exception( + logs.StructuredMessage("Unhandled exception in handler", error=e, dt=dt, scheduler_job=job) + ) if self.stop_on_handler_exceptions: self.stop() @@ -366,8 +368,9 @@ async def _dispatch_loop(self): next_dt = self._event_mux.peek_next_event_dt() if next_dt: # Check that events are processed in ascending order. - assert self._last_dt is None or next_dt >= self._last_dt, \ + assert self._last_dt is None or next_dt >= self._last_dt, ( f"{next_dt} can't be dispatched after {self._last_dt}" + ) await self._dispatch_scheduled(next_dt) await self._dispatch_events(next_dt) @@ -414,7 +417,7 @@ def __init__(self, max_concurrent: int): super().__init__(max_concurrent=max_concurrent) self._prev_event_dt: Dict[event.EventSource, datetime.datetime] = {} self.idle_sleep: float = 0.001 - self._wait_all_timeout: float = 0 # TODO: Will be removed in a future version. + self._wait_all_timeout: float = 0 # TODO: Will be removed in a future version. self._idle_handlers: List[IdleHandler] = [] def now(self) -> datetime.datetime: @@ -452,9 +455,7 @@ async def _dispatch_loop(self): async def _on_idle(self): if self._idle_handlers: - await gather_no_raise(*[ - self._handler_tasks.push(idle_handler) for idle_handler in self._idle_handlers - ]) + await gather_no_raise(*[self._handler_tasks.push(idle_handler) for idle_handler in self._idle_handlers]) # Avoid trashing the CPU if there's nothing to do. await asyncio.sleep(self.idle_sleep) @@ -471,19 +472,20 @@ async def _push_events(self, dt: datetime.datetime): # Check that events from the same source are returned in order. prev_event_dt = self._prev_event_dt.get(source) if prev_event_dt is not None and evnt.when < prev_event_dt: - self.on_error(logs.StructuredMessage( - "Events returned out of order", source=type(source), previous=prev_event_dt, current=evnt.when - )) + self.on_error( + logs.StructuredMessage( + "Events returned out of order", source=type(source), previous=prev_event_dt, current=evnt.when + ) + ) # TODO: Not ignoring out-of-order events should be an option. continue self._prev_event_dt[source] = evnt.when # Push event into the task pool for processing. await self._handler_tasks.push( - functools.partial(self._dispatch_event, EventDispatch( - event=evnt, - handlers=self._event_handlers[source] - )) + functools.partial( + self._dispatch_event, EventDispatch(event=evnt, handlers=self._event_handlers[source]) + ) ) diff --git a/basana/core/errors.py b/basana/core/errors.py index fa8645b..1f62c86 100644 --- a/basana/core/errors.py +++ b/basana/core/errors.py @@ -17,4 +17,5 @@ class Error(Exception): """Base class for exceptions.""" + pass diff --git a/basana/core/event.py b/basana/core/event.py index d0eed4a..012a89f 100644 --- a/basana/core/event.py +++ b/basana/core/event.py @@ -116,9 +116,12 @@ class FifoQueueEventSource(EventSource): :param producer: An optional producer associated with this event source. :param events: An optional list of initial events. """ + def __init__( - self, producer: Optional[Producer] = None, events: List[Event] = [], - priority: int = DEFAULT_EVENT_SOURCE_PRIORITY + self, + producer: Optional[Producer] = None, + events: List[Event] = [], + priority: int = DEFAULT_EVENT_SOURCE_PRIORITY, ): super().__init__(producer, priority) self._queue = deque(events) diff --git a/basana/core/event_sources/csv.py b/basana/core/event_sources/csv.py index 737e9f1..1937300 100644 --- a/basana/core/event_sources/csv.py +++ b/basana/core/event_sources/csv.py @@ -32,13 +32,13 @@ @contextlib.contextmanager -def open_file_with_detected_encoding(filename, default_encoding='utf-8'): - with open(filename, 'rb') as file: +def open_file_with_detected_encoding(filename, default_encoding="utf-8"): + with open(filename, "rb") as file: raw = file.read(4) # Read enough bytes to detect BOMs boms = [ - (codecs.BOM_UTF32_LE, 'utf-32-le'), - (codecs.BOM_UTF32_BE, 'utf-32-be'), + (codecs.BOM_UTF32_LE, "utf-32-le"), + (codecs.BOM_UTF32_BE, "utf-32-be"), (codecs.BOM_UTF16_LE, "utf-16-le"), (codecs.BOM_UTF16_BE, "utf-16-be"), (codecs.BOM_UTF8, "utf-8-sig"), @@ -52,7 +52,7 @@ def open_file_with_detected_encoding(filename, default_encoding='utf-8'): break # Re-open the file with the detected encoding and skip the bom. - f = open(filename, 'r', encoding=encoding) + f = open(filename, "r", encoding=encoding) if offset: f.seek(offset) yield f diff --git a/basana/core/event_sources/trading_signal.py b/basana/core/event_sources/trading_signal.py index 4029ff6..ff0f2f1 100644 --- a/basana/core/event_sources/trading_signal.py +++ b/basana/core/event_sources/trading_signal.py @@ -51,14 +51,13 @@ class TradingSignal(BaseTradingSignal): """ def __init__( - self, when: datetime.datetime, op_or_pos: Union[enums.OrderOperation, enums.Position], pair: pair.Pair + self, when: datetime.datetime, op_or_pos: Union[enums.OrderOperation, enums.Position], pair: pair.Pair ): super().__init__(when) if isinstance(op_or_pos, enums.OrderOperation): helpers.deprecation_warning( - "Support for bs.OrderOperation in trading signals will be removed soon." - " Switch to bs.Position" + "Support for bs.OrderOperation in trading signals will be removed soon. Switch to bs.Position" ) op_or_pos = { enums.OrderOperation.BUY: enums.Position.LONG, @@ -110,8 +109,10 @@ class TradingSignalSource(event.FifoQueueEventSource): """ def __init__( - self, dispatcher: dispatcher.EventDispatcher, producer: Optional[event.Producer] = None, - events: List[event.Event] = [] + self, + dispatcher: dispatcher.EventDispatcher, + producer: Optional[event.Producer] = None, + events: List[event.Event] = [], ): super().__init__(producer=producer, events=events) self._dispatcher = dispatcher diff --git a/basana/core/helpers.py b/basana/core/helpers.py index a27e4a1..6453182 100644 --- a/basana/core/helpers.py +++ b/basana/core/helpers.py @@ -72,14 +72,13 @@ class TaskPool: :param size: The maximum number of tasks to be running at the same time. :param max_queue_size: The maximum number of coroutine functions to be waiting in the queue for execution. """ + def __init__(self, max_tasks: int, max_queue_size: Optional[int] = None): assert max_tasks > 0, "Invalid max_tasks" assert max_queue_size is None or max_queue_size > 0, "Invalid max_queue_size" self._max_tasks = max_tasks - self._queue = LazyProxy( - lambda: asyncio.Queue(maxsize=max_tasks if max_queue_size is None else max_queue_size) - ) + self._queue = LazyProxy(lambda: asyncio.Queue(maxsize=max_tasks if max_queue_size is None else max_queue_size)) self._tasks: Dict[str, asyncio.Task] = {} self._queue_timeout = 1.0 self._active = 0 @@ -149,7 +148,6 @@ async def _task_main(self, task_name: str): try: eof = False while not eof: - try: coro_func = await asyncio.wait_for(self._queue.get(), timeout=self._queue_timeout) except asyncio.TimeoutError: diff --git a/basana/core/websockets.py b/basana/core/websockets.py index bb87359..9899b89 100644 --- a/basana/core/websockets.py +++ b/basana/core/websockets.py @@ -40,10 +40,14 @@ async def push_from_message(self, message: dict): class WebSocketClient(event.Producer, metaclass=abc.ABCMeta): - """"Base class for channel based web socket clients.""" + """ "Base class for channel based web socket clients.""" + def __init__( - self, url: str, session: Optional[aiohttp.ClientSession] = None, config_overrides: dict = {}, - heartbeat: float = 30 + self, + url: str, + session: Optional[aiohttp.ClientSession] = None, + config_overrides: dict = {}, + heartbeat: float = 30, ): super().__init__() self._url = url @@ -92,10 +96,11 @@ async def main(self): try: logger.debug(logs.StructuredMessage("Connecting websocket", src=self, url=self._url)) last_connect_ts = time.time() - async with helpers.use_or_create_session(session=self._session) as session, \ - session.ws_connect(self._url, heartbeat=self._heartbeat) as ws_cli, \ - helpers.TaskGroup() as tg: - + async with ( + helpers.use_or_create_session(session=self._session) as session, + session.ws_connect(self._url, heartbeat=self._heartbeat) as ws_cli, + helpers.TaskGroup() as tg, + ): # Turn this off since we just reconnected. self._reconnect_request.clear() @@ -111,9 +116,7 @@ async def main(self): await self.on_error(e) @abc.abstractmethod - async def subscribe_to_channels( - self, channels: List[str], ws_cli: aiohttp.ClientWebSocketResponse - ): + async def subscribe_to_channels(self, channels: List[str], ws_cli: aiohttp.ClientWebSocketResponse): raise NotImplementedError() @abc.abstractmethod diff --git a/basana/external/binance/client/__init__.py b/basana/external/binance/client/__init__.py index d2cb224..81f3524 100644 --- a/basana/external/binance/client/__init__.py +++ b/basana/external/binance/client/__init__.py @@ -27,9 +27,12 @@ class APIClient: def __init__( - self, api_key: Optional[str] = None, api_secret: Optional[str] = None, - session: Optional[aiohttp.ClientSession] = None, tb: Optional[token_bucket.TokenBucketLimiter] = None, - config_overrides: dict = {} + self, + api_key: Optional[str] = None, + api_secret: Optional[str] = None, + session: Optional[aiohttp.ClientSession] = None, + tb: Optional[token_bucket.TokenBucketLimiter] = None, + config_overrides: dict = {}, ): self._client = base.BaseClient( api_key=api_key, api_secret=api_secret, session=session, tb=tb, config_overrides=config_overrides @@ -60,16 +63,23 @@ async def get_order_book(self, symbol: str, limit: Optional[int] = None) -> dict return await self._client.make_request("GET", "/api/v3/depth", qs_params=params) async def get_candlestick_data( - self, symbol: str, interval: str, start_time: Optional[int] = None, end_time: Optional[int] = None, - limit: Optional[int] = None + self, + symbol: str, + interval: str, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + limit: Optional[int] = None, ) -> list: params: Dict[str, Any] = { "symbol": symbol, "interval": interval, } - base.set_optional_params(params, ( - ("startTime", start_time), - ("endTime", end_time), - ("limit", limit), - )) + base.set_optional_params( + params, + ( + ("startTime", start_time), + ("endTime", end_time), + ("limit", limit), + ), + ) return await self._client.make_request("GET", "/api/v3/klines", qs_params=params) diff --git a/basana/external/binance/client/base.py b/basana/external/binance/client/base.py index 5801c5e..43d0a4a 100644 --- a/basana/external/binance/client/base.py +++ b/basana/external/binance/client/base.py @@ -37,6 +37,7 @@ class Error(Exception): :param resp: The response. :param json_response: The response body, if it was a JSON. """ + def __init__(self, msg: str, code: Optional[int], resp: aiohttp.ClientResponse, json_response: Optional[Any]): super().__init__(msg) #: The error message. @@ -65,12 +66,16 @@ def raise_for_error(resp: aiohttp.ClientResponse, json_response): class BaseClient: def __init__( - self, api_key: Optional[str] = None, api_secret: Optional[str] = None, - session: Optional[aiohttp.ClientSession] = None, tb: Optional[token_bucket.TokenBucketLimiter] = None, - config_overrides: dict = {} + self, + api_key: Optional[str] = None, + api_secret: Optional[str] = None, + session: Optional[aiohttp.ClientSession] = None, + tb: Optional[token_bucket.TokenBucketLimiter] = None, + config_overrides: dict = {}, ): - assert not ((api_key is None) ^ (api_secret is None)), \ + assert not ((api_key is None) ^ (api_secret is None)), ( "Both api_key and api_secret should be set, or none of them" + ) self._api_key = api_key self._api_secret = api_secret @@ -79,8 +84,13 @@ def __init__( self._config_overrides = config_overrides async def make_request( - self, method: str, path: str, send_key: bool = False, send_sig: bool = False, - qs_params: Dict[str, Any] = {}, data: Dict[str, Any] = {} + self, + method: str, + path: str, + send_key: bool = False, + send_sig: bool = False, + qs_params: Dict[str, Any] = {}, + data: Dict[str, Any] = {}, ) -> Any: if self._tb and (sleep_time := self._tb.consume()): await asyncio.sleep(sleep_time) diff --git a/basana/external/binance/client/margin.py b/basana/external/binance/client/margin.py index f55a2d6..97f6090 100644 --- a/basana/external/binance/client/margin.py +++ b/basana/external/binance/client/margin.py @@ -32,10 +32,18 @@ def is_isolated(self) -> bool: raise NotImplementedError() async def create_order( - self, symbol: str, side: str, type: str, time_in_force: Optional[str] = None, - quantity: Optional[Decimal] = None, quote_order_qty: Optional[Decimal] = None, - price: Optional[Decimal] = None, stop_price: Optional[Decimal] = None, - new_client_order_id: Optional[str] = None, side_effect_type: Optional[str] = None, **kwargs: Dict[str, Any] + self, + symbol: str, + side: str, + type: str, + time_in_force: Optional[str] = None, + quantity: Optional[Decimal] = None, + quote_order_qty: Optional[Decimal] = None, + price: Optional[Decimal] = None, + stop_price: Optional[Decimal] = None, + new_client_order_id: Optional[str] = None, + side_effect_type: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> dict: params: Dict[str, Any] = { "symbol": symbol, @@ -43,56 +51,65 @@ async def create_order( "isIsolated": self.is_isolated, "type": type, } - base.set_optional_params(params, ( - ("timeInForce", time_in_force), - ("quantity", quantity), - ("quoteOrderQty", quote_order_qty), - ("price", price), - ("stopPrice", stop_price), - ("newClientOrderId", new_client_order_id), - ("sideEffectType", side_effect_type), - )) + base.set_optional_params( + params, + ( + ("timeInForce", time_in_force), + ("quantity", quantity), + ("quoteOrderQty", quote_order_qty), + ("price", price), + ("stopPrice", stop_price), + ("newClientOrderId", new_client_order_id), + ("sideEffectType", side_effect_type), + ), + ) params.update(kwargs) return await self._client.make_request("POST", "/sapi/v1/margin/order", data=params, send_sig=True) async def query_order( - self, symbol: str, order_id: Optional[int] = None, orig_client_order_id: Optional[str] = None + self, symbol: str, order_id: Optional[int] = None, orig_client_order_id: Optional[str] = None ) -> dict: - assert (order_id is not None) ^ (orig_client_order_id is not None), \ + assert (order_id is not None) ^ (orig_client_order_id is not None), ( "Either order_id or orig_client_order_id should be set" + ) params: Dict[str, Any] = { "symbol": symbol, "isIsolated": json.dumps(self.is_isolated), } - base.set_optional_params(params, ( - ("orderId", order_id), - ("origClientOrderId", orig_client_order_id), - )) + base.set_optional_params( + params, + ( + ("orderId", order_id), + ("origClientOrderId", orig_client_order_id), + ), + ) return await self._client.make_request("GET", "/sapi/v1/margin/order", qs_params=params, send_sig=True) - async def get_open_orders( - self, symbol: Optional[str] = None - ) -> dict: + async def get_open_orders(self, symbol: Optional[str] = None) -> dict: params: Dict[str, Any] = {"isIsolated": json.dumps(self.is_isolated)} if symbol is not None: params["symbol"] = symbol return await self._client.make_request("GET", "/sapi/v1/margin/openOrders", qs_params=params, send_sig=True) async def cancel_order( - self, symbol: str, order_id: Optional[int] = None, orig_client_order_id: Optional[str] = None + self, symbol: str, order_id: Optional[int] = None, orig_client_order_id: Optional[str] = None ) -> dict: - assert (order_id is not None) ^ (orig_client_order_id is not None), \ + assert (order_id is not None) ^ (orig_client_order_id is not None), ( "Either order_id or orig_client_order_id should be set" + ) params: Dict[str, Any] = { "symbol": symbol, "isIsolated": json.dumps(self.is_isolated), } - base.set_optional_params(params, ( - ("orderId", order_id), - ("origClientOrderId", orig_client_order_id), - )) + base.set_optional_params( + params, + ( + ("orderId", order_id), + ("origClientOrderId", orig_client_order_id), + ), + ) return await self._client.make_request("DELETE", "/sapi/v1/margin/order", qs_params=params, send_sig=True) async def get_trades(self, symbol: str, order_id: Optional[int] = None) -> List[dict]: @@ -105,11 +122,19 @@ async def get_trades(self, symbol: str, order_id: Optional[int] = None) -> List[ return await self._client.make_request("GET", "/sapi/v1/margin/myTrades", qs_params=params, send_sig=True) async def create_oco( - self, symbol: str, side: str, quantity: Decimal, price: Decimal, stop_price: Decimal, - stop_limit_price: Optional[Decimal] = None, stop_limit_time_in_force: Optional[str] = None, - list_client_order_id: Optional[str] = None, side_effect_type: Optional[str] = None, - limit_client_order_id: Optional[str] = None, stop_client_order_id: Optional[str] = None, - **kwargs: Dict[str, Any] + self, + symbol: str, + side: str, + quantity: Decimal, + price: Decimal, + stop_price: Decimal, + stop_limit_price: Optional[Decimal] = None, + stop_limit_time_in_force: Optional[str] = None, + list_client_order_id: Optional[str] = None, + side_effect_type: Optional[str] = None, + limit_client_order_id: Optional[str] = None, + stop_client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> dict: params: Dict[str, Any] = { "symbol": symbol, @@ -119,46 +144,57 @@ async def create_oco( "stopPrice": str(stop_price), "isIsolated": self.is_isolated, } - base.set_optional_params(params, ( - ("listClientOrderId", list_client_order_id), - ("stopLimitPrice", stop_limit_price), - ("stopLimitTimeInForce", stop_limit_time_in_force), - ("sideEffectType", side_effect_type), - ("limitClientOrderId", limit_client_order_id), - ("stopClientOrderId", stop_client_order_id), - )) + base.set_optional_params( + params, + ( + ("listClientOrderId", list_client_order_id), + ("stopLimitPrice", stop_limit_price), + ("stopLimitTimeInForce", stop_limit_time_in_force), + ("sideEffectType", side_effect_type), + ("limitClientOrderId", limit_client_order_id), + ("stopClientOrderId", stop_client_order_id), + ), + ) params.update(kwargs) return await self._client.make_request("POST", "/sapi/v1/margin/order/oco", data=params, send_sig=True) async def query_oco_order( - self, order_list_id: Optional[int] = None, client_order_list_id: Optional[str] = None + self, order_list_id: Optional[int] = None, client_order_list_id: Optional[str] = None ) -> dict: - assert (order_list_id is not None) ^ (client_order_list_id is not None), \ + assert (order_list_id is not None) ^ (client_order_list_id is not None), ( "Either order_list_id or client_order_list_id should be set" + ) params: Dict[str, Any] = { "isIsolated": json.dumps(self.is_isolated), } - base.set_optional_params(params, ( - ("orderListId", order_list_id), - ("origClientOrderId", client_order_list_id), - )) + base.set_optional_params( + params, + ( + ("orderListId", order_list_id), + ("origClientOrderId", client_order_list_id), + ), + ) return await self._client.make_request("GET", "/sapi/v1/margin/orderList", qs_params=params, send_sig=True) async def cancel_oco_order( - self, symbol: str, order_list_id: Optional[int] = None, client_order_list_id: Optional[str] = None + self, symbol: str, order_list_id: Optional[int] = None, client_order_list_id: Optional[str] = None ) -> dict: - assert (order_list_id is not None) ^ (client_order_list_id is not None), \ + assert (order_list_id is not None) ^ (client_order_list_id is not None), ( "Either order_list_id or client_order_list_id should be set" + ) params: Dict[str, Any] = { "symbol": symbol, "isIsolated": json.dumps(self.is_isolated), } - base.set_optional_params(params, ( - ("orderListId", order_list_id), - ("origClientOrderId", client_order_list_id), - )) + base.set_optional_params( + params, + ( + ("orderListId", order_list_id), + ("origClientOrderId", client_order_list_id), + ), + ) return await self._client.make_request("DELETE", "/sapi/v1/margin/orderList", data=params, send_sig=True) diff --git a/basana/external/binance/client/spot.py b/basana/external/binance/client/spot.py index 173c041..c9cf7b3 100644 --- a/basana/external/binance/client/spot.py +++ b/basana/external/binance/client/spot.py @@ -30,59 +30,75 @@ async def get_account_information(self) -> dict: return await self._client.make_request("GET", "/api/v3/account", send_sig=True) async def create_order( - self, symbol: str, side: str, type: str, time_in_force: Optional[str] = None, - quantity: Optional[Decimal] = None, quote_order_qty: Optional[Decimal] = None, - price: Optional[Decimal] = None, stop_price: Optional[Decimal] = None, - new_client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + symbol: str, + side: str, + type: str, + time_in_force: Optional[str] = None, + quantity: Optional[Decimal] = None, + quote_order_qty: Optional[Decimal] = None, + price: Optional[Decimal] = None, + stop_price: Optional[Decimal] = None, + new_client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> dict: params: Dict[str, Any] = { "symbol": symbol, "side": side, "type": type, } - base.set_optional_params(params, ( - ("timeInForce", time_in_force), - ("quantity", quantity), - ("quoteOrderQty", quote_order_qty), - ("price", price), - ("stopPrice", stop_price), - ("newClientOrderId", new_client_order_id), - )) + base.set_optional_params( + params, + ( + ("timeInForce", time_in_force), + ("quantity", quantity), + ("quoteOrderQty", quote_order_qty), + ("price", price), + ("stopPrice", stop_price), + ("newClientOrderId", new_client_order_id), + ), + ) params.update(kwargs) return await self._client.make_request("POST", "/api/v3/order", data=params, send_sig=True) async def query_order( - self, symbol: str, order_id: Optional[int] = None, orig_client_order_id: Optional[str] = None + self, symbol: str, order_id: Optional[int] = None, orig_client_order_id: Optional[str] = None ) -> dict: - assert (order_id is not None) ^ (orig_client_order_id is not None), \ + assert (order_id is not None) ^ (orig_client_order_id is not None), ( "Either order_id or orig_client_order_id should be set" + ) params: Dict[str, Any] = {"symbol": symbol} - base.set_optional_params(params, ( - ("orderId", order_id), - ("origClientOrderId", orig_client_order_id), - )) + base.set_optional_params( + params, + ( + ("orderId", order_id), + ("origClientOrderId", orig_client_order_id), + ), + ) return await self._client.make_request("GET", "/api/v3/order", qs_params=params, send_sig=True) - async def get_open_orders( - self, symbol: Optional[str] = None - ) -> dict: + async def get_open_orders(self, symbol: Optional[str] = None) -> dict: params: Dict[str, Any] = {} if symbol is not None: params["symbol"] = symbol return await self._client.make_request("GET", "/api/v3/openOrders", qs_params=params, send_sig=True) async def cancel_order( - self, symbol: str, order_id: Optional[int] = None, orig_client_order_id: Optional[str] = None + self, symbol: str, order_id: Optional[int] = None, orig_client_order_id: Optional[str] = None ) -> dict: - assert (order_id is not None) ^ (orig_client_order_id is not None), \ + assert (order_id is not None) ^ (orig_client_order_id is not None), ( "Either order_id or orig_client_order_id should be set" + ) params: Dict[str, Any] = {"symbol": symbol} - base.set_optional_params(params, ( - ("orderId", order_id), - ("origClientOrderId", orig_client_order_id), - )) + base.set_optional_params( + params, + ( + ("orderId", order_id), + ("origClientOrderId", orig_client_order_id), + ), + ) return await self._client.make_request("DELETE", "/api/v3/order", qs_params=params, send_sig=True) async def get_trades(self, symbol: str, order_id: Optional[int] = None) -> List[dict]: @@ -92,10 +108,18 @@ async def get_trades(self, symbol: str, order_id: Optional[int] = None) -> List[ return await self._client.make_request("GET", "/api/v3/myTrades", qs_params=params, send_sig=True) async def create_oco( - self, symbol: str, side: str, quantity: Decimal, price: Decimal, stop_price: Decimal, - stop_limit_price: Optional[Decimal] = None, stop_limit_time_in_force: Optional[str] = None, - list_client_order_id: Optional[str] = None, limit_client_order_id: Optional[str] = None, - stop_client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + symbol: str, + side: str, + quantity: Decimal, + price: Decimal, + stop_price: Decimal, + stop_limit_price: Optional[Decimal] = None, + stop_limit_time_in_force: Optional[str] = None, + list_client_order_id: Optional[str] = None, + limit_client_order_id: Optional[str] = None, + stop_client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> dict: params: Dict[str, Any] = { "symbol": symbol, @@ -104,42 +128,51 @@ async def create_oco( "price": str(price), "stopPrice": str(stop_price), } - base.set_optional_params(params, ( - ("listClientOrderId", list_client_order_id), - ("stopLimitPrice", stop_limit_price), - ("stopLimitTimeInForce", stop_limit_time_in_force), - ("limitClientOrderId", limit_client_order_id), - ("stopClientOrderId", stop_client_order_id), - )) + base.set_optional_params( + params, + ( + ("listClientOrderId", list_client_order_id), + ("stopLimitPrice", stop_limit_price), + ("stopLimitTimeInForce", stop_limit_time_in_force), + ("limitClientOrderId", limit_client_order_id), + ("stopClientOrderId", stop_client_order_id), + ), + ) params.update(kwargs) return await self._client.make_request("POST", "/api/v3/order/oco", data=params, send_sig=True) async def cancel_oco_order( - self, symbol: str, order_list_id: Optional[int] = None, client_order_list_id: Optional[str] = None + self, symbol: str, order_list_id: Optional[int] = None, client_order_list_id: Optional[str] = None ) -> dict: - assert (order_list_id is not None) ^ (client_order_list_id is not None), \ + assert (order_list_id is not None) ^ (client_order_list_id is not None), ( "Either order_list_id or client_order_list_id should be set" + ) - params: Dict[str, Any] = { - "symbol": symbol - } - base.set_optional_params(params, ( - ("orderListId", order_list_id), - ("origClientOrderId", client_order_list_id), - )) + params: Dict[str, Any] = {"symbol": symbol} + base.set_optional_params( + params, + ( + ("orderListId", order_list_id), + ("origClientOrderId", client_order_list_id), + ), + ) return await self._client.make_request("DELETE", "/api/v3/orderList", data=params, send_sig=True) async def query_oco_order( - self, order_list_id: Optional[int] = None, client_order_list_id: Optional[str] = None + self, order_list_id: Optional[int] = None, client_order_list_id: Optional[str] = None ) -> dict: - assert (order_list_id is not None) ^ (client_order_list_id is not None), \ + assert (order_list_id is not None) ^ (client_order_list_id is not None), ( "Either order_list_id or client_order_list_id should be set" + ) params: Dict[str, Any] = {} - base.set_optional_params(params, ( - ("orderListId", order_list_id), - ("origClientOrderId", client_order_list_id), - )) + base.set_optional_params( + params, + ( + ("orderListId", order_list_id), + ("origClientOrderId", client_order_list_id), + ), + ) return await self._client.make_request("GET", "/api/v3/orderList", qs_params=params, send_sig=True) async def create_listen_key(self) -> dict: diff --git a/basana/external/binance/config.py b/basana/external/binance/config.py index efe9731..cc8eeb6 100644 --- a/basana/external/binance/config.py +++ b/basana/external/binance/config.py @@ -38,6 +38,6 @@ "heartbeat": 15 * 60, }, }, - } + }, } } diff --git a/basana/external/binance/cross_margin.py b/basana/external/binance/cross_margin.py index 5403aae..c656895 100644 --- a/basana/external/binance/cross_margin.py +++ b/basana/external/binance/cross_margin.py @@ -61,6 +61,7 @@ async def keep_alive(self, api_client: client.APIClient): class Account(margin.Account): """Cross margin account.""" + def __init__(self, cli: margin_client.CrossMarginAccount, ws_mgr: websocket_mgr.WebsocketManager): self._cli = cli self._ws_mgr = ws_mgr @@ -104,9 +105,7 @@ def subscribe_to_user_data_events(self, event_handler: UserDataEventHandler): """ self._ws_mgr.subscribe_to_user_data_events( - CrossMarginUserDataChannel(), - lambda ws_cli: user_data.WebSocketEventSource(ws_cli), - event_handler + CrossMarginUserDataChannel(), lambda ws_cli: user_data.WebSocketEventSource(ws_cli), event_handler ) def subscribe_to_order_events(self, event_handler: OrderEventHandler): @@ -119,7 +118,5 @@ def subscribe_to_order_events(self, event_handler: OrderEventHandler): """ self._ws_mgr.subscribe_to_order_events( - CrossMarginUserDataChannel(), - lambda ws_cli: user_data.WebSocketEventSource(ws_cli), - event_handler + CrossMarginUserDataChannel(), lambda ws_cli: user_data.WebSocketEventSource(ws_cli), event_handler ) diff --git a/basana/external/binance/csv/bars.py b/basana/external/binance/csv/bars.py index 181dd08..6a39e63 100644 --- a/basana/external/binance/csv/bars.py +++ b/basana/external/binance/csv/bars.py @@ -23,16 +23,19 @@ period_to_timedelta = { - period_str: datetime.timedelta(seconds=period_secs) - for period_str, period_secs in period_to_step.items() + period_str: datetime.timedelta(seconds=period_secs) for period_str, period_secs in period_to_step.items() } class BarSource(csv.EventSource): def __init__( - self, pair: pair.Pair, csv_path: str, period: str, - sort: bool = False, tzinfo: datetime.tzinfo = datetime.timezone.utc, - dict_reader_kwargs: dict = {} + self, + pair: pair.Pair, + csv_path: str, + period: str, + sort: bool = False, + tzinfo: datetime.tzinfo = datetime.timezone.utc, + dict_reader_kwargs: dict = {}, ): # The datetime in the files are the beginning of the period but we need to generate the event at the period's # end. diff --git a/basana/external/binance/exchange.py b/basana/external/binance/exchange.py index 6019a73..e335702 100644 --- a/basana/external/binance/exchange.py +++ b/basana/external/binance/exchange.py @@ -20,8 +20,7 @@ import aiohttp -from . import client, helpers, order_book, order_book_diff, trades, spot, cross_margin, isolated_margin, \ - websocket_mgr +from . import client, helpers, order_book, order_book_diff, trades, spot, cross_margin, isolated_margin, websocket_mgr from basana.core import bar, dispatcher, enums, token_bucket from basana.core.pair import Pair, PairInfo @@ -71,9 +70,13 @@ class Exchange: """ def __init__( - self, dispatcher: dispatcher.EventDispatcher, api_key: Optional[str] = None, - api_secret: Optional[str] = None, session: Optional[aiohttp.ClientSession] = None, - tb: Optional[token_bucket.TokenBucketLimiter] = None, config_overrides: dict = {} + self, + dispatcher: dispatcher.EventDispatcher, + api_key: Optional[str] = None, + api_secret: Optional[str] = None, + session: Optional[aiohttp.ClientSession] = None, + tb: Optional[token_bucket.TokenBucketLimiter] = None, + config_overrides: dict = {}, ): self._dispatcher = dispatcher self._cli = client.APIClient( @@ -89,8 +92,12 @@ def __init__( ) def subscribe_to_bar_events( - self, pair: Pair, bar_duration: Union[int, str], event_handler: BarEventHandler, - skip_first_bar: bool = True, flush_delay: float = 1 + self, + pair: Pair, + bar_duration: Union[int, str], + event_handler: BarEventHandler, + skip_first_bar: bool = True, + flush_delay: float = 1, ): """ Registers an async callable that will be called when a new bar is available. @@ -148,7 +155,7 @@ def subscribe_to_bar_events( self._ws_mgr.subscribe_to_bar_events(pair, interval, event_handler) def subscribe_to_order_book_events( - self, pair: Pair, event_handler: PartialOrderBookEventHandler, depth: int = 10, interval: int = 1000 + self, pair: Pair, event_handler: PartialOrderBookEventHandler, depth: int = 10, interval: int = 1000 ): """ Registers an async callable that will be called with the top bids/asks of the order book. @@ -165,7 +172,7 @@ def subscribe_to_order_book_events( self._ws_mgr.subscribe_to_order_book_events(pair, event_handler, depth=depth, interval=interval) def subscribe_to_order_book_diff_events( - self, pair: Pair, event_handler: OrderBookDiffEventHandler, interval: int = 1000 + self, pair: Pair, event_handler: OrderBookDiffEventHandler, interval: int = 1000 ): """ Registers an async callable that will be called with depth updates. @@ -230,8 +237,7 @@ async def get_order_book(self, pair: Pair, limit: Optional[int] = None) -> order :param limit: The maximum number of levels to return. """ return order_book.PartialOrderBook( - pair, - await self._cli.get_order_book(helpers.pair_to_symbol(pair), limit=limit) + pair, await self._cli.get_order_book(helpers.pair_to_symbol(pair), limit=limit) ) @property @@ -263,7 +269,7 @@ async def _get_pair_info(self, symbol: str) -> PairInfoEx: ret = PairInfoEx( base_precision=get_precision_from_step_size(lot_size["stepSize"]), quote_precision=get_precision_from_step_size(price_filter["tickSize"]), - permissions=symbol_info.get("permissions") + permissions=symbol_info.get("permissions"), ) self._symbol_info[symbol] = ret self._symbol_to_pair[symbol_info["symbol"]] = Pair(symbol_info["baseAsset"], symbol_info["quoteAsset"]) diff --git a/basana/external/binance/isolated_margin.py b/basana/external/binance/isolated_margin.py index 5882dee..4e93665 100644 --- a/basana/external/binance/isolated_margin.py +++ b/basana/external/binance/isolated_margin.py @@ -92,6 +92,7 @@ async def keep_alive(self, api_client: client.APIClient): class Account(margin.Account): """Isolated margin account.""" + def __init__(self, cli: margin_client.IsolatedMarginAccount, ws_mgr: websocket_mgr.WebsocketManager): self._cli = cli self._ws_mgr = ws_mgr @@ -143,9 +144,7 @@ def subscribe_to_user_data_events(self, pair: Pair, event_handler: UserDataEvent """ self._ws_mgr.subscribe_to_user_data_events( - IsolatedMarginUserDataChannel(pair), - lambda ws_cli: user_data.WebSocketEventSource(ws_cli), - event_handler + IsolatedMarginUserDataChannel(pair), lambda ws_cli: user_data.WebSocketEventSource(ws_cli), event_handler ) def subscribe_to_order_events(self, pair: Pair, event_handler: OrderEventHandler): @@ -159,7 +158,5 @@ def subscribe_to_order_events(self, pair: Pair, event_handler: OrderEventHandler """ self._ws_mgr.subscribe_to_order_events( - IsolatedMarginUserDataChannel(pair), - lambda ws_cli: user_data.WebSocketEventSource(ws_cli), - event_handler + IsolatedMarginUserDataChannel(pair), lambda ws_cli: user_data.WebSocketEventSource(ws_cli), event_handler ) diff --git a/basana/external/binance/klines.py b/basana/external/binance/klines.py index dde1f59..3dfdc87 100644 --- a/basana/external/binance/klines.py +++ b/basana/external/binance/klines.py @@ -30,8 +30,14 @@ def __init__(self, pair: Pair, json: dict): start = helpers.timestamp_to_datetime(int(json["t"])) close = helpers.timestamp_to_datetime(int(json["T"])) super().__init__( - start, pair, Decimal(json["o"]), Decimal(json["h"]), - Decimal(json["l"]), Decimal(json["c"]), Decimal(json["v"]), close - start + start, + pair, + Decimal(json["o"]), + Decimal(json["h"]), + Decimal(json["l"]), + Decimal(json["c"]), + Decimal(json["v"]), + close - start, ) self.pair: Pair = pair self.json: dict = json @@ -49,10 +55,12 @@ async def push_from_message(self, message: dict): # Wait for the last update to the kline. if kline["x"] is False: return - self.push(bar.BarEvent( - helpers.timestamp_to_datetime(int(kline_event["E"])), # Event time - Bar(self._pair, kline) - )) + self.push( + bar.BarEvent( + helpers.timestamp_to_datetime(int(kline_event["E"])), # Event time + Bar(self._pair, kline), + ) + ) def get_channel(pair: Pair, interval: str) -> str: diff --git a/basana/external/binance/margin.py b/basana/external/binance/margin.py index cad12fd..6b60648 100644 --- a/basana/external/binance/margin.py +++ b/basana/external/binance/margin.py @@ -86,9 +86,14 @@ async def create_order(self, order_request: margin_requests.ExchangeOrder) -> Cr return CreatedOrder(created_order) async def create_market_order( - self, operation: OrderOperation, pair: Pair, amount: Optional[Decimal] = None, - quote_amount: Optional[Decimal] = None, client_order_id: Optional[str] = None, - side_effect_type: str = "NO_SIDE_EFFECT", **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Optional[Decimal] = None, + quote_amount: Optional[Decimal] = None, + client_order_id: Optional[str] = None, + side_effect_type: str = "NO_SIDE_EFFECT", + **kwargs: Dict[str, Any], ) -> CreatedOrder: """Creates a market order. @@ -108,15 +113,28 @@ async def create_market_order( * Either amount or quote_amount should be set, but not both. """ - return await self.create_order(margin_requests.MarketOrder( - operation, pair, amount=amount, quote_amount=quote_amount, client_order_id=client_order_id, - side_effect_type=side_effect_type, **kwargs - )) + return await self.create_order( + margin_requests.MarketOrder( + operation, + pair, + amount=amount, + quote_amount=quote_amount, + client_order_id=client_order_id, + side_effect_type=side_effect_type, + **kwargs, + ) + ) async def create_limit_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, limit_price: Decimal, - side_effect_type: str = "NO_SIDE_EFFECT", time_in_force: str = "GTC", client_order_id: Optional[str] = None, - **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + limit_price: Decimal, + side_effect_type: str = "NO_SIDE_EFFECT", + time_in_force: str = "GTC", + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> CreatedOrder: """Creates a limit order. @@ -133,15 +151,30 @@ async def create_limit_order( :param kwargs: Additional keyword arguments that will be forwarded. """ - return await self.create_order(margin_requests.LimitOrder( - operation, pair, amount, limit_price, side_effect_type=side_effect_type, time_in_force=time_in_force, - client_order_id=client_order_id, **kwargs - )) + return await self.create_order( + margin_requests.LimitOrder( + operation, + pair, + amount, + limit_price, + side_effect_type=side_effect_type, + time_in_force=time_in_force, + client_order_id=client_order_id, + **kwargs, + ) + ) async def create_stop_limit_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, stop_price: Decimal, limit_price: Decimal, - side_effect_type: str = "NO_SIDE_EFFECT", time_in_force: str = "GTC", - client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + stop_price: Decimal, + limit_price: Decimal, + side_effect_type: str = "NO_SIDE_EFFECT", + time_in_force: str = "GTC", + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> CreatedOrder: """Creates a stop limit order. @@ -159,14 +192,26 @@ async def create_stop_limit_order( :param kwargs: Additional keyword arguments that will be forwarded. """ - return await self.create_order(margin_requests.StopLimitOrder( - operation, pair, amount, stop_price, limit_price, side_effect_type=side_effect_type, - time_in_force=time_in_force, client_order_id=client_order_id, **kwargs - )) + return await self.create_order( + margin_requests.StopLimitOrder( + operation, + pair, + amount, + stop_price, + limit_price, + side_effect_type=side_effect_type, + time_in_force=time_in_force, + client_order_id=client_order_id, + **kwargs, + ) + ) async def get_order_info( - self, pair: Pair, order_id: Optional[str] = None, client_order_id: Optional[str] = None, - include_trades: bool = True + self, + pair: Pair, + order_id: Optional[str] = None, + client_order_id: Optional[str] = None, + include_trades: bool = True, ) -> OrderInfo: """Returns information about an order. @@ -181,14 +226,15 @@ async def get_order_info( """ order_book_symbol = helpers.pair_to_symbol(pair) order_info = await self.client.query_order( - order_book_symbol, order_id=None if order_id is None else int(order_id), - orig_client_order_id=client_order_id + order_book_symbol, + order_id=None if order_id is None else int(order_id), + orig_client_order_id=client_order_id, ) trades = [] if include_trades: trades = [ - Trade(trade) for trade in - await self.client.get_trades(order_book_symbol, order_id=order_info["orderId"]) + Trade(trade) + for trade in await self.client.get_trades(order_book_symbol, order_id=order_info["orderId"]) ] return OrderInfo(order_info, trades) @@ -201,12 +247,13 @@ async def get_open_orders(self, pair: Optional[Pair] = None) -> List[OpenOrder]: order_book_symbol = None if pair: order_book_symbol = helpers.pair_to_symbol(pair) - return [ - OpenOrder(open_order) for open_order in await self.client.get_open_orders(order_book_symbol) - ] + return [OpenOrder(open_order) for open_order in await self.client.get_open_orders(order_book_symbol)] async def cancel_order( - self, pair: Pair, order_id: Optional[str] = None, client_order_id: Optional[str] = None, + self, + pair: Pair, + order_id: Optional[str] = None, + client_order_id: Optional[str] = None, ) -> CanceledOrder: """Cancels an order. @@ -221,17 +268,26 @@ async def cancel_order( * Either order_id or client_order_id should be set, but not both. """ canceled_order = await self.client.cancel_order( - helpers.pair_to_symbol(pair), order_id=None if order_id is None else int(order_id), - orig_client_order_id=client_order_id + helpers.pair_to_symbol(pair), + order_id=None if order_id is None else int(order_id), + orig_client_order_id=client_order_id, ) return CanceledOrder(canceled_order) async def create_oco_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, limit_price: Decimal, stop_price: Decimal, - stop_limit_price: Optional[Decimal] = None, side_effect_type: str = "NO_SIDE_EFFECT", - stop_limit_time_in_force: str = "GTC", list_client_order_id: Optional[str] = None, - limit_client_order_id: Optional[str] = None, stop_client_order_id: Optional[str] = None, - **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + limit_price: Decimal, + stop_price: Decimal, + stop_limit_price: Optional[Decimal] = None, + side_effect_type: str = "NO_SIDE_EFFECT", + stop_limit_time_in_force: str = "GTC", + list_client_order_id: Optional[str] = None, + limit_client_order_id: Optional[str] = None, + stop_client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> CreatedOCOOrder: """Creates an OCO order. @@ -252,16 +308,26 @@ async def create_oco_order( :param kwargs: Additional keyword arguments that will be forwarded. """ order_req = margin_requests.OCOOrder( - operation, pair, amount, limit_price, stop_price, stop_limit_price=stop_limit_price, - side_effect_type=side_effect_type, stop_limit_time_in_force=stop_limit_time_in_force, - list_client_order_id=list_client_order_id, limit_client_order_id=limit_client_order_id, - stop_client_order_id=stop_client_order_id, **kwargs + operation, + pair, + amount, + limit_price, + stop_price, + stop_limit_price=stop_limit_price, + side_effect_type=side_effect_type, + stop_limit_time_in_force=stop_limit_time_in_force, + list_client_order_id=list_client_order_id, + limit_client_order_id=limit_client_order_id, + stop_client_order_id=stop_client_order_id, + **kwargs, ) created_order = await order_req.create_order(self.client) return CreatedOCOOrder(created_order) async def get_oco_order_info( - self, order_list_id: Optional[str] = None, client_order_list_id: Optional[str] = None, + self, + order_list_id: Optional[str] = None, + client_order_list_id: Optional[str] = None, ) -> OCOOrderInfo: """Returns information about an OCO order. @@ -274,12 +340,15 @@ async def get_oco_order_info( """ order_info = await self.client.query_oco_order( order_list_id=None if order_list_id is None else int(order_list_id), - client_order_list_id=client_order_list_id + client_order_list_id=client_order_list_id, ) return OCOOrderInfo(order_info) async def cancel_oco_order( - self, pair: Pair, order_list_id: Optional[str] = None, client_order_list_id: Optional[str] = None, + self, + pair: Pair, + order_list_id: Optional[str] = None, + client_order_list_id: Optional[str] = None, ) -> CanceledOCOOrder: """Cancels an OCO order. @@ -296,6 +365,6 @@ async def cancel_oco_order( canceled_order = await self.client.cancel_oco_order( helpers.pair_to_symbol(pair), order_list_id=None if order_list_id is None else int(order_list_id), - client_order_list_id=client_order_list_id + client_order_list_id=client_order_list_id, ) return CanceledOCOOrder(canceled_order) diff --git a/basana/external/binance/margin_requests.py b/basana/external/binance/margin_requests.py index b06e8cf..0a01582 100644 --- a/basana/external/binance/margin_requests.py +++ b/basana/external/binance/margin_requests.py @@ -25,8 +25,12 @@ class ExchangeOrder(metaclass=abc.ABCMeta): def __init__( - self, operation: OrderOperation, pair: Pair, amount: Optional[Decimal], - client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Optional[Decimal], + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): self._operation = operation self._pair = pair @@ -41,9 +45,14 @@ async def create_order(self, margin_account_cli) -> dict: class MarketOrder(ExchangeOrder): def __init__( - self, operation: OrderOperation, pair: Pair, amount: Optional[Decimal] = None, - quote_amount: Optional[Decimal] = None, client_order_id: Optional[str] = None, - side_effect_type: str = "NO_SIDE_EFFECT", **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Optional[Decimal] = None, + quote_amount: Optional[Decimal] = None, + client_order_id: Optional[str] = None, + side_effect_type: str = "NO_SIDE_EFFECT", + **kwargs: Dict[str, Any], ): assert (amount is not None) ^ (quote_amount is not None), "Either amount or quote_amount should be set" super().__init__(operation, pair, amount, client_order_id=client_order_id, **kwargs) @@ -52,17 +61,28 @@ def __init__( async def create_order(self, margin_account_cli) -> dict: return await margin_account_cli.create_order( - helpers.pair_to_symbol(self._pair), helpers.order_operation_to_side(self._operation), "MARKET", - quantity=self._amount, quote_order_qty=self._quote_amount, new_client_order_id=self._client_order_id, - side_effect_type=self._side_effect_type, **self._kwargs + helpers.pair_to_symbol(self._pair), + helpers.order_operation_to_side(self._operation), + "MARKET", + quantity=self._amount, + quote_order_qty=self._quote_amount, + new_client_order_id=self._client_order_id, + side_effect_type=self._side_effect_type, + **self._kwargs, ) class LimitOrder(ExchangeOrder): def __init__( - self, operation: OrderOperation, pair: Pair, amount: Decimal, limit_price: Decimal, - side_effect_type: str = "NO_SIDE_EFFECT", time_in_force: str = "GTC", client_order_id: Optional[str] = None, - **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + limit_price: Decimal, + side_effect_type: str = "NO_SIDE_EFFECT", + time_in_force: str = "GTC", + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): super().__init__(operation, pair, amount, client_order_id=client_order_id, **kwargs) self._limit_price = limit_price @@ -71,17 +91,30 @@ def __init__( async def create_order(self, margin_account_cli) -> dict: return await margin_account_cli.create_order( - helpers.pair_to_symbol(self._pair), helpers.order_operation_to_side(self._operation), "LIMIT", - quantity=self._amount, price=self._limit_price, time_in_force=self._time_in_force, - new_client_order_id=self._client_order_id, side_effect_type=self._side_effect_type, **self._kwargs + helpers.pair_to_symbol(self._pair), + helpers.order_operation_to_side(self._operation), + "LIMIT", + quantity=self._amount, + price=self._limit_price, + time_in_force=self._time_in_force, + new_client_order_id=self._client_order_id, + side_effect_type=self._side_effect_type, + **self._kwargs, ) class StopLimitOrder(ExchangeOrder): def __init__( - self, operation: OrderOperation, pair: Pair, amount: Decimal, stop_price: Decimal, limit_price: Decimal, - side_effect_type: str = "NO_SIDE_EFFECT", time_in_force: str = "GTC", - client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + stop_price: Decimal, + limit_price: Decimal, + side_effect_type: str = "NO_SIDE_EFFECT", + time_in_force: str = "GTC", + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): super().__init__(operation, pair, amount, client_order_id=client_order_id, **kwargs) self._stop_price = stop_price @@ -91,20 +124,34 @@ def __init__( async def create_order(self, margin_account_cli) -> dict: return await margin_account_cli.create_order( - helpers.pair_to_symbol(self._pair), helpers.order_operation_to_side(self._operation), - "STOP_LOSS_LIMIT", quantity=self._amount, stop_price=self._stop_price, price=self._limit_price, - time_in_force=self._time_in_force, new_client_order_id=self._client_order_id, - side_effect_type=self._side_effect_type, **self._kwargs + helpers.pair_to_symbol(self._pair), + helpers.order_operation_to_side(self._operation), + "STOP_LOSS_LIMIT", + quantity=self._amount, + stop_price=self._stop_price, + price=self._limit_price, + time_in_force=self._time_in_force, + new_client_order_id=self._client_order_id, + side_effect_type=self._side_effect_type, + **self._kwargs, ) class OCOOrder: def __init__( - self, operation: OrderOperation, pair: Pair, amount: Decimal, limit_price: Decimal, stop_price: Decimal, - stop_limit_price: Optional[Decimal] = None, side_effect_type: str = "NO_SIDE_EFFECT", - stop_limit_time_in_force: str = "GTC", list_client_order_id: Optional[str] = None, - limit_client_order_id: Optional[str] = None, stop_client_order_id: Optional[str] = None, - **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + limit_price: Decimal, + stop_price: Decimal, + stop_limit_price: Optional[Decimal] = None, + side_effect_type: str = "NO_SIDE_EFFECT", + stop_limit_time_in_force: str = "GTC", + list_client_order_id: Optional[str] = None, + limit_client_order_id: Optional[str] = None, + stop_client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): self._operation = operation self._pair = pair @@ -121,9 +168,16 @@ def __init__( async def create_order(self, margin_account_cli) -> dict: return await margin_account_cli.create_oco( - helpers.pair_to_symbol(self._pair), helpers.order_operation_to_side(self._operation), - self._amount, self._limit_price, self._stop_price, stop_limit_price=self._stop_limit_price, - stop_limit_time_in_force=self._stop_limit_time_in_force, list_client_order_id=self._list_client_order_id, - side_effect_type=self._side_effect_type, limit_client_order_id=self._limit_client_order_id, - stop_client_order_id=self._stop_client_order_id, **self._kwargs + helpers.pair_to_symbol(self._pair), + helpers.order_operation_to_side(self._operation), + self._amount, + self._limit_price, + self._stop_price, + stop_limit_price=self._stop_limit_price, + stop_limit_time_in_force=self._stop_limit_time_in_force, + list_client_order_id=self._list_client_order_id, + side_effect_type=self._side_effect_type, + limit_client_order_id=self._limit_client_order_id, + stop_client_order_id=self._stop_client_order_id, + **self._kwargs, ) diff --git a/basana/external/binance/order_book.py b/basana/external/binance/order_book.py index 029d932..4ebb8ee 100644 --- a/basana/external/binance/order_book.py +++ b/basana/external/binance/order_book.py @@ -42,6 +42,7 @@ class Entry: class PartialOrderBook: """An order book.""" + def __init__(self, pair: Pair, json: dict): #: The trading pair. self.pair: Pair = pair @@ -56,16 +57,12 @@ def last_update_id(self) -> int: @property def bids(self) -> List[Entry]: """Returns the top bid entries.""" - return [ - Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["bids"] - ] + return [Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["bids"]] @property def asks(self) -> List[Entry]: """Returns the top ask entries.""" - return [ - Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["asks"] - ] + return [Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["asks"]] class PartialOrderBookEvent(event.Event): @@ -75,6 +72,7 @@ class PartialOrderBookEvent(event.Event): :param when: The datetime when the event occurred. It must have timezone information set. :param order_book: The updated order book. """ + def __init__(self, when: datetime.datetime, order_book: PartialOrderBook): super().__init__(when) #: The order book. @@ -83,9 +81,13 @@ def __init__(self, when: datetime.datetime, order_book: PartialOrderBook): class PollOrderBook(event.FifoQueueEventSource, event.Producer): def __init__( - self, pair: Pair, interval: float, limit: Optional[int] = None, - session: Optional[aiohttp.ClientSession] = None, tb: Optional[token_bucket.TokenBucketLimiter] = None, - config_overrides: dict = {} + self, + pair: Pair, + interval: float, + limit: Optional[int] = None, + session: Optional[aiohttp.ClientSession] = None, + tb: Optional[token_bucket.TokenBucketLimiter] = None, + config_overrides: dict = {}, ): assert interval > 0, "Invalid interval" @@ -97,10 +99,12 @@ def __init__( async def _fetch_and_push(self, order_book_symbol: str): order_book_json = await self._client.get_order_book(order_book_symbol, limit=self._limit) - self.push(PartialOrderBookEvent( - dt.utc_now(monotonic=True), # The order book doesn't include a timestamp. - PartialOrderBook(self.pair, order_book_json) - )) + self.push( + PartialOrderBookEvent( + dt.utc_now(monotonic=True), # The order book doesn't include a timestamp. + PartialOrderBook(self.pair, order_book_json), + ) + ) async def on_error(self, error: Any): logger.error(logs.StructuredMessage("Error polling order book", channel=self.pair, error=error)) @@ -123,10 +127,12 @@ def __init__(self, pair: Pair, producer: event.Producer): async def push_from_message(self, message: dict): event = message["data"] - self.push(PartialOrderBookEvent( - dt.utc_now(monotonic=True), # The event doesn't include a timestamp. - PartialOrderBook(self._pair, event) - )) + self.push( + PartialOrderBookEvent( + dt.utc_now(monotonic=True), # The event doesn't include a timestamp. + PartialOrderBook(self._pair, event), + ) + ) def get_channel(pair: Pair, depth: int, interval: int) -> str: diff --git a/basana/external/binance/order_book_diff.py b/basana/external/binance/order_book_diff.py index 67a0fc9..e6d98e0 100644 --- a/basana/external/binance/order_book_diff.py +++ b/basana/external/binance/order_book_diff.py @@ -38,6 +38,7 @@ class OrderBookDiff: An order book diff as described in https://developers.binance.com/docs/binance-spot-api-docs/web-socket-streams#diff-depth-stream """ + def __init__(self, pair: Pair, json: dict): assert json["e"] == "depthUpdate", "Invalid event type: {}".format(json["e"]) @@ -59,16 +60,12 @@ def final_update_id(self) -> int: @property def bids(self) -> List[Entry]: """Bids to be updated.""" - return [ - Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["b"] - ] + return [Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["b"]] @property def asks(self) -> List[Entry]: """Asks to be updated.""" - return [ - Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["a"] - ] + return [Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["a"]] class OrderBookDiffEvent(event.Event): @@ -78,6 +75,7 @@ class OrderBookDiffEvent(event.Event): :param when: The datetime when the event occurred. It must have timezone information set. :param order_book_diff: The order book diff. """ + def __init__(self, when: datetime.datetime, order_book_diff: OrderBookDiff): super().__init__(when) #: The order book diff. @@ -93,10 +91,12 @@ def __init__(self, pair: Pair, producer: event.Producer): async def push_from_message(self, message: dict): event = message["data"] diff = OrderBookDiff(self._pair, event) - self.push(OrderBookDiffEvent( - helpers.timestamp_to_datetime(int(event["E"])), # Event time - diff - )) + self.push( + OrderBookDiffEvent( + helpers.timestamp_to_datetime(int(event["E"])), # Event time + diff, + ) + ) def get_channel(pair: Pair, interval: int) -> str: diff --git a/basana/external/binance/spot.py b/basana/external/binance/spot.py index 6bf0a12..bca7efb 100644 --- a/basana/external/binance/spot.py +++ b/basana/external/binance/spot.py @@ -114,6 +114,7 @@ async def keep_alive(self, api_client: client.APIClient): class Account: """Spot account.""" + def __init__(self, cli: spot_client.SpotAccount, ws_mgr: websocket_mgr.WebsocketManager): self._cli = cli self._ws_mgr = ws_mgr @@ -128,8 +129,13 @@ async def create_order(self, order_request: spot_requests.ExchangeOrder) -> Crea return CreatedOrder(created_order) async def create_market_order( - self, operation: OrderOperation, pair: Pair, amount: Optional[Decimal] = None, - quote_amount: Optional[Decimal] = None, client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Optional[Decimal] = None, + quote_amount: Optional[Decimal] = None, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> CreatedOrder: """Creates a market order. @@ -148,13 +154,21 @@ async def create_market_order( * Either amount or quote_amount should be set, but not both. """ - return await self.create_order(spot_requests.MarketOrder( - operation, pair, amount=amount, quote_amount=quote_amount, client_order_id=client_order_id, **kwargs - )) + return await self.create_order( + spot_requests.MarketOrder( + operation, pair, amount=amount, quote_amount=quote_amount, client_order_id=client_order_id, **kwargs + ) + ) async def create_limit_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, limit_price: Decimal, - time_in_force: str = "GTC", client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + limit_price: Decimal, + time_in_force: str = "GTC", + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> CreatedOrder: """Creates a limit order. @@ -170,14 +184,28 @@ async def create_limit_order( :param kwargs: Additional keyword arguments that will be forwarded. """ - return await self.create_order(spot_requests.LimitOrder( - operation, pair, amount, limit_price, time_in_force=time_in_force, client_order_id=client_order_id, - **kwargs - )) + return await self.create_order( + spot_requests.LimitOrder( + operation, + pair, + amount, + limit_price, + time_in_force=time_in_force, + client_order_id=client_order_id, + **kwargs, + ) + ) async def create_stop_limit_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, stop_price: Decimal, limit_price: Decimal, - time_in_force: str = "GTC", client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + stop_price: Decimal, + limit_price: Decimal, + time_in_force: str = "GTC", + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> CreatedOrder: """Creates a stop limit order. @@ -194,14 +222,25 @@ async def create_stop_limit_order( :param kwargs: Additional keyword arguments that will be forwarded. """ - return await self.create_order(spot_requests.StopLimitOrder( - operation, pair, amount, stop_price, limit_price, time_in_force=time_in_force, - client_order_id=client_order_id, **kwargs - )) + return await self.create_order( + spot_requests.StopLimitOrder( + operation, + pair, + amount, + stop_price, + limit_price, + time_in_force=time_in_force, + client_order_id=client_order_id, + **kwargs, + ) + ) async def get_order_info( - self, pair: Pair, order_id: Optional[str] = None, client_order_id: Optional[str] = None, - include_trades: bool = True + self, + pair: Pair, + order_id: Optional[str] = None, + client_order_id: Optional[str] = None, + include_trades: bool = True, ) -> OrderInfo: """Returns information about an order. @@ -217,8 +256,9 @@ async def get_order_info( """ order_book_symbol = helpers.pair_to_symbol(pair) order_info = await self._cli.query_order( - order_book_symbol, order_id=None if order_id is None else int(order_id), - orig_client_order_id=client_order_id + order_book_symbol, + order_id=None if order_id is None else int(order_id), + orig_client_order_id=client_order_id, ) trades = [] if include_trades: @@ -237,12 +277,13 @@ async def get_open_orders(self, pair: Optional[Pair] = None) -> List[OpenOrder]: order_book_symbol = None if pair: order_book_symbol = helpers.pair_to_symbol(pair) - return [ - OpenOrder(open_order) for open_order in await self._cli.get_open_orders(order_book_symbol) - ] + return [OpenOrder(open_order) for open_order in await self._cli.get_open_orders(order_book_symbol)] async def cancel_order( - self, pair: Pair, order_id: Optional[str] = None, client_order_id: Optional[str] = None, + self, + pair: Pair, + order_id: Optional[str] = None, + client_order_id: Optional[str] = None, ) -> CanceledOrder: """Cancels an order. @@ -257,16 +298,25 @@ async def cancel_order( * Either order_id or client_order_id should be set, but not both. """ canceled_order = await self._cli.cancel_order( - helpers.pair_to_symbol(pair), order_id=None if order_id is None else int(order_id), - orig_client_order_id=client_order_id + helpers.pair_to_symbol(pair), + order_id=None if order_id is None else int(order_id), + orig_client_order_id=client_order_id, ) return CanceledOrder(canceled_order) async def create_oco_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, limit_price: Decimal, stop_price: Decimal, - stop_limit_price: Optional[Decimal] = None, stop_limit_time_in_force: str = "GTC", - list_client_order_id: Optional[str] = None, limit_client_order_id: Optional[str] = None, - stop_client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + limit_price: Decimal, + stop_price: Decimal, + stop_limit_price: Optional[Decimal] = None, + stop_limit_time_in_force: str = "GTC", + list_client_order_id: Optional[str] = None, + limit_client_order_id: Optional[str] = None, + stop_client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> CreatedOCOOrder: """Creates an OCO order. @@ -286,16 +336,25 @@ async def create_oco_order( :param kwargs: Additional keyword arguments that will be forwarded. """ order_req = spot_requests.OCOOrder( - operation, pair, amount, limit_price, stop_price, stop_limit_price=stop_limit_price, - stop_limit_time_in_force=stop_limit_time_in_force, list_client_order_id=list_client_order_id, - limit_client_order_id=limit_client_order_id, stop_client_order_id=stop_client_order_id, - **kwargs + operation, + pair, + amount, + limit_price, + stop_price, + stop_limit_price=stop_limit_price, + stop_limit_time_in_force=stop_limit_time_in_force, + list_client_order_id=list_client_order_id, + limit_client_order_id=limit_client_order_id, + stop_client_order_id=stop_client_order_id, + **kwargs, ) created_order = await order_req.create_order(self._cli) return CreatedOCOOrder(created_order) async def get_oco_order_info( - self, order_list_id: Optional[str] = None, client_order_list_id: Optional[str] = None, + self, + order_list_id: Optional[str] = None, + client_order_list_id: Optional[str] = None, ) -> OCOOrderInfo: """Returns information about an OCO order. @@ -308,12 +367,15 @@ async def get_oco_order_info( """ order_info = await self._cli.query_oco_order( order_list_id=None if order_list_id is None else int(order_list_id), - client_order_list_id=client_order_list_id + client_order_list_id=client_order_list_id, ) return OCOOrderInfo(order_info) async def cancel_oco_order( - self, pair: Pair, order_list_id: Optional[str] = None, client_order_list_id: Optional[str] = None, + self, + pair: Pair, + order_list_id: Optional[str] = None, + client_order_list_id: Optional[str] = None, ) -> CanceledOCOOrder: """Cancels an OCO order. @@ -331,7 +393,7 @@ async def cancel_oco_order( canceled_order = await self._cli.cancel_oco_order( helpers.pair_to_symbol(pair), order_list_id=None if order_list_id is None else int(order_list_id), - client_order_list_id=client_order_list_id + client_order_list_id=client_order_list_id, ) return CanceledOCOOrder(canceled_order) @@ -345,9 +407,7 @@ def subscribe_to_user_data_events(self, event_handler: UserDataEventHandler): """ self._ws_mgr.subscribe_to_user_data_events( - SpotUserDataChannel(), - lambda ws_cli: user_data.WebSocketEventSource(ws_cli), - event_handler + SpotUserDataChannel(), lambda ws_cli: user_data.WebSocketEventSource(ws_cli), event_handler ) def subscribe_to_order_events(self, event_handler: OrderEventHandler): @@ -360,7 +420,5 @@ def subscribe_to_order_events(self, event_handler: OrderEventHandler): """ self._ws_mgr.subscribe_to_order_events( - SpotUserDataChannel(), - lambda ws_cli: user_data.WebSocketEventSource(ws_cli), - event_handler + SpotUserDataChannel(), lambda ws_cli: user_data.WebSocketEventSource(ws_cli), event_handler ) diff --git a/basana/external/binance/spot_requests.py b/basana/external/binance/spot_requests.py index 1398bc9..1337532 100644 --- a/basana/external/binance/spot_requests.py +++ b/basana/external/binance/spot_requests.py @@ -25,8 +25,12 @@ class ExchangeOrder(metaclass=abc.ABCMeta): def __init__( - self, operation: OrderOperation, pair: Pair, amount: Optional[Decimal], - client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Optional[Decimal], + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): self._operation = operation self._pair = pair @@ -41,8 +45,13 @@ async def create_order(self, spot_account_cli) -> dict: class MarketOrder(ExchangeOrder): def __init__( - self, operation: OrderOperation, pair: Pair, amount: Optional[Decimal] = None, - quote_amount: Optional[Decimal] = None, client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Optional[Decimal] = None, + quote_amount: Optional[Decimal] = None, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): assert (amount is not None) ^ (quote_amount is not None), "Either amount or quote_amount should be set" super().__init__(operation, pair, amount, client_order_id=client_order_id, **kwargs) @@ -50,16 +59,26 @@ def __init__( async def create_order(self, spot_account_cli) -> dict: return await spot_account_cli.create_order( - helpers.pair_to_symbol(self._pair), helpers.order_operation_to_side(self._operation), "MARKET", - quantity=self._amount, quote_order_qty=self._quote_amount, new_client_order_id=self._client_order_id, - **self._kwargs + helpers.pair_to_symbol(self._pair), + helpers.order_operation_to_side(self._operation), + "MARKET", + quantity=self._amount, + quote_order_qty=self._quote_amount, + new_client_order_id=self._client_order_id, + **self._kwargs, ) class LimitOrder(ExchangeOrder): def __init__( - self, operation: OrderOperation, pair: Pair, amount: Decimal, limit_price: Decimal, - time_in_force: str = "GTC", client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + limit_price: Decimal, + time_in_force: str = "GTC", + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): super().__init__(operation, pair, amount, client_order_id=client_order_id, **kwargs) self._limit_price = limit_price @@ -67,16 +86,28 @@ def __init__( async def create_order(self, spot_account_cli) -> dict: return await spot_account_cli.create_order( - helpers.pair_to_symbol(self._pair), helpers.order_operation_to_side(self._operation), "LIMIT", - quantity=self._amount, price=self._limit_price, time_in_force=self._time_in_force, - new_client_order_id=self._client_order_id, **self._kwargs + helpers.pair_to_symbol(self._pair), + helpers.order_operation_to_side(self._operation), + "LIMIT", + quantity=self._amount, + price=self._limit_price, + time_in_force=self._time_in_force, + new_client_order_id=self._client_order_id, + **self._kwargs, ) class StopLimitOrder(ExchangeOrder): def __init__( - self, operation: OrderOperation, pair: Pair, amount: Decimal, stop_price: Decimal, limit_price: Decimal, - time_in_force: str = "GTC", client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + stop_price: Decimal, + limit_price: Decimal, + time_in_force: str = "GTC", + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): super().__init__(operation, pair, amount, client_order_id=client_order_id, **kwargs) self._stop_price = stop_price @@ -85,18 +116,32 @@ def __init__( async def create_order(self, spot_account_cli) -> dict: return await spot_account_cli.create_order( - helpers.pair_to_symbol(self._pair), helpers.order_operation_to_side(self._operation), - "STOP_LOSS_LIMIT", quantity=self._amount, stop_price=self._stop_price, price=self._limit_price, - time_in_force=self._time_in_force, new_client_order_id=self._client_order_id, **self._kwargs + helpers.pair_to_symbol(self._pair), + helpers.order_operation_to_side(self._operation), + "STOP_LOSS_LIMIT", + quantity=self._amount, + stop_price=self._stop_price, + price=self._limit_price, + time_in_force=self._time_in_force, + new_client_order_id=self._client_order_id, + **self._kwargs, ) class OCOOrder: def __init__( - self, operation: OrderOperation, pair: Pair, amount: Decimal, limit_price: Decimal, stop_price: Decimal, - stop_limit_price: Optional[Decimal] = None, stop_limit_time_in_force: str = "GTC", - list_client_order_id: Optional[str] = None, limit_client_order_id: Optional[str] = None, - stop_client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + limit_price: Decimal, + stop_price: Decimal, + stop_limit_price: Optional[Decimal] = None, + stop_limit_time_in_force: str = "GTC", + list_client_order_id: Optional[str] = None, + limit_client_order_id: Optional[str] = None, + stop_client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): self._operation = operation self._pair = pair @@ -112,9 +157,15 @@ def __init__( async def create_order(self, spot_account_cli) -> dict: return await spot_account_cli.create_oco( - helpers.pair_to_symbol(self._pair), helpers.order_operation_to_side(self._operation), - self._amount, self._limit_price, self._stop_price, stop_limit_price=self._stop_limit_price, - stop_limit_time_in_force=self._stop_limit_time_in_force, list_client_order_id=self._list_client_order_id, - limit_client_order_id=self._limit_client_order_id, stop_client_order_id=self._stop_client_order_id, - **self._kwargs + helpers.pair_to_symbol(self._pair), + helpers.order_operation_to_side(self._operation), + self._amount, + self._limit_price, + self._stop_price, + stop_limit_price=self._stop_limit_price, + stop_limit_time_in_force=self._stop_limit_time_in_force, + list_client_order_id=self._list_client_order_id, + limit_client_order_id=self._limit_client_order_id, + stop_client_order_id=self._stop_client_order_id, + **self._kwargs, ) diff --git a/basana/external/binance/tools/download_bars.py b/basana/external/binance/tools/download_bars.py index 499c4ee..6fc8f12 100644 --- a/basana/external/binance/tools/download_bars.py +++ b/basana/external/binance/tools/download_bars.py @@ -47,9 +47,9 @@ def parse_date(date: str): - return datetime.datetime.combine( - datetime.date.fromisoformat(date), datetime.time() - ).replace(tzinfo=datetime.timezone.utc) + return datetime.datetime.combine(datetime.date.fromisoformat(date), datetime.time()).replace( + tzinfo=datetime.timezone.utc + ) class Candlestick: @@ -79,24 +79,29 @@ def write_candlestick(self, candlestick: Candlestick): self._header_written = True dt_col = datetime.datetime.fromtimestamp(candlestick.open_timestamp / 1000, tz=datetime.timezone.utc) - print(",".join([ - str(dt_col.replace(tzinfo=None)), - candlestick.open, candlestick.high, candlestick.low, candlestick.close, candlestick.volume - ]), file=self._output_file) + print( + ",".join( + [ + str(dt_col.replace(tzinfo=None)), + candlestick.open, + candlestick.high, + candlestick.low, + candlestick.close, + candlestick.volume, + ] + ), + file=self._output_file, + ) async def main(params: Optional[List[str]] = None, config_overrides: dict = {}): parser = argparse.ArgumentParser() parser.add_argument("-c", "--currency-pair", help="The currency pair.", required=True) - parser.add_argument( - "-p", "--period", help="The period for the bars.", choices=period_to_step.keys(), required=True - ) + parser.add_argument("-p", "--period", help="The period for the bars.", choices=period_to_step.keys(), required=True) parser.add_argument( "-s", "--start", help="The starting date YYYY-MM-DD format. Included in the range.", required=True ) - parser.add_argument( - "-e", "--end", help="The ending date YYYY-MM-DD format. Included in the range.", required=True - ) + parser.add_argument("-e", "--end", help="The ending date YYYY-MM-DD format. Included in the range.", required=True) parser.add_argument("-o", "--output", help="The output file.", required=False, default=None) args = parser.parse_args(args=params) diff --git a/basana/external/binance/trades.py b/basana/external/binance/trades.py index fd74059..b0ea321 100644 --- a/basana/external/binance/trades.py +++ b/basana/external/binance/trades.py @@ -88,10 +88,12 @@ def __init__(self, pair: Pair, producer: event.Producer): async def push_from_message(self, message: dict): event = message["data"] - self.push(TradeEvent( - helpers.timestamp_to_datetime(int(event["E"])), # Event time - Trade(self._pair, event) - )) + self.push( + TradeEvent( + helpers.timestamp_to_datetime(int(event["E"])), # Event time + Trade(self._pair, event), + ) + ) def get_channel(pair: Pair) -> str: diff --git a/basana/external/binance/user_data.py b/basana/external/binance/user_data.py index 3d31eb4..ceb0f61 100644 --- a/basana/external/binance/user_data.py +++ b/basana/external/binance/user_data.py @@ -173,7 +173,7 @@ async def push_from_message(self, message: dict): }.get(json["e"], Event) event = event_cls( helpers.timestamp_to_datetime(int(json["E"])), # Event time - json + json, ) self.push(event) diff --git a/basana/external/binance/websocket_mgr.py b/basana/external/binance/websocket_mgr.py index d1ab625..eb97cca 100644 --- a/basana/external/binance/websocket_mgr.py +++ b/basana/external/binance/websocket_mgr.py @@ -25,8 +25,11 @@ class WebsocketManager: def __init__( - self, dispatcher: dispatcher.EventDispatcher, api_client: client.APIClient, - session: Optional[aiohttp.ClientSession] = None, config_overrides: dict = {} + self, + dispatcher: dispatcher.EventDispatcher, + api_client: client.APIClient, + session: Optional[aiohttp.ClientSession] = None, + config_overrides: dict = {}, ): self._dispatcher = dispatcher self._cli = api_client @@ -38,46 +41,45 @@ def subscribe_to_bar_events(self, pair: Pair, interval: str, event_handler: bar. self._subscribe_to_ws_channel_events( binance_ws.PublicChannel(klines.get_channel(pair, interval)), lambda ws_cli: klines.WebSocketEventSource(pair, ws_cli), - cast(dispatcher.EventHandler, event_handler) + cast(dispatcher.EventHandler, event_handler), ) def subscribe_to_order_book_events( - self, pair: Pair, event_handler: order_book.PartialOrderBookEventHandler, depth: int = 10, - interval: int = 1000 + self, pair: Pair, event_handler: order_book.PartialOrderBookEventHandler, depth: int = 10, interval: int = 1000 ): self._subscribe_to_ws_channel_events( binance_ws.PublicChannel(order_book.get_channel(pair, depth, interval)), lambda ws_cli: order_book.WebSocketEventSource(pair, ws_cli), - cast(dispatcher.EventHandler, event_handler) + cast(dispatcher.EventHandler, event_handler), ) def subscribe_to_order_book_diff_events( - self, pair: Pair, event_handler: order_book_diff.OrderBookDiffEventHandler, interval: int = 1000 + self, pair: Pair, event_handler: order_book_diff.OrderBookDiffEventHandler, interval: int = 1000 ): self._subscribe_to_ws_channel_events( binance_ws.PublicChannel(order_book_diff.get_channel(pair, interval)), lambda ws_cli: order_book_diff.WebSocketEventSource(pair, ws_cli), - cast(dispatcher.EventHandler, event_handler) + cast(dispatcher.EventHandler, event_handler), ) def subscribe_to_trade_events(self, pair: Pair, event_handler: trades.TradeEventHandler): self._subscribe_to_ws_channel_events( binance_ws.PublicChannel(trades.get_channel(pair)), lambda ws_cli: trades.WebSocketEventSource(pair, ws_cli), - cast(dispatcher.EventHandler, event_handler) + cast(dispatcher.EventHandler, event_handler), ) def subscribe_to_user_data_events( - self, channel: binance_ws.Channel, + self, + channel: binance_ws.Channel, event_src_factory: Callable[[core_ws.WebSocketClient], core_ws.ChannelEventSource], event_handler: user_data.UserDataEventHandler, ): - self._subscribe_to_ws_channel_events( - channel, event_src_factory, cast(dispatcher.EventHandler, event_handler) - ) + self._subscribe_to_ws_channel_events(channel, event_src_factory, cast(dispatcher.EventHandler, event_handler)) def subscribe_to_order_events( - self, channel: binance_ws.Channel, + self, + channel: binance_ws.Channel, event_src_factory: Callable[[core_ws.WebSocketClient], core_ws.ChannelEventSource], event_handler: user_data.OrderEventHandler, ): @@ -90,9 +92,10 @@ async def forward_if_order_event(event: user_data.Event): ) def _subscribe_to_ws_channel_events( - self, channel: binance_ws.Channel, - event_src_factory: Callable[[core_ws.WebSocketClient], core_ws.ChannelEventSource], - event_handler: dispatcher.EventHandler + self, + channel: binance_ws.Channel, + event_src_factory: Callable[[core_ws.WebSocketClient], core_ws.ChannelEventSource], + event_handler: dispatcher.EventHandler, ): # Get/create the event source for the channel. ws_cli = self._get_ws_client() diff --git a/basana/external/binance/websockets.py b/basana/external/binance/websockets.py index 47ca755..0cfa824 100644 --- a/basana/external/binance/websockets.py +++ b/basana/external/binance/websockets.py @@ -69,16 +69,20 @@ def stream(self) -> str: class WebSocketClient(core_ws.WebSocketClient): def __init__( - self, dispatcher: dispatcher.EventDispatcher, api_client: client.APIClient, - session: Optional[aiohttp.ClientSession] = None, config_overrides: dict = {} + self, + dispatcher: dispatcher.EventDispatcher, + api_client: client.APIClient, + session: Optional[aiohttp.ClientSession] = None, + config_overrides: dict = {}, ): url = urljoin( - get_config_value(config.DEFAULTS, "api.websockets.base_url", overrides=config_overrides), - "/stream" + get_config_value(config.DEFAULTS, "api.websockets.base_url", overrides=config_overrides), "/stream" ) super().__init__( - url, session=session, config_overrides=config_overrides, - heartbeat=get_config_value(config.DEFAULTS, "api.websockets.heartbeat", overrides=config_overrides) + url, + session=session, + config_overrides=config_overrides, + heartbeat=get_config_value(config.DEFAULTS, "api.websockets.heartbeat", overrides=config_overrides), ) self._dispatcher = dispatcher self._cli = api_client @@ -100,19 +104,13 @@ async def subscribe_to_channels(self, channel_aliases: List[str], ws_cli: aiohtt # Give a chance for dynamic channels to resolve the stream name. channels: List[Channel] = [self._alias_to_channel[alias] for alias in channel_aliases] - await asyncio.gather(*[ - channel.resolve_stream_name(self._cli) for channel in channels - ]) - self._stream_to_channel.update({ - channel.stream: channel for channel in channels - }) + await asyncio.gather(*[channel.resolve_stream_name(self._cli) for channel in channels]) + self._stream_to_channel.update({channel.stream: channel for channel in channels}) msg_id = self._get_next_msg_id() - await ws_cli.send_str(json.dumps({ - "id": msg_id, - "method": "SUBSCRIBE", - "params": [channel.stream for channel in channels] - })) + await ws_cli.send_str( + json.dumps({"id": msg_id, "method": "SUBSCRIBE", "params": [channel.stream for channel in channels]}) + ) # Schedule keep alives. for channel in channels: @@ -130,9 +128,9 @@ async def handle_message(self, message: dict) -> bool: assert channel, f"{stream} could not be mapped to a channel instance" # Resubscribe to the channel if the listen key expired. if message.get("data", {}).get("e") == "listenKeyExpired": - logger.debug(logs.StructuredMessage( - "License key expired. Scheduling re-subscription", alias=channel.alias - )) + logger.debug( + logs.StructuredMessage("License key expired. Scheduling re-subscription", alias=channel.alias) + ) self.schedule_resubscription([channel.alias]) # Get the event source for the channel alias. if event_source := self.get_channel_event_source(channel.alias): @@ -161,6 +159,7 @@ async def scheduler_job(): await channel.keep_alive(self._cli) finally: self._schedule_keep_alive(channel) + return scheduler_job def _schedule_keep_alive(self, channel: Channel): diff --git a/basana/external/bitstamp/client.py b/basana/external/bitstamp/client.py index b8ea2cf..b078030 100644 --- a/basana/external/bitstamp/client.py +++ b/basana/external/bitstamp/client.py @@ -51,12 +51,16 @@ def raise_for_error(resp: aiohttp.ClientResponse, json_response): class APIClient: def __init__( - self, api_key: Optional[str] = None, api_secret: Optional[str] = None, - session: Optional[aiohttp.ClientSession] = None, tb: Optional[token_bucket.TokenBucketLimiter] = None, - config_overrides: dict = {} + self, + api_key: Optional[str] = None, + api_secret: Optional[str] = None, + session: Optional[aiohttp.ClientSession] = None, + tb: Optional[token_bucket.TokenBucketLimiter] = None, + config_overrides: dict = {}, ): - assert not ((api_key is None) ^ (api_secret is None)), \ + assert not ((api_key is None) ^ (api_secret is None)), ( "Both api_key and api_secret should be set, or none of them" + ) self._api_key = api_key self._api_secret = api_secret @@ -65,8 +69,7 @@ def __init__( self._config_overrides = config_overrides async def _make_request( - self, method: str, path: str, authenticate: bool, qs_params: Dict[str, Any] = {}, - data: Dict[str, Any] = {} + self, method: str, path: str, authenticate: bool, qs_params: Dict[str, Any] = {}, data: Dict[str, Any] = {} ) -> Any: # Throttling enabled ? go sleep if necessary. if self._tb and (sleep_time := self._tb.consume()): @@ -92,8 +95,7 @@ async def _make_request( hostname, self._api_key, self._api_secret, nonce, method, path, qs_params=qs_params, data=data ) async with session_method( - url, headers=headers, skip_auto_headers=skip_auto_headers, params=qs_params, data=data, - timeout=timeout + url, headers=headers, skip_auto_headers=skip_auto_headers, params=qs_params, data=data, timeout=timeout ) as resp: json_response = None if (ct := resp.headers.get("Content-Type")) and ct.lower().find("application/json") == 0: @@ -114,8 +116,13 @@ async def get_ticker(self, currency_pair: str) -> dict: return await self._make_request("GET", f"/api/v2/ticker/{currency_pair}/", False) async def get_ohlc_data( - self, currency_pair: str, step: int, limit: int, start: Optional[int] = None, end: Optional[int] = None, - exclude_current_candle: bool = False + self, + currency_pair: str, + step: int, + limit: int, + start: Optional[int] = None, + end: Optional[int] = None, + exclude_current_candle: bool = False, ) -> dict: assert start is None or end is None, "both start and end should not be set" @@ -123,10 +130,13 @@ async def get_ohlc_data( "step": step, "limit": limit, } - set_optional_params(params, ( - ("start", start), - ("end", end), - )) + set_optional_params( + params, + ( + ("start", start), + ("end", end), + ), + ) if exclude_current_candle: params["exclude_current_candle"] = "true" return await self._make_request("GET", f"/api/v2/ohlc/{currency_pair}/", False, qs_params=params) @@ -145,8 +155,10 @@ async def get_open_orders(self, currency_pair: Optional[str] = None) -> List[dic return await self._make_request("POST", url, True) async def get_order_status( - self, id: Optional[Union[str, int]] = None, client_order_id: Optional[str] = None, - omit_transactions: Optional[bool] = None + self, + id: Optional[Union[str, int]] = None, + client_order_id: Optional[str] = None, + omit_transactions: Optional[bool] = None, ) -> dict: assert (id is not None) ^ (client_order_id is not None), "Either id or client_order_id should be set" @@ -159,8 +171,12 @@ async def cancel_order(self, id: Union[str, int]) -> dict: return await self._make_request("POST", "/api/v2/cancel_order/", True, data={"id": id}) async def create_market_order( - self, action: str, currency_pair: str, amount: Decimal, client_order_id: Optional[str] = None, - **kwargs: Dict[str, Any] + self, + action: str, + currency_pair: str, + amount: Decimal, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> dict: assert action in ["buy", "sell"], "Invalid action" @@ -173,8 +189,13 @@ async def create_market_order( return await self._make_request("POST", f"/api/v2/{action}/market/{currency_pair}/", True, data=data) async def create_limit_order( - self, action: str, currency_pair: str, amount: Decimal, price: Decimal, - client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + action: str, + currency_pair: str, + amount: Decimal, + price: Decimal, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> dict: assert action in ["buy", "sell"], "Invalid action" @@ -188,8 +209,13 @@ async def create_limit_order( return await self._make_request("POST", f"/api/v2/{action}/{currency_pair}/", True, data=data) async def create_instant_order( - self, action: str, currency_pair: str, amount: Decimal, amount_in_counter: bool = False, - client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + action: str, + currency_pair: str, + amount: Decimal, + amount_in_counter: bool = False, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> dict: assert action in ["buy", "sell"], "Invalid action" assert amount_in_counter is False or action == "sell", "amount_in_counter only supported for sell orders" @@ -197,10 +223,13 @@ async def create_instant_order( data: Dict[str, Any] = { "amount": str(amount), } - set_optional_params(data, ( - ("client_order_id", client_order_id), - ("amount_in_counter", amount_in_counter), - )) + set_optional_params( + data, + ( + ("client_order_id", client_order_id), + ("amount_in_counter", amount_in_counter), + ), + ) data.update(kwargs) return await self._make_request("POST", f"/api/v2/{action}/instant/{currency_pair}/", True, data=data) diff --git a/basana/external/bitstamp/config.py b/basana/external/bitstamp/config.py index 3fd1ee1..160a60d 100644 --- a/basana/external/bitstamp/config.py +++ b/basana/external/bitstamp/config.py @@ -23,6 +23,6 @@ "websockets": { "base_url": "wss://ws.bitstamp.net/", "heartbeat": 30, - } + }, } } diff --git a/basana/external/bitstamp/csv/bars.py b/basana/external/bitstamp/csv/bars.py index fe542ed..1b8b2bb 100644 --- a/basana/external/bitstamp/csv/bars.py +++ b/basana/external/bitstamp/csv/bars.py @@ -25,8 +25,7 @@ period_to_timedelta = { - period_str: datetime.timedelta(seconds=period_secs) - for period_str, period_secs in period_to_step.items() + period_str: datetime.timedelta(seconds=period_secs) for period_str, period_secs in period_to_step.items() } @@ -40,9 +39,13 @@ class BarPeriod(enum.Enum): class BarSource(csv.EventSource): def __init__( - self, pair: pair.Pair, csv_path: str, period: Union[str, BarPeriod], - sort: bool = False, tzinfo: datetime.tzinfo = datetime.timezone.utc, - dict_reader_kwargs: dict = {} + self, + pair: pair.Pair, + csv_path: str, + period: Union[str, BarPeriod], + sort: bool = False, + tzinfo: datetime.tzinfo = datetime.timezone.utc, + dict_reader_kwargs: dict = {}, ): # TODO: Deprecate at v2. if isinstance(period, BarPeriod): diff --git a/basana/external/bitstamp/exchange.py b/basana/external/bitstamp/exchange.py index b851dec..4efa648 100644 --- a/basana/external/bitstamp/exchange.py +++ b/basana/external/bitstamp/exchange.py @@ -193,9 +193,7 @@ def is_open(self) -> bool: "Expired": False, "Canceled": False, }.get(self._order_status.status) - assert is_open is not None, "No mapping for {} order status".format( - self._order_status.status - ) + assert is_open is not None, "No mapping for {} order status".format(self._order_status.status) return is_open @property @@ -323,10 +321,15 @@ class Exchange: :param tb: An optional token bucket limiter, in case you want to throttle requests. :param config_overrides: An optional dictionary for overriding config settings. """ + def __init__( - self, dispatcher: dispatcher.EventDispatcher, api_key: Optional[str] = None, - api_secret: Optional[str] = None, session: Optional[aiohttp.ClientSession] = None, - tb: Optional[token_bucket.TokenBucketLimiter] = None, config_overrides: dict = {} + self, + dispatcher: dispatcher.EventDispatcher, + api_key: Optional[str] = None, + api_secret: Optional[str] = None, + session: Optional[aiohttp.ClientSession] = None, + tb: Optional[token_bucket.TokenBucketLimiter] = None, + config_overrides: dict = {}, ): self._dispatcher = dispatcher self._api_key = api_key @@ -379,8 +382,12 @@ async def create_order(self, order_request: requests.ExchangeOrder) -> CreatedOr return CreatedOrder(created_order) async def create_market_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, client_order_id: Optional[str] = None, - **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> CreatedOrder: """Creates a market order. @@ -392,13 +399,18 @@ async def create_market_order( :param client_order_id: A client order id. :param kwargs: Additional keyword arguments that will be forwarded. """ - return await self.create_order(requests.MarketOrder( - operation, pair, amount, client_order_id=client_order_id, **kwargs - )) + return await self.create_order( + requests.MarketOrder(operation, pair, amount, client_order_id=client_order_id, **kwargs) + ) async def create_limit_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, limit_price: Decimal, - client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + limit_price: Decimal, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> CreatedOrder: """Creates a limit order. @@ -411,13 +423,18 @@ async def create_limit_order( :param client_order_id: A client order id. :param kwargs: Additional keyword arguments that will be forwarded. """ - return await self.create_order(requests.LimitOrder( - operation, pair, amount, limit_price, client_order_id=client_order_id, **kwargs - )) + return await self.create_order( + requests.LimitOrder(operation, pair, amount, limit_price, client_order_id=client_order_id, **kwargs) + ) async def create_instant_order( - self, operation: OrderOperation, pair: Pair, amount: Decimal, amount_in_counter: bool = False, - client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + amount_in_counter: bool = False, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ) -> CreatedOrder: """Creates an instant order. @@ -431,9 +448,11 @@ async def create_instant_order( :param client_order_id: A client order id. :param kwargs: Additional keyword arguments that will be forwarded. """ - return await self.create_order(requests.InstantOrder( - operation, pair, amount, amount_in_counter=amount_in_counter, client_order_id=client_order_id, **kwargs - )) + return await self.create_order( + requests.InstantOrder( + operation, pair, amount, amount_in_counter=amount_in_counter, client_order_id=client_order_id, **kwargs + ) + ) async def cancel_order(self, order_id: Union[str, int]) -> CanceledOrder: """Cancels an order. @@ -446,7 +465,10 @@ async def cancel_order(self, order_id: Union[str, int]) -> CanceledOrder: return CanceledOrder(canceled_order) async def get_order_info( - self, pair: Pair, order_id: Optional[Union[str, int]] = None, client_order_id: Optional[str] = None, + self, + pair: Pair, + order_id: Optional[Union[str, int]] = None, + client_order_id: Optional[str] = None, ) -> Optional[OrderInfo]: """Returns information about an order. @@ -466,8 +488,10 @@ async def get_order_info( return ret async def get_order_status( - self, order_id: Optional[Union[str, int]] = None, client_order_id: Optional[str] = None, - omit_transactions: Optional[bool] = None + self, + order_id: Optional[Union[str, int]] = None, + client_order_id: Optional[str] = None, + omit_transactions: Optional[bool] = None, ) -> Optional[OrderStatus]: if order_id is not None: order_id = int(order_id) @@ -499,8 +523,12 @@ async def get_balances(self) -> Dict[str, Balance]: return {balance["currency"].upper(): Balance(balance) for balance in balances} def subscribe_to_bar_events( - self, pair: Pair, bar_duration: int, event_handler: BarEventHandler, skip_first_bar: bool = True, - flush_delay: float = 1 + self, + pair: Pair, + bar_duration: int, + event_handler: BarEventHandler, + skip_first_bar: bool = True, + flush_delay: float = 1, ): """Registers an async callable that will be called when a new bar is available. @@ -538,8 +566,10 @@ def subscribe_to_order_book_events(self, pair: Pair, event_handler: OrderBookEve """ channel = order_book.get_channel(pair) self._subscribe_to_ws_channel_events( - channel, True, lambda ws_cli: order_book.WebSocketEventSource(pair, ws_cli), - cast(dispatcher.EventHandler, event_handler) + channel, + True, + lambda ws_cli: order_book.WebSocketEventSource(pair, ws_cli), + cast(dispatcher.EventHandler, event_handler), ) def subscribe_to_public_order_events(self, pair: Pair, event_handler: OrderEventHandler): @@ -550,8 +580,10 @@ def subscribe_to_public_order_events(self, pair: Pair, event_handler: OrderEvent """ channel = orders.get_public_channel(pair) self._subscribe_to_ws_channel_events( - channel, True, lambda ws_cli: orders.WebSocketEventSource(pair, ws_cli), - cast(dispatcher.EventHandler, event_handler) + channel, + True, + lambda ws_cli: orders.WebSocketEventSource(pair, ws_cli), + cast(dispatcher.EventHandler, event_handler), ) def subscribe_to_private_order_events(self, pair: Pair, event_handler: OrderEventHandler): @@ -562,8 +594,10 @@ def subscribe_to_private_order_events(self, pair: Pair, event_handler: OrderEven """ channel = orders.get_private_channel(pair) self._subscribe_to_ws_channel_events( - channel, False, lambda ws_cli: orders.WebSocketEventSource(pair, ws_cli), - cast(dispatcher.EventHandler, event_handler) + channel, + False, + lambda ws_cli: orders.WebSocketEventSource(pair, ws_cli), + cast(dispatcher.EventHandler, event_handler), ) def subscribe_to_public_trade_events(self, pair: Pair, event_handler: TradeEventHandler): @@ -574,8 +608,10 @@ def subscribe_to_public_trade_events(self, pair: Pair, event_handler: TradeEvent """ channel = trades.get_public_channel(pair) self._subscribe_to_ws_channel_events( - channel, True, lambda ws_cli: trades.WebSocketEventSource(pair, ws_cli), - cast(dispatcher.EventHandler, event_handler) + channel, + True, + lambda ws_cli: trades.WebSocketEventSource(pair, ws_cli), + cast(dispatcher.EventHandler, event_handler), ) def subscribe_to_private_trade_events(self, pair: Pair, event_handler: TradeEventHandler): @@ -586,14 +622,18 @@ def subscribe_to_private_trade_events(self, pair: Pair, event_handler: TradeEven """ channel = trades.get_private_channel(pair) self._subscribe_to_ws_channel_events( - channel, False, lambda ws_cli: trades.WebSocketEventSource(pair, ws_cli), - cast(dispatcher.EventHandler, event_handler) + channel, + False, + lambda ws_cli: trades.WebSocketEventSource(pair, ws_cli), + cast(dispatcher.EventHandler, event_handler), ) def _subscribe_to_ws_channel_events( - self, channel: str, is_public: bool, - event_src_factory: Callable[[core_ws.WebSocketClient], core_ws.ChannelEventSource], - event_handler: dispatcher.EventHandler + self, + channel: str, + is_public: bool, + event_src_factory: Callable[[core_ws.WebSocketClient], core_ws.ChannelEventSource], + event_handler: dispatcher.EventHandler, ): # Get/create the event source for the channel. ws_cli = self._get_pub_ws_client() if is_public else self._get_priv_ws_client() diff --git a/basana/external/bitstamp/helpers.py b/basana/external/bitstamp/helpers.py index d898832..7e364fa 100644 --- a/basana/external/bitstamp/helpers.py +++ b/basana/external/bitstamp/helpers.py @@ -30,8 +30,14 @@ def generate_nonce() -> str: def get_auth_headers( - hostname: str, api_key: str, api_secret: str, nonce: str, method: str, path: str, - qs_params: Dict[str, str] = {}, data: Dict[str, str] = {} + hostname: str, + api_key: str, + api_secret: str, + nonce: str, + method: str, + path: str, + qs_params: Dict[str, str] = {}, + data: Dict[str, str] = {}, ) -> Dict[str, str]: assert path[0] == "/", "Leading slash is missing from path" diff --git a/basana/external/bitstamp/order_book.py b/basana/external/bitstamp/order_book.py index ab3bcb8..7aa69e1 100644 --- a/basana/external/bitstamp/order_book.py +++ b/basana/external/bitstamp/order_book.py @@ -55,16 +55,12 @@ def datetime(self) -> datetime.datetime: @property def bids(self) -> List[Entry]: """Returns the top bid entries.""" - return [ - Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["bids"] - ] + return [Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["bids"]] @property def asks(self) -> List[Entry]: """Returns the top ask entries.""" - return [ - Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["asks"] - ] + return [Entry(price=Decimal(entry[0]), volume=Decimal(entry[1])) for entry in self.json["asks"]] class OrderBookEvent(event.Event): @@ -82,9 +78,13 @@ def __init__(self, when: datetime.datetime, order_book: OrderBook): class PollOrderBook(event.FifoQueueEventSource, event.Producer): def __init__( - self, pair: Pair, interval: float, group: Optional[int] = None, - session: Optional[aiohttp.ClientSession] = None, tb: Optional[token_bucket.TokenBucketLimiter] = None, - config_overrides: dict = {} + self, + pair: Pair, + interval: float, + group: Optional[int] = None, + session: Optional[aiohttp.ClientSession] = None, + tb: Optional[token_bucket.TokenBucketLimiter] = None, + config_overrides: dict = {}, ): assert interval > 0, "Invalid interval" diff --git a/basana/external/bitstamp/requests.py b/basana/external/bitstamp/requests.py index 2035e87..352c1a7 100644 --- a/basana/external/bitstamp/requests.py +++ b/basana/external/bitstamp/requests.py @@ -25,8 +25,12 @@ class ExchangeOrder(metaclass=abc.ABCMeta): def __init__( - self, operation: OrderOperation, pair: Pair, amount: Decimal, client_order_id: Optional[str] = None, - **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): self._operation = operation self._pair = pair @@ -67,15 +71,22 @@ class MarketOrder(ExchangeOrder): """ def __init__( - self, operation: OrderOperation, pair: Pair, amount: Decimal, client_order_id: Optional[str] = None, - **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): super().__init__(operation, pair, amount, client_order_id=client_order_id, **kwargs) async def create_order(self, cli: client.APIClient) -> dict: return await cli.create_market_order( - self._get_action(), helpers.pair_to_currency_pair(self.pair), self.amount, - client_order_id=self._client_order_id, **self._kwargs + self._get_action(), + helpers.pair_to_currency_pair(self.pair), + self.amount, + client_order_id=self._client_order_id, + **self._kwargs, ) @@ -88,8 +99,13 @@ class LimitOrder(ExchangeOrder): """ def __init__( - self, operation: OrderOperation, pair: Pair, amount: Decimal, limit_price: Decimal, - client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + limit_price: Decimal, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): super().__init__(operation, pair, amount, client_order_id=client_order_id, **kwargs) self._limit_price = limit_price @@ -100,21 +116,34 @@ def limit_price(self) -> Decimal: async def create_order(self, cli: client.APIClient) -> dict: return await cli.create_limit_order( - self._get_action(), helpers.pair_to_currency_pair(self.pair), self.amount, self.limit_price, - client_order_id=self._client_order_id, **self._kwargs + self._get_action(), + helpers.pair_to_currency_pair(self.pair), + self.amount, + self.limit_price, + client_order_id=self._client_order_id, + **self._kwargs, ) class InstantOrder(ExchangeOrder): def __init__( - self, operation: OrderOperation, pair: Pair, amount: Decimal, amount_in_counter: bool = False, - client_order_id: Optional[str] = None, **kwargs: Dict[str, Any] + self, + operation: OrderOperation, + pair: Pair, + amount: Decimal, + amount_in_counter: bool = False, + client_order_id: Optional[str] = None, + **kwargs: Dict[str, Any], ): super().__init__(operation, pair, amount, client_order_id=client_order_id, **kwargs) self._amount_in_counter = amount_in_counter async def create_order(self, cli: client.APIClient) -> dict: return await cli.create_instant_order( - self._get_action(), helpers.pair_to_currency_pair(self.pair), self.amount, - amount_in_counter=self._amount_in_counter, client_order_id=self._client_order_id, **self._kwargs + self._get_action(), + helpers.pair_to_currency_pair(self.pair), + self.amount, + amount_in_counter=self._amount_in_counter, + client_order_id=self._client_order_id, + **self._kwargs, ) diff --git a/basana/external/bitstamp/tools/download_bars.py b/basana/external/bitstamp/tools/download_bars.py index 0b16608..e98d526 100644 --- a/basana/external/bitstamp/tools/download_bars.py +++ b/basana/external/bitstamp/tools/download_bars.py @@ -56,9 +56,9 @@ def __init__(self, ohlc: dict): def parse_date(date: str): - return datetime.datetime.combine( - datetime.date.fromisoformat(date), datetime.time() - ).replace(tzinfo=datetime.timezone.utc) + return datetime.datetime.combine(datetime.date.fromisoformat(date), datetime.time()).replace( + tzinfo=datetime.timezone.utc + ) def to_bitstamp_currency_pair(currency_pair: str): @@ -77,24 +77,20 @@ def write_ohlc(self, ohlc: OHLC): self._header_written = True dt_col = datetime.datetime.fromtimestamp(ohlc.open_timestamp, tz=datetime.timezone.utc) - print(",".join([ - str(dt_col.replace(tzinfo=None)), - ohlc.open, ohlc.high, ohlc.low, ohlc.close, ohlc.volume - ]), file=self._output_file) + print( + ",".join([str(dt_col.replace(tzinfo=None)), ohlc.open, ohlc.high, ohlc.low, ohlc.close, ohlc.volume]), + file=self._output_file, + ) async def main(params: Optional[List[str]] = None, config_overrides: dict = {}): parser = argparse.ArgumentParser() parser.add_argument("-c", "--currency-pair", help="The currency pair.", required=True) - parser.add_argument( - "-p", "--period", help="The period for the bars.", choices=period_to_step.keys(), required=True - ) + parser.add_argument("-p", "--period", help="The period for the bars.", choices=period_to_step.keys(), required=True) parser.add_argument( "-s", "--start", help="The starting date YYYY-MM-DD format. Included in the range.", required=True ) - parser.add_argument( - "-e", "--end", help="The ending date YYYY-MM-DD format. Included in the range.", required=True - ) + parser.add_argument("-e", "--end", help="The ending date YYYY-MM-DD format. Included in the range.", required=True) parser.add_argument("-o", "--output", help="The output file.", required=False, default=None) args = parser.parse_args(args=params) diff --git a/basana/external/bitstamp/websockets.py b/basana/external/bitstamp/websockets.py index f617e28..1b94ca9 100644 --- a/basana/external/bitstamp/websockets.py +++ b/basana/external/bitstamp/websockets.py @@ -35,8 +35,9 @@ class WebSocketClient(core_ws.WebSocketClient): def __init__(self, session: Optional[aiohttp.ClientSession] = None, config_overrides: dict = {}): super().__init__( get_config_value(config.DEFAULTS, "api.websockets.base_url", overrides=config_overrides), - session=session, config_overrides=config_overrides, - heartbeat=get_config_value(config.DEFAULTS, "api.websockets.heartbeat", overrides=config_overrides) + session=session, + config_overrides=config_overrides, + heartbeat=get_config_value(config.DEFAULTS, "api.websockets.heartbeat", overrides=config_overrides), ) async def handle_message(self, message: dict) -> bool: @@ -51,14 +52,16 @@ async def handle_message(self, message: dict) -> bool: "bts:subscription_succeeded": self._on_bts_subscription_succeeded, "bts:error": self.on_error, "bts:subscription_failed": self.on_error, - }.get(event) + }.get(event), ) if message_handler: coro = message_handler(message) # Is it a channel message ? - elif event in CHANNEL_EVENTS \ - and (channel := message.get("channel")) \ - and (event_source := self.get_channel_event_source(channel)): + elif ( + event in CHANNEL_EVENTS + and (channel := message.get("channel")) + and (event_source := self.get_channel_event_source(channel)) + ): coro = event_source.push_from_message(message) ret = False @@ -81,21 +84,21 @@ def __init__(self, session: Optional[aiohttp.ClientSession] = None, config_overr async def subscribe_to_channels(self, channels: List[str], ws_cli: aiohttp.ClientWebSocketResponse): logger.debug(logs.StructuredMessage("Subscribing", src=self, channels=channels)) - await asyncio.gather(*[ - ws_cli.send_str(json.dumps({ - "event": "bts:subscribe", - "data": { - "channel": channel - } - })) - for channel in channels - ]) + await asyncio.gather( + *[ + ws_cli.send_str(json.dumps({"event": "bts:subscribe", "data": {"channel": channel}})) + for channel in channels + ] + ) class PrivateWebSocketClient(WebSocketClient): def __init__( - self, api_key: str, api_secret: str, session: Optional[aiohttp.ClientSession] = None, - config_overrides: dict = {} + self, + api_key: str, + api_secret: str, + session: Optional[aiohttp.ClientSession] = None, + config_overrides: dict = {}, ): super().__init__(session=session, config_overrides=config_overrides) self._client = client.APIClient(api_key, api_secret, session=session, config_overrides=config_overrides) @@ -105,13 +108,19 @@ async def subscribe_to_channels(self, channels: List[str], ws_cli: aiohttp.Clien logger.debug(logs.StructuredMessage("Authenticating", src=self)) websockets_token = await self._client.get_websocket_auth_token() - await asyncio.gather(*[ - ws_cli.send_str(json.dumps({ - "event": "bts:subscribe", - "data": { - "auth": websockets_token["token"], - "channel": "{}-{}".format(channel, websockets_token["user_id"]) - } - })) - for channel in channels - ]) + await asyncio.gather( + *[ + ws_cli.send_str( + json.dumps( + { + "event": "bts:subscribe", + "data": { + "auth": websockets_token["token"], + "channel": "{}-{}".format(channel, websockets_token["user_id"]), + }, + } + ) + ) + for channel in channels + ] + ) diff --git a/basana/external/common/csv/bars.py b/basana/external/common/csv/bars.py index 71251a6..f25f0f4 100644 --- a/basana/external/common/csv/bars.py +++ b/basana/external/common/csv/bars.py @@ -23,9 +23,7 @@ class RowParser(csv.RowParser): - def __init__( - self, pair: pair.Pair, tzinfo: datetime.tzinfo, timedelta: datetime.timedelta - ): + def __init__(self, pair: pair.Pair, tzinfo: datetime.tzinfo, timedelta: datetime.timedelta): self.pair = pair self.tzinfo = tzinfo self.timedelta = timedelta @@ -46,8 +44,14 @@ def parse_row(self, row_dict: dict) -> Sequence[event.Event]: bar.BarEvent( dt + self.timedelta, bar.Bar( - dt, self.pair, Decimal(row_dict["open"]), Decimal(row_dict["high"]), Decimal(row_dict["low"]), - Decimal(row_dict["close"]), volume, self.timedelta - ) + dt, + self.pair, + Decimal(row_dict["open"]), + Decimal(row_dict["high"]), + Decimal(row_dict["low"]), + Decimal(row_dict["close"]), + volume, + self.timedelta, + ), ) ] diff --git a/basana/external/yahoo/bars.py b/basana/external/yahoo/bars.py index eb7aa10..4afba38 100644 --- a/basana/external/yahoo/bars.py +++ b/basana/external/yahoo/bars.py @@ -34,7 +34,7 @@ def adjust_ohlc( - open: Decimal, high: Decimal, low: Decimal, close: Decimal, adj_close: Decimal + open: Decimal, high: Decimal, low: Decimal, close: Decimal, adj_close: Decimal ) -> Tuple[Decimal, Decimal, Decimal, Decimal]: adj_factor = adj_close / close open *= adj_factor @@ -44,9 +44,9 @@ def adjust_ohlc( return open, high, low, close -def sanitize_ohlc(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> Tuple[ - Decimal, Decimal, Decimal, Decimal -]: +def sanitize_ohlc( + open: Decimal, high: Decimal, low: Decimal, close: Decimal +) -> Tuple[Decimal, Decimal, Decimal, Decimal]: if low > open: low = open if low > close: @@ -60,8 +60,11 @@ def sanitize_ohlc(open: Decimal, high: Decimal, low: Decimal, close: Decimal) -> class RowParser(csv.RowParser): def __init__( - self, pair: pair.Pair, adjust_ohlc: bool = False, tzinfo: datetime.tzinfo = tz.tzlocal(), - timedelta: datetime.timedelta = datetime.timedelta(hours=24) + self, + pair: pair.Pair, + adjust_ohlc: bool = False, + tzinfo: datetime.tzinfo = tz.tzlocal(), + timedelta: datetime.timedelta = datetime.timedelta(hours=24), ): self.pair = pair self.tzinfo = tzinfo @@ -72,7 +75,10 @@ def __init__( def parse_row(self, row_dict: dict) -> Sequence[event.Event]: dt = datetime.datetime.strptime(row_dict["Date"], "%Y-%m-%d").replace(tzinfo=self.tzinfo) open, high, low, close = ( - Decimal(row_dict["Open"]), Decimal(row_dict["High"]), Decimal(row_dict["Low"]), Decimal(row_dict["Close"]) + Decimal(row_dict["Open"]), + Decimal(row_dict["High"]), + Decimal(row_dict["Low"]), + Decimal(row_dict["Close"]), ) if self.sanitize: open, high, low, close = sanitize_ohlc(open, high, low, close) @@ -82,17 +88,21 @@ def parse_row(self, row_dict: dict) -> Sequence[event.Event]: return [ bar.BarEvent( dt + self.timedelta, - bar.Bar(dt, self.pair, open, high, low, close, Decimal(row_dict["Volume"]), self.timedelta) + bar.Bar(dt, self.pair, open, high, low, close, Decimal(row_dict["Volume"]), self.timedelta), ) ] class CSVBarSource(csv.EventSource): def __init__( - self, pair: pair.Pair, csv_path: str, adjust_ohlc: bool = False, sort: bool = True, - tzinfo: datetime.tzinfo = tz.tzlocal(), - timedelta: datetime.timedelta = datetime.timedelta(hours=24), - dict_reader_kwargs: dict = {} + self, + pair: pair.Pair, + csv_path: str, + adjust_ohlc: bool = False, + sort: bool = True, + tzinfo: datetime.tzinfo = tz.tzlocal(), + timedelta: datetime.timedelta = datetime.timedelta(hours=24), + dict_reader_kwargs: dict = {}, ): self.row_parser = RowParser(pair, adjust_ohlc=adjust_ohlc, tzinfo=tzinfo, timedelta=timedelta) super().__init__(csv_path, self.row_parser, sort=sort, dict_reader_kwargs=dict_reader_kwargs) diff --git a/docs/backtesting_lending.rst b/docs/backtesting_lending.rst index 6fdf8ec..8e2aaaf 100644 --- a/docs/backtesting_lending.rst +++ b/docs/backtesting_lending.rst @@ -1,6 +1,6 @@ basana.backtesting.lending ========================== - + Lending strategies are used by the backtesting exchange to support different lending schemes, or no lending at all. **Margin calls are not yet implemented.** diff --git a/docs/backtesting_liquidity.rst b/docs/backtesting_liquidity.rst index a3169d9..2369c49 100644 --- a/docs/backtesting_liquidity.rst +++ b/docs/backtesting_liquidity.rst @@ -1,6 +1,6 @@ basana.backtesting.liquidity ============================ - + Liquidity strategies are used by the backtesting exchange to determine: * How much of a bar's volume can be consumed by an order. diff --git a/docs/conf.py b/docs/conf.py index 2f560e5..fbb109d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,34 +6,34 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = 'Basana' -copyright = '2022, Gabriel Martin Becedillas Ruiz' -author = 'Gabriel Martin Becedillas Ruiz' +project = "Basana" +copyright = "2022, Gabriel Martin Becedillas Ruiz" +author = "Gabriel Martin Becedillas Ruiz" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ - 'sphinx.ext.duration', - 'sphinx.ext.doctest', - 'sphinx.ext.autodoc', + "sphinx.ext.duration", + "sphinx.ext.doctest", + "sphinx.ext.autodoc", ] -templates_path = ['_templates'] -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] -autodoc_typehints = 'description' -autodoc_typehints_format = 'short' +autodoc_typehints = "description" +autodoc_typehints_format = "short" # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = 'alabaster' -html_static_path = ['_static'] +html_theme = "alabaster" +html_static_path = ["_static"] html_theme_options = { - 'github_user': 'gbeced', - 'github_repo': 'basana', - 'github_banner': 'false', - 'github_type': 'star', - 'github_count': 'true', + "github_user": "gbeced", + "github_repo": "basana", + "github_banner": "false", + "github_type": "star", + "github_count": "true", } diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 431f10a..6243a89 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -57,7 +57,7 @@ There are two types of events taking place in this example: When a new bar is received by the strategy, a technical indicator will be fed using the bar's closing price. If the technical indicator is ready, the strategy will check the values to determine if a switch in position should take place, and in that case a trading signal -will be pushed. +will be pushed. When the trading signal is received by the position manager, a buy or sell market order will be submitted to the exchange in order to open or close a position. diff --git a/pyproject.toml b/pyproject.toml index 22f2fa1..87bc677 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,3 +52,6 @@ exclude = [ # Add the `line-too-long` rule to the enforced rule set. extend-select = ["E501"] +[tool.bandit] +exclude_dirs = ["tests"] +skips = ["B101", "B110"] diff --git a/samples/backtest_bbands.py b/samples/backtest_bbands.py index 3f5478e..0f3900e 100644 --- a/samples/backtest_bbands.py +++ b/samples/backtest_bbands.py @@ -48,18 +48,21 @@ async def main(): # We'll be opening short positions so we need to set a lending strategy when initializing the exchange. lending_strategy = lending.MarginLoans( - pair.quote_symbol, Decimal("0.5"), + pair.quote_symbol, + Decimal("0.5"), default_conditions=lending.MarginLoanConditions( - interest_symbol=pair.quote_symbol, interest_percentage=Decimal("7"), - interest_period=datetime.timedelta(days=365), min_interest=Decimal("0.01"), - ) + interest_symbol=pair.quote_symbol, + interest_percentage=Decimal("7"), + interest_period=datetime.timedelta(days=365), + min_interest=Decimal("0.01"), + ), ) exchange = backtesting_exchange.Exchange( event_dispatcher, initial_balances={pair.quote_symbol: Decimal(1200)}, fee_strategy=backtesting_fees.Percentage(percentage=Decimal("0.1"), min_fee=Decimal("0.01")), lending_strategy=lending_strategy, - immediate_order_processing=True + immediate_order_processing=True, ) exchange.set_symbol_precision(pair.base_symbol, 8) exchange.set_symbol_precision(pair.quote_symbol, 2) @@ -70,9 +73,7 @@ async def main(): exchange.subscribe_to_bar_events(pair, strategy.on_bar_event) # Connect the position manager to different types of events. - position_mgr = position_manager.PositionManager( - exchange, position_amount, pair.quote_symbol, stop_loss_pct - ) + position_mgr = position_manager.PositionManager(exchange, position_amount, pair.quote_symbol, stop_loss_pct) strategy.subscribe_to_trading_signals(position_mgr.on_trading_signal) exchange.subscribe_to_bar_events(pair, position_mgr.on_bar_event) exchange.subscribe_to_order_events(position_mgr.on_order_event) diff --git a/samples/backtest_pairs_trading.py b/samples/backtest_pairs_trading.py index b94f3af..cb66ea6 100644 --- a/samples/backtest_pairs_trading.py +++ b/samples/backtest_pairs_trading.py @@ -49,17 +49,20 @@ async def main(): # We'll be opening short positions so we need to set a lending strategy when initializing the exchange. lending_strategy = lending.MarginLoans( - quote_symbol, Decimal("0.5"), + quote_symbol, + Decimal("0.5"), default_conditions=lending.MarginLoanConditions( - interest_symbol=quote_symbol, interest_percentage=Decimal("10"), - interest_period=datetime.timedelta(days=365), min_interest=Decimal("0.01") - ) + interest_symbol=quote_symbol, + interest_percentage=Decimal("10"), + interest_period=datetime.timedelta(days=365), + min_interest=Decimal("0.01"), + ), ) exchange = backtesting_exchange.Exchange( event_dispatcher, initial_balances={quote_symbol: Decimal(1200)}, lending_strategy=lending_strategy, - immediate_order_processing=True + immediate_order_processing=True, ) exchange.set_symbol_precision(pair_1.base_symbol, 8) exchange.set_symbol_precision(pair_2.base_symbol, 8) @@ -74,8 +77,14 @@ async def main(): # The pairs trading strategy. p_value_threshold = 0.01 trading_strategy = pairs_trading.Strategy( - event_dispatcher, pair_1, pair_2, window_size=24 * 10, z_score_window_size=24 * 10, - p_value_threshold=p_value_threshold, z_score_entry_ge=2.3, z_score_exit_lt=1.5 + event_dispatcher, + pair_1, + pair_2, + window_size=24 * 10, + z_score_window_size=24 * 10, + p_value_threshold=p_value_threshold, + z_score_entry_ge=2.3, + z_score_exit_lt=1.5, ) # Connect the position manager to different types of events. trading_strategy.subscribe_to_trading_signals(position_mgr.on_trading_signal) @@ -105,9 +114,7 @@ async def main(): # Log balances. balances = await exchange.get_balances() for currency, balance in balances.items(): - logging.info(StructuredMessage( - f"{currency} balance", available=balance.available - )) + logging.info(StructuredMessage(f"{currency} balance", available=balance.available)) chart.show() diff --git a/samples/backtest_rsi.py b/samples/backtest_rsi.py index a652bf8..2238f42 100644 --- a/samples/backtest_rsi.py +++ b/samples/backtest_rsi.py @@ -41,9 +41,7 @@ async def main(): event_dispatcher = bs.backtesting_dispatcher() pair = bs.Pair("BTC", "USD") exchange = backtesting_exchange.Exchange( - event_dispatcher, - initial_balances={"BTC": Decimal(0), "USD": Decimal(1200)}, - immediate_order_processing=True + event_dispatcher, initial_balances={"BTC": Decimal(0), "USD": Decimal(1200)}, immediate_order_processing=True ) exchange.set_symbol_precision(pair.base_symbol, 8) exchange.set_symbol_precision(pair.quote_symbol, 2) @@ -56,8 +54,11 @@ async def main(): # Connect the position manager to different types of events. Borrowing is disabled in this example. position_mgr = position_manager.PositionManager( - exchange, position_amount=Decimal(1000), quote_symbol=pair.quote_symbol, stop_loss_pct=Decimal(6), - borrowing_disabled=True + exchange, + position_amount=Decimal(1000), + quote_symbol=pair.quote_symbol, + stop_loss_pct=Decimal(6), + borrowing_disabled=True, ) strategy.subscribe_to_trading_signals(position_mgr.on_trading_signal) exchange.subscribe_to_bar_events(pair, position_mgr.on_bar_event) @@ -84,5 +85,6 @@ async def main(): chart.show() + if __name__ == "__main__": asyncio.run(main()) diff --git a/samples/backtest_sma.py b/samples/backtest_sma.py index 539b243..53d655d 100644 --- a/samples/backtest_sma.py +++ b/samples/backtest_sma.py @@ -42,9 +42,7 @@ async def main(): event_dispatcher = bs.backtesting_dispatcher() pair = bs.Pair("BTC", "USDT") exchange = backtesting_exchange.Exchange( - event_dispatcher, - initial_balances={pair.quote_symbol: Decimal(1200)}, - immediate_order_processing=True + event_dispatcher, initial_balances={pair.quote_symbol: Decimal(1200)}, immediate_order_processing=True ) exchange.set_symbol_precision(pair.base_symbol, 8) exchange.set_symbol_precision(pair.quote_symbol, 2) @@ -55,8 +53,11 @@ async def main(): # Connect the position manager to different types of events. Borrowing is disabled in this example. position_mgr = position_manager.PositionManager( - exchange, position_amount=Decimal(1000), quote_symbol=pair.quote_symbol, stop_loss_pct=Decimal(5), - borrowing_disabled=True + exchange, + position_amount=Decimal(1000), + quote_symbol=pair.quote_symbol, + stop_loss_pct=Decimal(5), + borrowing_disabled=True, ) strategy.subscribe_to_trading_signals(position_mgr.on_trading_signal) exchange.subscribe_to_bar_events(pair, position_mgr.on_bar_event) @@ -77,9 +78,7 @@ async def main(): # Log balances. balances = await exchange.get_balances() for currency, balance in balances.items(): - logging.info(StructuredMessage( - f"{currency} balance", available=balance.available - )) + logging.info(StructuredMessage(f"{currency} balance", available=balance.available)) chart.show() diff --git a/samples/backtesting/position_manager.py b/samples/backtesting/position_manager.py index bc34ad3..203e9b6 100644 --- a/samples/backtesting/position_manager.py +++ b/samples/backtesting/position_manager.py @@ -42,8 +42,9 @@ class PositionInfo: def __post_init__(self): # Both initial and initial_avg_price should be set to 0, or none of them. - assert (self.initial == Decimal(0)) is (self.initial_avg_price == Decimal(0)), \ - f"initial={self.initial}, initial_avg_price={self.initial_avg_price}" + assert (self.initial == Decimal(0)) is (self.initial_avg_price == Decimal(0)), ( + f"initial={self.initial}, initial_avg_price={self.initial_avg_price}" + ) @property def current(self) -> Decimal: @@ -79,13 +80,18 @@ def avg_price(self) -> Decimal: else: assert self.initial * self.target > 0 # Reducing the position. - if self.target > 0 and self.order.operation == bs.OrderOperation.SELL \ - or self.target < 0 and self.order.operation == bs.OrderOperation.BUY: + if ( + self.target > 0 + and self.order.operation == bs.OrderOperation.SELL + or self.target < 0 + and self.order.operation == bs.OrderOperation.BUY + ): ret = self.initial_avg_price # Increasing the position. else: - ret = (abs(self.initial) * self.initial_avg_price + self.order.amount_filled * order_fill_price) \ - / (abs(self.initial) + self.order.amount_filled) + ret = (abs(self.initial) * self.initial_avg_price + self.order.amount_filled * order_fill_price) / ( + abs(self.initial) + self.order.amount_filled + ) return ret @@ -111,8 +117,12 @@ def calculate_unrealized_pnl_pct(self, bid: Decimal, ask: Decimal) -> Decimal: class PositionManager: # Responsible for managing orders and tracking positions in response to trading signals. def __init__( - self, exchange: backtesting_exchange.Exchange, position_amount: Decimal, quote_symbol: str, - stop_loss_pct: Decimal, borrowing_disabled: bool = False + self, + exchange: backtesting_exchange.Exchange, + position_amount: Decimal, + quote_symbol: str, + stop_loss_pct: Decimal, + borrowing_disabled: bool = False, ): assert position_amount > 0 assert stop_loss_pct > 0 @@ -148,12 +158,16 @@ async def check_loss(self): bids_and_asks = await asyncio.gather(*[self._exchange.get_bid_ask(pos_info.pair) for pos_info in non_neutral]) for pos_info, (bid, ask) in zip(non_neutral, bids_and_asks): pnl_pct = pos_info.calculate_unrealized_pnl_pct(bid, ask) - logger.info(StructuredMessage( - f"Position for {pos_info.pair}", current=pos_info.current, target=pos_info.target, - order_open=pos_info.order_open, - avg_price=bs.round_decimal(pos_info.avg_price, pos_info.pair_info.quote_precision), - pnl_pct=bs.round_decimal(pnl_pct, 2) - )) + logger.info( + StructuredMessage( + f"Position for {pos_info.pair}", + current=pos_info.current, + target=pos_info.target, + order_open=pos_info.order_open, + avg_price=bs.round_decimal(pos_info.avg_price, pos_info.pair_info.quote_precision), + pnl_pct=bs.round_decimal(pnl_pct, 2), + ) + ) if pnl_pct <= self._stop_loss_pct * -1: logger.info(f"Stop loss for {pos_info.pair}") await self.switch_position(pos_info.pair, bs.Position.NEUTRAL, force=True) @@ -162,14 +176,16 @@ async def switch_position(self, pair: bs.Pair, target_position: bs.Position, for current_pos_info = await self.get_position_info(pair) # Unless force is set, we can ignore the request if we're already there. - if not force and any([ + if not force and any( + [ current_pos_info is None and target_position == bs.Position.NEUTRAL, ( current_pos_info is not None and signed_to_position(current_pos_info.target) == target_position and current_pos_info.target_reached - ) - ]): + ), + ] + ): return # Exclusive access to the position since we're going to modify it. @@ -213,9 +229,9 @@ async def switch_position(self, pair: bs.Pair, target_position: bs.Position, for # 3. Create the order. order_size = abs(delta) operation = bs.OrderOperation.BUY if delta > 0 else bs.OrderOperation.SELL - logger.info(StructuredMessage( - "Creating market order", operation=operation, pair=pair, order_size=order_size - )) + logger.info( + StructuredMessage("Creating market order", operation=operation, pair=pair, order_size=order_size) + ) created_order = await self._exchange.create_market_order( operation, pair, order_size, auto_borrow=True, auto_repay=True ) @@ -224,8 +240,12 @@ async def switch_position(self, pair: bs.Pair, target_position: bs.Position, for # 4. Keep track of the position. initial_avg_price = Decimal(0) if current_pos_info is None else current_pos_info.avg_price pos_info = PositionInfo( - pair=pair, pair_info=pair_info, initial=current, initial_avg_price=initial_avg_price, target=target, - order=created_order + pair=pair, + pair_info=pair_info, + initial=current, + initial_avg_price=initial_avg_price, + target=target, + order=created_order, ) self._positions[pair] = pos_info @@ -252,15 +272,21 @@ async def on_bar_event(self, bar_event: bs.BarEvent): async def on_order_event(self, order_event: backtesting_exchange.OrderEvent): order = order_event.order - logger.info(StructuredMessage( - "Order updated", id=order.id, is_open=order.is_open, amount=order.amount, - amount_filled=order.amount_filled, avg_fill_price=order.fill_price - )) + logger.info( + StructuredMessage( + "Order updated", + id=order.id, + is_open=order.is_open, + amount=order.amount, + amount_filled=order.amount_filled, + avg_fill_price=order.fill_price, + ) + ) # Update the position info. async with self._pos_mutex[order.pair]: pos_info = self._positions[order.pair] - if order.id == pos_info.order.id and order.amount_filled >= pos_info.order.amount_filled: + if order.id == pos_info.order.id and order.amount_filled >= pos_info.order.amount_filled: pos_info.order = order diff --git a/samples/binance/order_book_mirror.py b/samples/binance/order_book_mirror.py index b3fcfe4..539e513 100644 --- a/samples/binance/order_book_mirror.py +++ b/samples/binance/order_book_mirror.py @@ -37,9 +37,8 @@ class OrderBook: while maintaining proper synchronization. It ensures price levels are properly maintained by inserting, updating or removing entries based on incoming data. """ - def __init__( - self, bids: List[Tuple[Decimal, Decimal]] = [], asks: List[Tuple[Decimal, Decimal]] = [] - ): + + def __init__(self, bids: List[Tuple[Decimal, Decimal]] = [], asks: List[Tuple[Decimal, Decimal]] = []): self.bids = sorted(bids, reverse=True) self.asks = sorted(asks) self.last_update_id = 0 @@ -55,8 +54,7 @@ def from_exchange(cls, obook: binance_exchange.PartialOrderBook) -> "OrderBook": :param obook: Partial order book data from Binance exchange. """ ret = OrderBook( - bids=[(bid.price, bid.volume) for bid in obook.bids], - asks=[(ask.price, ask.volume) for ask in obook.asks] + bids=[(bid.price, bid.volume) for bid in obook.bids], asks=[(ask.price, ask.volume) for ask in obook.asks] ) ret.last_update_id = obook.last_update_id return ret @@ -160,11 +158,17 @@ class OrderBookUpdater: book depth in shape. :param restart_threshold: The threshold below which a re-sync from start is triggered. """ + def __init__( - self, pair: bs.Pair, exchange: binance_exchange.Exchange, max_depth: int = 5000, - diff_interval_ms: int = 100, check_interval_ms: int = 5000, check_depth: int = 20, - full_depth_threshold: float = 0.8, - restart_threshold: float = 0.2, + self, + pair: bs.Pair, + exchange: binance_exchange.Exchange, + max_depth: int = 5000, + diff_interval_ms: int = 100, + check_interval_ms: int = 5000, + check_depth: int = 20, + full_depth_threshold: float = 0.8, + restart_threshold: float = 0.2, ): assert diff_interval_ms in (100, 1000) assert max_depth > 0 and max_depth <= 5000 @@ -186,17 +190,20 @@ def __init__( exchange.subscribe_to_order_book_diff_events(pair, self._on_order_book_diff_event, interval=diff_interval_ms) async def _on_order_book_diff_event(self, diff_event: binance_exchange.OrderBookDiffEvent): - logger.info(StructuredMessage( - "Order book diff", first_update_id=diff_event.order_book_diff.first_update_id, - final_update_id=diff_event.order_book_diff.final_update_id - )) + logger.info( + StructuredMessage( + "Order book diff", + first_update_id=diff_event.order_book_diff.first_update_id, + final_update_id=diff_event.order_book_diff.final_update_id, + ) + ) await self._state.on_order_book_diff_event(self, diff_event) msg_kwargs = dict( bids=len(self.order_book.bids), asks=len(self.order_book.asks), - last_update_id=self.order_book.last_update_id + last_update_id=self.order_book.last_update_id, ) if self.order_book.bids: msg_kwargs["bid"] = self.order_book.bids[0][0] @@ -212,20 +219,25 @@ async def _on_order_book_diff_event(self, diff_event: binance_exchange.OrderBook async def switch_state(self, state: UpdaterState, order_book: Optional[OrderBook] = None): async with self._switch_mutex: - logger.info(StructuredMessage( - "Switch state", current=self._state.__class__.__name__, new=state.__class__.__name__ - )) + logger.info( + StructuredMessage("Switch state", current=self._state.__class__.__name__, new=state.__class__.__name__) + ) await self._state.on_exit_state(self) if order_book: assert order_book.last_update_id >= self.order_book.last_update_id self.order_book = order_book - logger.info(StructuredMessage( - "New orderbook set", last_update_id=self.order_book.last_update_id, - top_bid=self.order_book.bids[0][0], last_bid=self.order_book.bids[-1][0], - top_ask=self.order_book.asks[0][0], last_ask=self.order_book.asks[-1][0], - )) + logger.info( + StructuredMessage( + "New orderbook set", + last_update_id=self.order_book.last_update_id, + top_bid=self.order_book.bids[0][0], + last_bid=self.order_book.bids[-1][0], + top_ask=self.order_book.asks[0][0], + last_ask=self.order_book.asks[-1][0], + ) + ) self._state = state await self._state.on_enter_state(self) @@ -237,7 +249,7 @@ def __init__(self): self._fetch_task: Optional[asyncio.Task] = None async def on_order_book_diff_event( - self, updater: OrderBookUpdater, diff_event: binance_exchange.OrderBookDiffEvent + self, updater: OrderBookUpdater, diff_event: binance_exchange.OrderBookDiffEvent ): # Buffer diffs to be processed once the snapshot is fetched. self._buffer.append(diff_event) @@ -288,7 +300,7 @@ async def on_enter_state(self, updater: "OrderBookUpdater"): self._fetch_task = asyncio.create_task(self._fetch_snapshot(updater)) async def on_order_book_diff_event( - self, updater: OrderBookUpdater, diff_event: binance_exchange.OrderBookDiffEvent + self, updater: OrderBookUpdater, diff_event: binance_exchange.OrderBookDiffEvent ): try: updater.order_book.update_from_diff(diff_event) @@ -311,8 +323,10 @@ async def _fetch_snapshot(self, updater: OrderBookUpdater): await asyncio.sleep(updater._check_interval_ms / 1000) limit = updater._check_depth - if len(updater.order_book.bids) <= updater._full_depth_threshold \ - or len(updater.order_book.asks) <= updater._full_depth_threshold: + if ( + len(updater.order_book.bids) <= updater._full_depth_threshold + or len(updater.order_book.asks) <= updater._full_depth_threshold + ): limit = updater._max_depth snapshot = await updater._exchange.get_order_book(updater._pair, limit=limit) @@ -332,37 +346,40 @@ def value_desc(is_bid: bool): return "bids" if is_bid else "asks" def check_or_update( - snapshot_values: List[Tuple[Decimal, Decimal]], order_book_values: List[Tuple[Decimal, Decimal]], - is_bid: bool + snapshot_values: List[Tuple[Decimal, Decimal]], + order_book_values: List[Tuple[Decimal, Decimal]], + is_bid: bool, ): ret = True if len(snapshot_values) > len(order_book_values): # There is no need to check, just go and update. - logger.info(StructuredMessage( - f"Updating {value_desc(is_bid)} using snapshot", order_book=len(order_book_values), - snapshot=len(snapshot_values) - )) + logger.info( + StructuredMessage( + f"Updating {value_desc(is_bid)} using snapshot", + order_book=len(order_book_values), + snapshot=len(snapshot_values), + ) + ) if is_bid: updater.order_book.update_bids(snapshot_values) else: updater.order_book.update_asks(snapshot_values) - elif order_book_values[:updater._check_depth] != snapshot_values[:updater._check_depth]: - logger.error(StructuredMessage( - f"{value_desc(is_bid).title()} mismatch", last_update_id=self._snapshot.last_update_id, - order_book=order_book_values[:updater._check_depth], - snapshot=snapshot_values[:updater._check_depth], - )) + elif order_book_values[: updater._check_depth] != snapshot_values[: updater._check_depth]: + logger.error( + StructuredMessage( + f"{value_desc(is_bid).title()} mismatch", + last_update_id=self._snapshot.last_update_id, + order_book=order_book_values[: updater._check_depth], + snapshot=snapshot_values[: updater._check_depth], + ) + ) ret = False return ret if not check_or_update( - [(bid.price, bid.volume) for bid in self._snapshot.bids], - updater.order_book.bids, - True + [(bid.price, bid.volume) for bid in self._snapshot.bids], updater.order_book.bids, True ) or not check_or_update( - [(ask.price, ask.volume) for ask in self._snapshot.asks], - updater.order_book.asks, - False + [(ask.price, ask.volume) for ask in self._snapshot.asks], updater.order_book.asks, False ): await updater.switch_state(Initializing()) diff --git a/samples/binance/position_manager.py b/samples/binance/position_manager.py index c83f20d..c64805d 100644 --- a/samples/binance/position_manager.py +++ b/samples/binance/position_manager.py @@ -63,8 +63,9 @@ class PositionInfo: def __post_init__(self): # Both initial and initial_avg_price should be set to 0, or none of them. - assert (self.initial == Decimal(0)) is (self.initial_avg_price == Decimal(0)), \ - f"initial={self.initial}, initial_avg_price={self.initial_avg_price}" + assert (self.initial == Decimal(0)) is (self.initial_avg_price == Decimal(0)), ( + f"initial={self.initial}, initial_avg_price={self.initial_avg_price}" + ) @property def current(self) -> Decimal: @@ -100,13 +101,18 @@ def avg_price(self) -> Decimal: else: assert self.initial * self.target > 0 # Reducing the position. - if self.target > 0 and self.order.operation == bs.OrderOperation.SELL \ - or self.target < 0 and self.order.operation == bs.OrderOperation.BUY: + if ( + self.target > 0 + and self.order.operation == bs.OrderOperation.SELL + or self.target < 0 + and self.order.operation == bs.OrderOperation.BUY + ): ret = self.initial_avg_price # Increasing the position. else: - ret = (abs(self.initial) * self.initial_avg_price + self.order.amount_filled * order_fill_price) \ - / (abs(self.initial) + self.order.amount_filled) + ret = (abs(self.initial) * self.initial_avg_price + self.order.amount_filled * order_fill_price) / ( + abs(self.initial) + self.order.amount_filled + ) return ret @@ -132,8 +138,12 @@ def calculate_unrealized_pnl_pct(self, bid: Decimal, ask: Decimal) -> Decimal: class SpotAccountPositionManager: # Responsible for managing orders and tracking positions in response to trading signals. def __init__( - self, exchange: exchange.Exchange, position_amount: Decimal, quote_symbol: str, - stop_loss_pct: Decimal, checkpoint_fname: str + self, + exchange: exchange.Exchange, + position_amount: Decimal, + quote_symbol: str, + stop_loss_pct: Decimal, + checkpoint_fname: str, ): assert position_amount > 0 assert stop_loss_pct > 0 @@ -170,12 +180,16 @@ async def check_loss(self): bids_and_asks = await asyncio.gather(*[self._exchange.get_bid_ask(pos_info.pair) for pos_info in non_neutral]) for pos_info, (bid, ask) in zip(non_neutral, bids_and_asks): pnl_pct = pos_info.calculate_unrealized_pnl_pct(bid, ask) - logger.info(StructuredMessage( - f"Position for {pos_info.pair}", current=pos_info.current, target=pos_info.target, - order_open=pos_info.order_open, - avg_price=bs.round_decimal(pos_info.avg_price, pos_info.pair_info.quote_precision), - pnl_pct=bs.round_decimal(pnl_pct, 2) - )) + logger.info( + StructuredMessage( + f"Position for {pos_info.pair}", + current=pos_info.current, + target=pos_info.target, + order_open=pos_info.order_open, + avg_price=bs.round_decimal(pos_info.avg_price, pos_info.pair_info.quote_precision), + pnl_pct=bs.round_decimal(pnl_pct, 2), + ) + ) if pnl_pct <= self._stop_loss_pct * -1: logger.info(f"Stop loss for {pos_info.pair}") await self.switch_position(pos_info.pair, bs.Position.NEUTRAL, force=True) @@ -184,14 +198,16 @@ async def switch_position(self, pair: bs.Pair, target_position: bs.Position, for current_pos_info = await self.get_position_info(pair) # Unless force is set, we can ignore the request if we're already there. - if not force and any([ + if not force and any( + [ current_pos_info is None and target_position == bs.Position.NEUTRAL, ( current_pos_info is not None and signed_to_position(current_pos_info.target) == target_position and current_pos_info.target_reached - ) - ]): + ), + ] + ): return # Exclusive access to the position since we're going to modify it. @@ -200,9 +216,7 @@ async def switch_position(self, pair: bs.Pair, target_position: bs.Position, for if current_pos_info and current_pos_info.order_open: logger.info(StructuredMessage("Canceling order", order_ids=current_pos_info.order.id)) await self._exchange.spot_account.cancel_order(pair, order_id=current_pos_info.order.id) - order_info = await self._exchange.spot_account.get_order_info( - pair, order_id=current_pos_info.order.id - ) + order_info = await self._exchange.spot_account.get_order_info(pair, order_id=current_pos_info.order.id) current_pos_info.order.update_from_order_info(order_info) (bid, ask), pair_info = await asyncio.gather( @@ -238,9 +252,9 @@ async def switch_position(self, pair: bs.Pair, target_position: bs.Position, for # 3. Create the order. order_size = abs(delta) operation = bs.OrderOperation.BUY if delta > 0 else bs.OrderOperation.SELL - logger.info(StructuredMessage( - "Creating market order", operation=operation, pair=pair, order_size=order_size - )) + logger.info( + StructuredMessage("Creating market order", operation=operation, pair=pair, order_size=order_size) + ) created_order = await self._exchange.spot_account.create_market_order(operation, pair, order_size) logger.info(StructuredMessage("Order created", id=created_order.id)) order = await self._exchange.spot_account.get_order_info(pair, order_id=created_order.id) @@ -248,11 +262,18 @@ async def switch_position(self, pair: bs.Pair, target_position: bs.Position, for # 4. Keep track of the position. initial_avg_price = Decimal(0) if current_pos_info is None else current_pos_info.avg_price pos_info = PositionInfo( - pair=pair, pair_info=pair_info, initial=current, initial_avg_price=initial_avg_price, target=target, + pair=pair, + pair_info=pair_info, + initial=current, + initial_avg_price=initial_avg_price, + target=target, order=OrderInfo( - id=order.id, operation=order.operation, is_open=order.is_open, - amount_filled=order.amount_filled, fill_price=order.fill_price, - ) + id=order.id, + operation=order.operation, + is_open=order.is_open, + amount_filled=order.amount_filled, + fill_price=order.fill_price, + ), ) self._positions[pair] = pos_info @@ -281,15 +302,22 @@ async def on_bar_event(self, bar_event: bs.BarEvent): async def on_order_event(self, order_event: spot.OrderEvent): order_update = order_event.order_update pair = await self._exchange.symbol_to_pair(order_update.symbol) - logger.info(StructuredMessage( - "Order updated", id=order_update.id, pair=pair, is_open=order_update.is_open, amount=order_update.amount, - amount_filled=order_update.amount_filled, avg_fill_price=order_update.fill_price - )) + logger.info( + StructuredMessage( + "Order updated", + id=order_update.id, + pair=pair, + is_open=order_update.is_open, + amount=order_update.amount, + amount_filled=order_update.amount_filled, + avg_fill_price=order_update.fill_price, + ) + ) # Update the position info. async with self._pos_mutex[pair]: pos_info = self._positions[pair] - if order_update.id == pos_info.order.id and order_update.amount_filled >= pos_info.order.amount_filled: + if order_update.id == pos_info.order.id and order_update.amount_filled >= pos_info.order.amount_filled: pos_info.order.update_from_order_update(order_update) diff --git a/samples/binance_bbands.py b/samples/binance_bbands.py index 3a91c1a..17441ab 100644 --- a/samples/binance_bbands.py +++ b/samples/binance_bbands.py @@ -17,6 +17,7 @@ from decimal import Decimal import asyncio import logging +import os from basana.external.binance import exchange as binance_exchange import basana as bs @@ -33,8 +34,8 @@ async def main(): position_amount = Decimal(100) stop_loss_pct = Decimal(5) checkpoint_fname = "binance_bbands_positions.json" - api_key = "YOUR_API_KEY" - api_secret = "YOUR_API_SECRET" + api_key = os.environ["BINANCE_API_KEY"] + api_secret = os.environ["BINANCE_API_SECRET"] exchange = binance_exchange.Exchange(event_dispatcher, api_key=api_key, api_secret=api_secret) diff --git a/samples/binance_order_book_mirror.py b/samples/binance_order_book_mirror.py index 5bcb5d9..1d00f74 100644 --- a/samples/binance_order_book_mirror.py +++ b/samples/binance_order_book_mirror.py @@ -137,8 +137,8 @@ def _generate_stats(self): stats["spread"] = spread stats["mid_price"] = mid_price - top_bids = self._mirror.order_book.bids[:self._top_n] - top_asks = self._mirror.order_book.asks[:self._top_n] + top_bids = self._mirror.order_book.bids[: self._top_n] + top_asks = self._mirror.order_book.asks[: self._top_n] top_bids_depth = sum(bid[1] for bid in top_bids) top_asks_depth = sum(ask[1] for ask in top_asks) top_bids_vwap = sum(bid[0] * bid[1] for bid in top_bids) / top_bids_depth @@ -194,7 +194,7 @@ def build_row(row: int): row_i = current_rows + i table.add_row(*build_row(row_i), key=str(row_i)) else: - while (len(table.rows) > final_rows): + while len(table.rows) > final_rows: table.remove_row(str(len(table.rows) - 1)) def _update_basic_stats(self, stats: dict): @@ -206,8 +206,11 @@ def _update_basic_stats(self, stats: dict): values = {} values["spread"] = format_stat(stats, "spread", "{:.8f}") values["mid_price"] = format_stat(stats, "mid_price", "{:.8f}") - values["lag"] = "N/A" if self._mirror.order_book.last_updated is None else \ - f"{(bs.utc_now() - self._mirror.order_book.last_updated).total_seconds():.3f} s" + values["lag"] = ( + "N/A" + if self._mirror.order_book.last_updated is None + else f"{(bs.utc_now() - self._mirror.order_book.last_updated).total_seconds():.3f} s" + ) self._update_table("#basic_stats", rows, values) diff --git a/samples/binance_websockets.py b/samples/binance_websockets.py index f727774..8e01f88 100644 --- a/samples/binance_websockets.py +++ b/samples/binance_websockets.py @@ -24,30 +24,40 @@ async def on_bar_event(bar_event: bs.BarEvent): logging.info( "Bar event: pair=%s open=%s high=%s low=%s close=%s volume=%s", - bar_event.bar.pair, bar_event.bar.open, bar_event.bar.high, bar_event.bar.low, bar_event.bar.close, - bar_event.bar.volume + bar_event.bar.pair, + bar_event.bar.open, + bar_event.bar.high, + bar_event.bar.low, + bar_event.bar.close, + bar_event.bar.volume, ) async def on_order_book_event(order_book_event: binance_exchange.OrderBookEvent): logging.info( "Order book event: pair=%s bid=%s ask=%s", - order_book_event.order_book.pair, order_book_event.order_book.bids[0].price, - order_book_event.order_book.asks[0].price + order_book_event.order_book.pair, + order_book_event.order_book.bids[0].price, + order_book_event.order_book.asks[0].price, ) async def on_trade_event(trade_event: binance_exchange.TradeEvent): logging.info( "Trade event: pair=%s price=%s amount=%s", - trade_event.trade.pair, trade_event.trade.price, trade_event.trade.amount + trade_event.trade.pair, + trade_event.trade.price, + trade_event.trade.amount, ) async def on_order_event(event): logging.info( "Order event: id=%s status=%s amount_filled=%s fees=%s", - event.order_update.id, event.order_update.status, event.order_update.amount_filled, event.order_update.fees + event.order_update.id, + event.order_update.status, + event.order_update.amount_filled, + event.order_update.fees, ) @@ -56,8 +66,9 @@ async def main(): event_dispatcher = bs.realtime_dispatcher() exchange = binance_exchange.Exchange( event_dispatcher, - # api_key="YOUR_API_KEY", - # api_secret="YOUR_API_SECRET" + # Optional private stream auth via env vars: + # api_key=os.environ["BINANCE_API_KEY"], + # api_secret=os.environ["BINANCE_API_SECRET"], ) pairs = [ diff --git a/samples/bitstamp_websockets.py b/samples/bitstamp_websockets.py index 53e465f..5fdb1e2 100644 --- a/samples/bitstamp_websockets.py +++ b/samples/bitstamp_websockets.py @@ -24,30 +24,36 @@ async def on_bar_event(bar_event: bs.BarEvent): logging.info( "Bar event: pair=%s open=%s high=%s low=%s close=%s volume=%s", - bar_event.bar.pair, bar_event.bar.open, bar_event.bar.high, bar_event.bar.low, bar_event.bar.close, - bar_event.bar.volume + bar_event.bar.pair, + bar_event.bar.open, + bar_event.bar.high, + bar_event.bar.low, + bar_event.bar.close, + bar_event.bar.volume, ) async def on_order_book_event(order_book_event: bitstamp_exchange.OrderBookEvent): logging.info( "Order book event: pair=%s bid=%s ask=%s", - order_book_event.order_book.pair, order_book_event.order_book.bids[0].price, - order_book_event.order_book.asks[0].price + order_book_event.order_book.pair, + order_book_event.order_book.bids[0].price, + order_book_event.order_book.asks[0].price, ) async def on_trade_event(trade_event: bitstamp_exchange.TradeEvent): logging.info( "Trade event: pair=%s price=%s amount=%s", - trade_event.trade.pair, trade_event.trade.price, trade_event.trade.amount + trade_event.trade.pair, + trade_event.trade.price, + trade_event.trade.amount, ) async def on_order_event(event: bitstamp_exchange.OrderEvent): logging.info( - "Order event: id=%s amount_filled=%s json=%s", - event.order.id, event.order.amount_filled, event.order.json + "Order event: id=%s amount_filled=%s json=%s", event.order.id, event.order.amount_filled, event.order.json ) diff --git a/samples/strategies/bbands.py b/samples/strategies/bbands.py index dce665a..fee03ae 100644 --- a/samples/strategies/bbands.py +++ b/samples/strategies/bbands.py @@ -45,6 +45,10 @@ async def on_bar_event(self, bar_event: bs.BarEvent): elif self._values[-2] <= self.bb[-2].ub and self._values[-1] > self.bb[-1].ub: self.push(bs.TradingSignal(bar_event.when, bs.Position.SHORT, bar_event.bar.pair)) # Go neutral when the price touches the middle band. - elif self._values[-2] < self.bb[-2].cb and self._values[-1] >= self.bb[-1].cb \ - or self._values[-2] > self.bb[-2].cb and self._values[-1] <= self.bb[-1].cb: + elif ( + self._values[-2] < self.bb[-2].cb + and self._values[-1] >= self.bb[-1].cb + or self._values[-2] > self.bb[-2].cb + and self._values[-1] <= self.bb[-1].cb + ): self.push(bs.TradingSignal(bar_event.when, bs.Position.NEUTRAL, bar_event.bar.pair)) diff --git a/samples/strategies/dmac.py b/samples/strategies/dmac.py index a9fa9d4..cba5583 100644 --- a/samples/strategies/dmac.py +++ b/samples/strategies/dmac.py @@ -33,8 +33,7 @@ async def on_bar_event(self, bar_event: bs.BarEvent): self._lt_sma.add(value) # Are MAs ready ? - if len(self._st_sma) < 2 or len(self._lt_sma) < 2 \ - or self._st_sma[-2] is None or self._lt_sma[-2] is None: + if len(self._st_sma) < 2 or len(self._lt_sma) < 2 or self._st_sma[-2] is None or self._lt_sma[-2] is None: return # Go long when short-term MA crosses above long-term MA. diff --git a/samples/strategies/pairs_trading.py b/samples/strategies/pairs_trading.py index a984633..f7ff31c 100644 --- a/samples/strategies/pairs_trading.py +++ b/samples/strategies/pairs_trading.py @@ -31,8 +31,15 @@ def get_p_value(values_1, values_2): # Strategy based on https://notebook.community/gwulfs/research_public/lectures/pairs_trading/Pairs%20Trading class Strategy(bs.TradingSignalSource): def __init__( - self, dispatcher: bs.EventDispatcher, pair_1: bs.Pair, pair_2: bs.Pair, window_size: int, - z_score_window_size: int, p_value_threshold: float, z_score_entry_ge: float, z_score_exit_lt: float + self, + dispatcher: bs.EventDispatcher, + pair_1: bs.Pair, + pair_2: bs.Pair, + window_size: int, + z_score_window_size: int, + p_value_threshold: float, + z_score_entry_ge: float, + z_score_exit_lt: float, ): assert z_score_window_size <= window_size @@ -86,7 +93,7 @@ async def on_bar_event(self, bar_event: bs.BarEvent): bs.Position.LONG: bs.Position.SHORT, bs.Position.SHORT: bs.Position.LONG, bs.Position.NEUTRAL: bs.Position.NEUTRAL, - }[target_position] + }[target_position], ) self.push(signal) @@ -100,7 +107,7 @@ def _get_target_position(self) -> Optional[bs.Position]: def _update_df(self, bar_event: bs.BarEvent): self._df.at[bar_event.bar.datetime, bar_event.bar.pair.base_symbol] = float(bar_event.bar.close) - self._df = self._df.iloc[-self._window_size:] + self._df = self._df.iloc[-self._window_size :] def _df_ready(self): if len(self._df.columns) != 2: @@ -116,5 +123,5 @@ def _update_indicators(self): values_2 = self._df[self._pair_2.base_symbol] self._p_value = get_p_value(values_1, values_2) - ratios = values_1[-self._z_score_window_size:] / values_2[-self._z_score_window_size:] + ratios = values_1[-self._z_score_window_size :] / values_2[-self._z_score_window_size :] self._z_score = (ratios.iloc[-1] - ratios.mean()) / ratios.std() diff --git a/setup.cfg b/setup.cfg index cfed057..11ce359 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,6 +9,6 @@ show_missing = True skip_covered = True include = basana/* -exclude_lines = +exclude_lines = raise NotImplementedError() pragma: no cover diff --git a/tests/backtesting_exchange_orders_test_data.py b/tests/backtesting_exchange_orders_test_data.py index f1a8070..3205c5d 100644 --- a/tests/backtesting_exchange_orders_test_data.py +++ b/tests/backtesting_exchange_orders_test_data.py @@ -40,30 +40,22 @@ { datetime.date(2000, 1, 4): [ ( - lambda e: e.create_stop_order( - OrderOperation.BUY, orcl_pair, Decimal("1e6"), Decimal("0.01") - ), + lambda e: e.create_stop_order(OrderOperation.BUY, orcl_pair, Decimal("1e6"), Decimal("0.01")), [], - None + None, ), ], }, - False + False, ), # Market order canceled due to insufficient funds. ( { datetime.date(2000, 1, 8): [ - ( - lambda e: e.create_market_order( - OrderOperation.BUY, orcl_pair, Decimal("9649") - ), - [], - None - ), + (lambda e: e.create_market_order(OrderOperation.BUY, orcl_pair, Decimal("9649")), [], None), ], }, - False + False, ), # Multiple orders in the test. ( @@ -71,9 +63,7 @@ datetime.date(2000, 1, 4): [ # Buy market. ( - lambda e: e.create_market_order( - OrderOperation.BUY, orcl_pair, Decimal("2") - ), + lambda e: e.create_market_order(OrderOperation.BUY, orcl_pair, Decimal("2")), [ dict( when=datetime.datetime(2000, 1, 4, tzinfo=tz.tzlocal()), @@ -90,7 +80,7 @@ amount_filled=Decimal("0"), amount_remaining=Decimal("2"), quote_amount_filled=Decimal("0"), - fees={} + fees={}, ), dict( is_open=False, @@ -102,30 +92,26 @@ fees={"USD": Decimal("0.58")}, fill_price=Decimal("115.5"), ), - ] + ], ), # Limit order within bar. ( - lambda e: e.create_limit_order( - OrderOperation.BUY, orcl_pair, Decimal("4"), Decimal("110.01") - ), + lambda e: e.create_limit_order(OrderOperation.BUY, orcl_pair, Decimal("4"), Decimal("110.01")), [ dict( when=datetime.datetime(2000, 1, 4, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("4"), "USD": Decimal("-440.04")}, fees={"USD": Decimal("-1.11")}, - fill_price=Decimal("110.01") + fill_price=Decimal("110.01"), ), ], - None + None, ), ], datetime.date(2000, 1, 15): [ # Sell market. ( - lambda e: e.create_market_order( - OrderOperation.SELL, orcl_pair, Decimal("1") - ), + lambda e: e.create_market_order(OrderOperation.SELL, orcl_pair, Decimal("1")), [ dict( when=datetime.datetime(2000, 1, 18, tzinfo=tz.tzlocal()), @@ -133,13 +119,11 @@ fees={"USD": Decimal("-0.27")}, ), ], - None + None, ), # Limit order within bar. ( - lambda e: e.create_limit_order( - OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("108") - ), + lambda e: e.create_limit_order(OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("108")), [ dict( when=datetime.datetime(2000, 1, 18, tzinfo=tz.tzlocal()), @@ -147,13 +131,11 @@ fees={"USD": Decimal("-0.27")}, ), ], - None + None, ), # Sell stop. ( - lambda e: e.create_stop_order( - OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("108") - ), + lambda e: e.create_stop_order(OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("108")), [ dict( when=datetime.datetime(2000, 1, 18, tzinfo=tz.tzlocal()), @@ -162,15 +144,14 @@ fill_price=Decimal("107.87"), ), ], - None + None, ), ], datetime.date(2000, 1, 19): [ # Stop price should be hit on 2000-01-20 and order should be filled on 2000-01-24. ( lambda e: e.create_stop_limit_order( - OrderOperation.BUY, orcl_pair, Decimal("5"), Decimal("59.5"), - Decimal("58.03") + OrderOperation.BUY, orcl_pair, Decimal("5"), Decimal("59.5"), Decimal("58.03") ), [ dict( @@ -179,15 +160,14 @@ fees={"USD": Decimal("-0.73")}, ), ], - None + None, ), ], datetime.date(2000, 3, 10): [ # Stop price should be hit on 2000-03-10 and order should be filled on 2000-03-13 at open price. ( lambda e: e.create_stop_limit_order( - OrderOperation.BUY, orcl_pair, Decimal("10"), Decimal("81.62"), - Decimal("80.24") + OrderOperation.BUY, orcl_pair, Decimal("10"), Decimal("81.62"), Decimal("80.24") ), [ dict( @@ -196,13 +176,12 @@ fees={"USD": Decimal("-1.97")}, ), ], - None + None, ), # Stop price should be hit on 2000-03-10 and order should be filled on 2000-03-10. ( lambda e: e.create_stop_limit_order( - OrderOperation.BUY, orcl_pair, Decimal("9"), Decimal("81.62"), - Decimal("81") + OrderOperation.BUY, orcl_pair, Decimal("9"), Decimal("81.62"), Decimal("81") ), [ dict( @@ -211,13 +190,12 @@ fees={"USD": Decimal("-1.83")}, ), ], - None + None, ), # Stop price should be hit on 2000-03-13 and order should be filled on 2000-03-13. ( lambda e: e.create_stop_limit_order( - OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("79"), - Decimal("78.75") + OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("79"), Decimal("78.75") ), [ dict( @@ -226,13 +204,12 @@ fees={"USD": Decimal("-0.20")}, ), ], - None + None, ), # Stop price should be hit on 2000-03-13 and order should be filled on 2000-03-14. ( lambda e: e.create_stop_limit_order( - OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("79"), - Decimal("83.65") + OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("79"), Decimal("83.65") ), [ dict( @@ -241,13 +218,12 @@ fees={"USD": Decimal("-0.21")}, ), ], - None + None, ), # Stop price should be hit on 2000-03-13 and order should be filled on 2000-03-15 at open. ( lambda e: e.create_stop_limit_order( - OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("79"), - Decimal("83.80") + OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("79"), Decimal("83.80") ), [ dict( @@ -256,20 +232,18 @@ fees={"USD": Decimal("-0.21")}, ), ], - None + None, ), ], }, - False + False, ), # Limit order is filled in multiple bars. ( { datetime.date(2001, 1, 3): [ ( - lambda e: e.create_limit_order( - OrderOperation.BUY, aapl_pair, Decimal("50"), Decimal("10") - ), + lambda e: e.create_limit_order(OrderOperation.BUY, aapl_pair, Decimal("50"), Decimal("10")), [ dict( when=datetime.datetime(2001, 1, 3, tzinfo=tz.tzlocal()), @@ -317,33 +291,31 @@ limit_price=Decimal("10"), fill_price=Decimal("5.5"), ), - ] + ], ), ], }, - False + False, ), # Regression test. ( { datetime.date(2000, 1, 4): [ ( - lambda e: e.create_limit_order( - OrderOperation.BUY, orcl_pair, Decimal("8600"), Decimal("115.50") - ), + lambda e: e.create_limit_order(OrderOperation.BUY, orcl_pair, Decimal("8600"), Decimal("115.50")), [ dict( when=datetime.datetime(2000, 1, 4, tzinfo=tz.tzlocal()), balance_updates={"ORCL": Decimal("8600"), "USD": Decimal("-993300.00")}, fees={"USD": Decimal("-2483.25")}, - fill_price=Decimal("115.50") + fill_price=Decimal("115.50"), ), ], - None + None, ), ], }, - False + False, ), # Multiple orders with immediate processing enabled. ( @@ -371,13 +343,11 @@ limit_price=None, fill_price=Decimal("118.12"), ), - ] + ], ), # Limit order gets filled immediately using previous bar's close. ( - lambda e: e.create_limit_order( - OrderOperation.BUY, orcl_pair, Decimal("1"), Decimal("119") - ), + lambda e: e.create_limit_order(OrderOperation.BUY, orcl_pair, Decimal("1"), Decimal("119")), [ dict( when=datetime.datetime(2000, 1, 4, tzinfo=tz.tzlocal()), @@ -398,13 +368,11 @@ limit_price=Decimal("119"), fill_price=Decimal("118.12"), ), - ] + ], ), # Order gets filled on the 23rd bar, not the previous one. ( - lambda e: e.create_limit_order( - OrderOperation.BUY, orcl_pair, Decimal("1"), Decimal("108") - ), + lambda e: e.create_limit_order(OrderOperation.BUY, orcl_pair, Decimal("1"), Decimal("108")), [ dict( when=datetime.datetime(2000, 1, 4, tzinfo=tz.tzlocal()), @@ -421,7 +389,7 @@ amount_remaining=Decimal("1"), quote_amount_filled=Decimal("0"), fees={}, - limit_price=Decimal("108") + limit_price=Decimal("108"), ), dict( is_open=False, @@ -434,15 +402,13 @@ limit_price=Decimal("108"), fill_price=Decimal("108"), ), - ] + ], ), ], datetime.date(2000, 1, 25): [ # Stop order gets filled immediately using previous bar's close. ( - lambda e: e.create_stop_order( - OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("55") - ), + lambda e: e.create_stop_order(OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("55")), [ dict( when=datetime.datetime(2000, 1, 25, tzinfo=tz.tzlocal()), @@ -462,13 +428,11 @@ stop_price=Decimal("55"), fill_price=Decimal("54.19"), ) - ] + ], ), # Stop order gets filled a few bars later. ( - lambda e: e.create_stop_order( - OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("53.99") - ), + lambda e: e.create_stop_order(OrderOperation.SELL, orcl_pair, Decimal("1"), Decimal("53.99")), [ dict( when=datetime.datetime(2000, 1, 27, tzinfo=tz.tzlocal()), @@ -485,7 +449,7 @@ amount_remaining=Decimal("1"), quote_amount_filled=Decimal("0"), fees={}, - stop_price=Decimal("53.99") + stop_price=Decimal("53.99"), ), dict( is_open=False, @@ -497,8 +461,8 @@ fees={"USD": Decimal("0.14")}, stop_price=Decimal("53.99"), fill_price=Decimal("53.99"), - ) - ] + ), + ], ), ], datetime.date(2000, 1, 26): [ @@ -537,8 +501,8 @@ stop_price=Decimal("56"), limit_price=Decimal("55.5"), fill_price=Decimal("55.5"), - ) - ] + ), + ], ), ], datetime.date(2000, 12, 29): [ @@ -555,13 +519,13 @@ amount_remaining=Decimal("1"), quote_amount_filled=Decimal("0"), fees={}, - limit_price=None + limit_price=None, ), - ] + ], ), ], }, - True + True, ), ] @@ -585,31 +549,13 @@ requests.StopOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1.001")), requests.StopOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("-0.1")), # Stop limit order: Invalid amount/price. - requests.StopLimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(0), Decimal("1"), Decimal("1") - ), - requests.StopLimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("0"), Decimal("1") - ), - requests.StopLimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("0.001"), Decimal("1") - ), - requests.StopLimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1.001"), Decimal("1") - ), - requests.StopLimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("-0.1"), Decimal("1") - ), - requests.StopLimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1"), Decimal("0") - ), - requests.StopLimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1"), Decimal("0.001") - ), - requests.StopLimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1"), Decimal("1.001") - ), - requests.StopLimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1"), Decimal("-0.1") - ), + requests.StopLimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(0), Decimal("1"), Decimal("1")), + requests.StopLimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("0"), Decimal("1")), + requests.StopLimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("0.001"), Decimal("1")), + requests.StopLimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1.001"), Decimal("1")), + requests.StopLimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("-0.1"), Decimal("1")), + requests.StopLimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1"), Decimal("0")), + requests.StopLimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1"), Decimal("0.001")), + requests.StopLimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1"), Decimal("1.001")), + requests.StopLimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1"), Decimal("-0.1")), ] diff --git a/tests/data/binance_depth_update.json b/tests/data/binance_depth_update.json index c46f9ef..cd55423 100644 --- a/tests/data/binance_depth_update.json +++ b/tests/data/binance_depth_update.json @@ -1000,4 +1000,4 @@ "0.00087000" ] ] -} \ No newline at end of file +} diff --git a/tests/data/bitstamp_btcusd_day_2015.csv b/tests/data/bitstamp_btcusd_day_2015.csv index 1586ba0..9c8afc4 100644 --- a/tests/data/bitstamp_btcusd_day_2015.csv +++ b/tests/data/bitstamp_btcusd_day_2015.csv @@ -363,4 +363,4 @@ datetime,open,high,low,close,volume 2015-12-28 00:00:00,421.78,429.86,417.01,421.46,7560.56299201 2015-12-29 00:00:00,420.81,433.33,418.55,431.82,10419.58536596 2015-12-30 00:00:00,431.7,434.97,420.75,425.84,7717.51026264 -2015-12-31 00:00:00,426.09,433.89,419.99,430.89,6634.86316708 \ No newline at end of file +2015-12-31 00:00:00,426.09,433.89,419.99,430.89,6634.86316708 diff --git a/tests/fixtures/binance.py b/tests/fixtures/binance.py index 126e54d..a4ef3fa 100644 --- a/tests/fixtures/binance.py +++ b/tests/fixtures/binance.py @@ -30,6 +30,9 @@ def binance_http_api_mock(): @pytest.fixture() def binance_exchange(realtime_dispatcher): return exchange.Exchange( - realtime_dispatcher, "api_key", "api_secret", tb=token_bucket.TokenBucketLimiter(10, 1, 0), - config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}} + realtime_dispatcher, + "api_key", + "api_secret", + tb=token_bucket.TokenBucketLimiter(10, 1, 0), + config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}, ) diff --git a/tests/test_backtesting_account_balances.py b/tests/test_backtesting_account_balances.py index 2866950..4fa60be 100644 --- a/tests/test_backtesting_account_balances.py +++ b/tests/test_backtesting_account_balances.py @@ -77,34 +77,39 @@ def test_invalid_updates_and_holds(): balances.update(borrowed_updates={"BTC": Decimal(-1)}) -@pytest.mark.parametrize("order_fun, checkpoints", [ - ( - lambda e: e.create_market_order(orders.OrderOperation.BUY, Pair("BTC", "USD"), Decimal("1")), - { - dt.local_datetime(2001, 1, 2): { - "BTC": {"available": Decimal("1"), "hold": Decimal("0"), "borrowed": Decimal("0")}, - "USD": {"available": Decimal("999900"), "hold": Decimal("0"), "borrowed": Decimal("0")}, - } - } - ), - ( - lambda e: e.create_limit_order(orders.OrderOperation.BUY, Pair("BTC", "USD"), Decimal("150"), Decimal("100")), - { - dt.local_datetime(2001, 1, 1): { - "BTC": {"available": Decimal("0"), "hold": Decimal("0"), "borrowed": Decimal("0")}, - "USD": {"available": Decimal("1e6"), "hold": Decimal("15000"), "borrowed": Decimal("0")}, +@pytest.mark.parametrize( + "order_fun, checkpoints", + [ + ( + lambda e: e.create_market_order(orders.OrderOperation.BUY, Pair("BTC", "USD"), Decimal("1")), + { + dt.local_datetime(2001, 1, 2): { + "BTC": {"available": Decimal("1"), "hold": Decimal("0"), "borrowed": Decimal("0")}, + "USD": {"available": Decimal("999900"), "hold": Decimal("0"), "borrowed": Decimal("0")}, + } }, - dt.local_datetime(2001, 1, 2): { - "BTC": {"available": Decimal("100"), "hold": Decimal("0"), "borrowed": Decimal("0")}, - "USD": {"available": Decimal("985000"), "hold": Decimal("5000"), "borrowed": Decimal("0")}, + ), + ( + lambda e: e.create_limit_order( + orders.OrderOperation.BUY, Pair("BTC", "USD"), Decimal("150"), Decimal("100") + ), + { + dt.local_datetime(2001, 1, 1): { + "BTC": {"available": Decimal("0"), "hold": Decimal("0"), "borrowed": Decimal("0")}, + "USD": {"available": Decimal("1e6"), "hold": Decimal("15000"), "borrowed": Decimal("0")}, + }, + dt.local_datetime(2001, 1, 2): { + "BTC": {"available": Decimal("100"), "hold": Decimal("0"), "borrowed": Decimal("0")}, + "USD": {"available": Decimal("985000"), "hold": Decimal("5000"), "borrowed": Decimal("0")}, + }, + dt.local_datetime(2001, 1, 3): { + "BTC": {"available": Decimal("150"), "hold": Decimal("0"), "borrowed": Decimal("0")}, + "USD": {"available": Decimal("985000"), "hold": Decimal("0"), "borrowed": Decimal("0")}, + }, }, - dt.local_datetime(2001, 1, 3): { - "BTC": {"available": Decimal("150"), "hold": Decimal("0"), "borrowed": Decimal("0")}, - "USD": {"available": Decimal("985000"), "hold": Decimal("0"), "borrowed": Decimal("0")}, - } - } - ), -]) + ), + ], +) def test_balance_updates_as_orders_get_processed(order_fun, checkpoints, backtesting_dispatcher): pair = Pair("BTC", "USD") e = exchange.Exchange( @@ -127,32 +132,49 @@ async def on_bar(bar_event): async def impl(): # These are for testing scenarios where fills take place in multiple bars. - src = event.FifoQueueEventSource(events=[ - bar.BarEvent( - dt.local_datetime(2001, 1, 2), - bar.Bar( - dt.local_datetime(2001, 1, 1), - pair, Decimal(100), Decimal(100), Decimal(100), Decimal(100), Decimal(100), - datetime.timedelta(days=1) - ) - ), - bar.BarEvent( - dt.local_datetime(2001, 1, 3), - bar.Bar( + src = event.FifoQueueEventSource( + events=[ + bar.BarEvent( dt.local_datetime(2001, 1, 2), - pair, Decimal(100), Decimal(100), Decimal(100), Decimal(100), Decimal(100), - datetime.timedelta(days=1) - ) - ), - bar.BarEvent( - dt.local_datetime(2001, 1, 4), - bar.Bar( + bar.Bar( + dt.local_datetime(2001, 1, 1), + pair, + Decimal(100), + Decimal(100), + Decimal(100), + Decimal(100), + Decimal(100), + datetime.timedelta(days=1), + ), + ), + bar.BarEvent( dt.local_datetime(2001, 1, 3), - pair, Decimal(100), Decimal(100), Decimal(100), Decimal(100), Decimal(100), - datetime.timedelta(days=1) - ) - ), - ]) + bar.Bar( + dt.local_datetime(2001, 1, 2), + pair, + Decimal(100), + Decimal(100), + Decimal(100), + Decimal(100), + Decimal(100), + datetime.timedelta(days=1), + ), + ), + bar.BarEvent( + dt.local_datetime(2001, 1, 4), + bar.Bar( + dt.local_datetime(2001, 1, 3), + pair, + Decimal(100), + Decimal(100), + Decimal(100), + Decimal(100), + Decimal(100), + datetime.timedelta(days=1), + ), + ), + ] + ) e.add_bar_source(src) e.subscribe_to_bar_events(pair, on_bar) await order_fun(e) @@ -165,8 +187,13 @@ async def impl(): def test_account_locked(): class LockAccount(account_balances.UpdateRule): def check( - self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap, - delta_balances: ValueMap, delta_holds: ValueMap, delta_borrowed: ValueMap + self, + updated_balances: ValueMap, + updated_holds: ValueMap, + updated_borrowed: ValueMap, + delta_balances: ValueMap, + delta_holds: ValueMap, + delta_borrowed: ValueMap, ): raise Exception("Account locked") diff --git a/tests/test_backtesting_charts.py b/tests/test_backtesting_charts.py index 6601250..40381bd 100644 --- a/tests/test_backtesting_charts.py +++ b/tests/test_backtesting_charts.py @@ -37,10 +37,14 @@ ], } -@pytest.mark.parametrize("order_plan, candlesticks", [ - (order_plan, True), - (order_plan, False), -]) + +@pytest.mark.parametrize( + "order_plan, candlesticks", + [ + (order_plan, True), + (order_plan, False), + ], +) def test_save_line_chart(order_plan, candlesticks, backtesting_dispatcher, caplog): e = exchange.Exchange( backtesting_dispatcher, @@ -53,9 +57,7 @@ def test_save_line_chart(order_plan, candlesticks, backtesting_dispatcher, caplo line_charts = charts.LineCharts(e) line_charts.add_pair(pair, candlesticks=candlesticks) line_charts.add_balance("USD") - line_charts.add_pair_indicator( - "CONSTANT", pair, charts.DataPointFromSequence([100]), marker={"symbol": "arrow"} - ) + line_charts.add_pair_indicator("CONSTANT", pair, charts.DataPointFromSequence([100]), marker={"symbol": "arrow"}) line_charts.add_portfolio_value("USD") line_charts.add_portfolio_value("INVALID") line_charts.add_custom("CUSTOM", "line_name", lambda _: 3, marker={"symbol": "arrow"}) diff --git a/tests/test_backtesting_dispatcher.py b/tests/test_backtesting_dispatcher.py index 558dd0d..31f476c 100644 --- a/tests/test_backtesting_dispatcher.py +++ b/tests/test_backtesting_dispatcher.py @@ -49,8 +49,10 @@ async def test_main(): handler_count = 100 for i in range(handler_count): + async def handler(e, i=i): priorities.append(i) + backtesting_dispatcher.subscribe(src, handler) await backtesting_dispatcher.run() @@ -87,25 +89,29 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("schedule_dates", [ +@pytest.mark.parametrize( + "schedule_dates", [ - datetime.datetime(2000, 1, 1, 0, 0, 1), + [ + datetime.datetime(2000, 1, 1, 0, 0, 1), + ], + [ + datetime.datetime(1999, 1, 1, 0, 0, 0), + datetime.datetime(2000, 1, 2, 0, 0, 0), + datetime.datetime(2001, 1, 2, 0, 0, 0), + datetime.datetime(2002, 1, 1, 0, 0, 0), + dt.local_now(), + dt.utc_now(), + ], ], - [ - datetime.datetime(1999, 1, 1, 0, 0, 0), - datetime.datetime(2000, 1, 2, 0, 0, 0), - datetime.datetime(2001, 1, 2, 0, 0, 0), - datetime.datetime(2002, 1, 1, 0, 0, 0), - dt.local_now(), - dt.utc_now(), - ], -]) +) def test_backtesting_scheduler(schedule_dates, backtesting_dispatcher): datetimes = [] def scheduled_job_factory(when): async def scheduled_job(): datetimes.append(when) + return scheduled_job async def proces_event(event): @@ -154,11 +160,13 @@ async def scheduler_handler(): raise Exception("Scheduler handler error") async def test_main(): - src = event.FifoQueueEventSource(events=[ - event.Event(dt.utc_now()), - event.Event(dt.utc_now()), - event.Event(dt.utc_now()), - ]) + src = event.FifoQueueEventSource( + events=[ + event.Event(dt.utc_now()), + event.Event(dt.utc_now()), + event.Event(dt.utc_now()), + ] + ) backtesting_dispatcher.subscribe_all(event_handler, front_run=True) backtesting_dispatcher.subscribe(src, event_handler) @@ -187,11 +195,13 @@ async def event_handler_2(event): raise Exception("Event handler error") async def test_main(): - src = event.FifoQueueEventSource(events=[ - event.Event(dt.utc_now()), - event.Event(dt.utc_now()), - event.Event(dt.utc_now()), - ]) + src = event.FifoQueueEventSource( + events=[ + event.Event(dt.utc_now()), + event.Event(dt.utc_now()), + event.Event(dt.utc_now()), + ] + ) backtesting_dispatcher.subscribe(src, event_handler) backtesting_dispatcher.subscribe(src, event_handler_2) @@ -220,11 +230,13 @@ async def scheduler_handler_2(): raise Exception("Scheduler handler error") async def test_main(): - src = event.FifoQueueEventSource(events=[ - event.Event(dt.utc_now()), - event.Event(dt.utc_now()), - event.Event(dt.utc_now()), - ]) + src = event.FifoQueueEventSource( + events=[ + event.Event(dt.utc_now()), + event.Event(dt.utc_now()), + event.Event(dt.utc_now()), + ] + ) backtesting_dispatcher.subscribe(src, event_handler) backtesting_dispatcher.schedule(dt.utc_now(), scheduler_handler) @@ -255,9 +267,11 @@ async def proces_event(event): backtesting_dispatcher.schedule(next_dt, scheduled_job) async def test_main(): - src = event.FifoQueueEventSource(events=[ - event.Event(datetime.datetime(2024, 1, 1).replace(tzinfo=datetime.timezone.utc)), - ]) + src = event.FifoQueueEventSource( + events=[ + event.Event(datetime.datetime(2024, 1, 1).replace(tzinfo=datetime.timezone.utc)), + ] + ) backtesting_dispatcher.subscribe(src, proces_event) await backtesting_dispatcher.run() assert jobs_processed == 1 diff --git a/tests/test_backtesting_exchange.py b/tests/test_backtesting_exchange.py index 2b08e17..7d57969 100644 --- a/tests/test_backtesting_exchange.py +++ b/tests/test_backtesting_exchange.py @@ -35,12 +35,7 @@ def test_account_balances(backtesting_dispatcher): async def impl(): e = exchange.Exchange( - backtesting_dispatcher, - { - "usd": Decimal("1000"), - "ars": Decimal("-2000.05"), - "eth": Decimal("0") - } + backtesting_dispatcher, {"usd": Decimal("1000"), "ars": Decimal("-2000.05"), "eth": Decimal("0")} ) assert (await e.get_balance("usd")).available == Decimal("1000") assert (await e.get_balance("usd")).borrowed == Decimal("0") @@ -58,11 +53,7 @@ def test_exchange_object_container(): idx = bt_helpers.ExchangeObjectContainer[orders.Order]() for i in range(1, 3): - idx.add( - orders.MarketOrder( - str(i), OrderOperation.BUY, btc_pair, btc_pair_info, Decimal("1") - ) - ) + idx.add(orders.MarketOrder(str(i), OrderOperation.BUY, btc_pair, btc_pair_info, Decimal("1"))) assert "1" in [o.id for o in idx.get_open()] idx.get("1").cancel() assert "1" not in [o.id for o in idx.get_open()] @@ -96,7 +87,7 @@ async def impl(): { "IBM": Decimal("0"), "USD": Decimal("1000"), - } + }, ) p = Pair("IBM", "USD") e.add_bar_source(bars.CSVBarSource(p, abs_data_path("orcl-2001-yahoo.csv"), sort=True)) @@ -132,7 +123,7 @@ async def impl(): { "USD": Decimal("1e6"), }, - fee_strategy=fees.Percentage(percentage=Decimal("0.25")) + fee_strategy=fees.Percentage(percentage=Decimal("0.25")), ) pair_info = await e.get_pair_info(Pair("ORCL", "USD")) @@ -152,16 +143,23 @@ def test_bid_ask(backtesting_dispatcher): async def impl(): p = Pair("ORCL", "USD") - bs = event.FifoQueueEventSource(events=[ - bar.BarEvent( - dt.local_datetime(2000, 1, 3, 23, 59, 59), - bar.Bar( - dt.local_datetime(2000, 1, 3), p, - Decimal("124.62"), Decimal("125.19"), Decimal("111.62"), Decimal("118.12"), Decimal("98122000"), - datetime.timedelta(days=1) + bs = event.FifoQueueEventSource( + events=[ + bar.BarEvent( + dt.local_datetime(2000, 1, 3, 23, 59, 59), + bar.Bar( + dt.local_datetime(2000, 1, 3), + p, + Decimal("124.62"), + Decimal("125.19"), + Decimal("111.62"), + Decimal("118.12"), + Decimal("98122000"), + datetime.timedelta(days=1), + ), ) - ) - ]) + ] + ) e.add_bar_source(bs) with pytest.raises(errors.Error, match="No price for"): diff --git a/tests/test_backtesting_exchange_auto_lending.py b/tests/test_backtesting_exchange_auto_lending.py index 3944cd6..317d766 100644 --- a/tests/test_backtesting_exchange_auto_lending.py +++ b/tests/test_backtesting_exchange_auto_lending.py @@ -28,21 +28,15 @@ from basana.core.pair import Pair, PairInfo -def build_bar_source( - pair: Pair, - bar_duration: datetime.timedelta, - prices: List[Tuple[datetime.datetime, Decimal]] -): +def build_bar_source(pair: Pair, bar_duration: datetime.timedelta, prices: List[Tuple[datetime.datetime, Decimal]]): bs = event.FifoQueueEventSource() for bar_begin, price in prices: - bs.push(bar.BarEvent( - bar_begin + bar_duration, - bar.Bar( - bar_begin, pair, - price, price, price, price, Decimal("1e7"), - bar_duration + bs.push( + bar.BarEvent( + bar_begin + bar_duration, + bar.Bar(bar_begin, pair, price, price, price, price, Decimal("1e7"), bar_duration), ) - )) + ) return bs @@ -51,65 +45,62 @@ def build_bar_source( [ # Limit order buy. ( - { - "BTC": Decimal(0), - "USD": Decimal("1000") + Decimal("12.5") + Decimal("1.09") - }, - dt.local_datetime(2000, 1, 2), Decimal(1000), - dt.local_datetime(2000, 1, 3), Decimal(2000), + {"BTC": Decimal(0), "USD": Decimal("1000") + Decimal("12.5") + Decimal("1.09")}, + dt.local_datetime(2000, 1, 2), + Decimal(1000), + dt.local_datetime(2000, 1, 3), + Decimal(2000), Decimal("5"), - { - "BTC": Decimal(0), - "USD": Decimal("1000") + Decimal("5000") - Decimal("25") - Decimal("1.1") - }, + {"BTC": Decimal(0), "USD": Decimal("1000") + Decimal("5000") - Decimal("25") - Decimal("1.1")}, ), # Limit order sell. ( - { - "BTC": Decimal(0), - "USD": Decimal(1000) - }, - dt.local_datetime(2000, 1, 4), Decimal(3000), - dt.local_datetime(2000, 1, 6), Decimal(1000), + {"BTC": Decimal(0), "USD": Decimal(1000)}, + dt.local_datetime(2000, 1, 4), + Decimal(3000), + dt.local_datetime(2000, 1, 6), + Decimal(1000), Decimal("-1"), { "BTC": Decimal(0), - "USD": Decimal("1000") + Decimal("2000") - Decimal("7.5") - Decimal("2.5") - Decimal("1") + "USD": Decimal("1000") + Decimal("2000") - Decimal("7.5") - Decimal("2.5") - Decimal("1"), }, ), # Market order buy. ( - { - "BTC": Decimal(0), - "USD": Decimal("1000") + Decimal("12.5") + Decimal("1.09") - }, - dt.local_datetime(2000, 1, 2), None, - dt.local_datetime(2000, 1, 3), None, + {"BTC": Decimal(0), "USD": Decimal("1000") + Decimal("12.5") + Decimal("1.09")}, + dt.local_datetime(2000, 1, 2), + None, + dt.local_datetime(2000, 1, 3), + None, Decimal("5"), - { - "BTC": Decimal(0), - "USD": Decimal("1000") + Decimal("5000") - Decimal("25") - Decimal("1.1") - }, + {"BTC": Decimal(0), "USD": Decimal("1000") + Decimal("5000") - Decimal("25") - Decimal("1.1")}, ), # Market order sell. ( - { - "BTC": Decimal(0), - "USD": Decimal(1000) - }, - dt.local_datetime(2000, 1, 4), None, - dt.local_datetime(2000, 1, 6), None, + {"BTC": Decimal(0), "USD": Decimal(1000)}, + dt.local_datetime(2000, 1, 4), + None, + dt.local_datetime(2000, 1, 6), + None, Decimal("-1"), { "BTC": Decimal(0), - "USD": Decimal("1000") + Decimal("2000") - Decimal("7.5") - Decimal("2.5") - Decimal("1") + "USD": Decimal("1000") + Decimal("2000") - Decimal("7.5") - Decimal("2.5") - Decimal("1"), }, ), - ] + ], ) def test_entry_and_exit_ok( - initial_balances, entry_dt, entry_limit_price, exit_dt, exit_limit_price, amount, final_balances, - backtesting_dispatcher, caplog + initial_balances, + entry_dt, + entry_limit_price, + exit_dt, + exit_limit_price, + amount, + final_balances, + backtesting_dispatcher, + caplog, ): caplog.set_level(0) pair = Pair("BTC", "USD") @@ -117,16 +108,20 @@ def test_entry_and_exit_ok( exit_order = None # Should be able to open a position up to 5 times our initial balance. lending_strategy = margin.MarginLoans( - "USD", Decimal("0.2"), + "USD", + Decimal("0.2"), default_conditions=margin.MarginLoanConditions( - interest_symbol="USD", interest_percentage=Decimal(10), interest_period=datetime.timedelta(days=365), + interest_symbol="USD", + interest_percentage=Decimal(10), + interest_period=datetime.timedelta(days=365), min_interest=Decimal(1), - ) + ), ) e = exchange.Exchange( - backtesting_dispatcher, initial_balances, + backtesting_dispatcher, + initial_balances, fee_strategy=fees.Percentage(percentage=Decimal("0.25")), - lending_strategy=lending_strategy + lending_strategy=lending_strategy, ) async def enter_position(): @@ -163,7 +158,8 @@ async def impl(): # Load bars bs = build_bar_source( - pair, datetime.timedelta(days=1), + pair, + datetime.timedelta(days=1), [ (dt.local_datetime(2000, 1, 1), Decimal(1000)), (dt.local_datetime(2000, 1, 2), Decimal(1000)), @@ -172,7 +168,7 @@ async def impl(): (dt.local_datetime(2000, 1, 5), Decimal(2000)), (dt.local_datetime(2000, 1, 6), Decimal(1000)), (dt.local_datetime(2000, 1, 7), Decimal(800)), - ] + ], ) e.add_bar_source(bs) await backtesting_dispatcher.run() @@ -208,16 +204,20 @@ def test_rollback_if_borrowing_fails(backtesting_dispatcher, caplog): # Should be able to borrow up to our initial balance. lending_strategy = margin.MarginLoans( - "USD", Decimal("0.5"), + "USD", + Decimal("0.5"), default_conditions=margin.MarginLoanConditions( - interest_symbol="USD", interest_percentage=Decimal(10), interest_period=datetime.timedelta(days=365), - min_interest=Decimal(1) - ) + interest_symbol="USD", + interest_percentage=Decimal(10), + interest_period=datetime.timedelta(days=365), + min_interest=Decimal(1), + ), ) e = exchange.Exchange( - backtesting_dispatcher, {"ETH": Decimal(1)}, + backtesting_dispatcher, + {"ETH": Decimal(1)}, fee_strategy=fees.Percentage(percentage=Decimal("0.25"), min_fee=Decimal("2001")), - lending_strategy=lending_strategy + lending_strategy=lending_strategy, ) async def enter_position(): @@ -245,20 +245,22 @@ async def impl(): # Load BTC bars. e.add_bar_source( build_bar_source( - pair, datetime.timedelta(days=1), + pair, + datetime.timedelta(days=1), [ (dt.local_datetime(2000, 1, 2), Decimal(1000)), (dt.local_datetime(2000, 1, 3), Decimal(1000)), - ] + ], ) ) # This is just to get prices for ETH. e.add_bar_source( build_bar_source( - Pair("ETH", "USD"), datetime.timedelta(days=1), + Pair("ETH", "USD"), + datetime.timedelta(days=1), [ (dt.local_datetime(2000, 1, 2), Decimal(1000)), - ] + ], ) ) @@ -295,16 +297,20 @@ def test_repay_fails(backtesting_dispatcher, caplog): exit_order = None # Should be able to borrow up to our initial balance. lending_strategy = margin.MarginLoans( - "USD", Decimal("0.5"), + "USD", + Decimal("0.5"), default_conditions=margin.MarginLoanConditions( - interest_symbol="USD", interest_percentage=Decimal(10), interest_period=datetime.timedelta(days=365), - min_interest=Decimal(1) - ) + interest_symbol="USD", + interest_percentage=Decimal(10), + interest_period=datetime.timedelta(days=365), + min_interest=Decimal(1), + ), ) e = exchange.Exchange( - backtesting_dispatcher, {"BTC": Decimal(0), "USD": Decimal(1000)}, + backtesting_dispatcher, + {"BTC": Decimal(0), "USD": Decimal(1000)}, fee_strategy=fees.NoFee(), - lending_strategy=lending_strategy + lending_strategy=lending_strategy, ) amount = Decimal("1.8") @@ -332,12 +338,13 @@ async def impl(): # Load bars bs = build_bar_source( - pair, datetime.timedelta(days=1), + pair, + datetime.timedelta(days=1), [ (dt.local_datetime(2000, 1, 1), Decimal(1000)), (dt.local_datetime(2000, 1, 2), Decimal(1000)), (dt.local_datetime(2000, 1, 3), Decimal(10)), - ] + ], ) e.add_bar_source(bs) await backtesting_dispatcher.run() diff --git a/tests/test_backtesting_exchange_loans.py b/tests/test_backtesting_exchange_loans.py index 3f94228..8b2a920 100644 --- a/tests/test_backtesting_exchange_loans.py +++ b/tests/test_backtesting_exchange_loans.py @@ -55,18 +55,17 @@ async def impl(): # Borrow USD using USD as collateral. # Minimum interest charged in USD. ( - Decimal(9600), "USD", Decimal("0.04"), - Decimal("7"), "USD", datetime.timedelta(days=360), Decimal(1), + Decimal(9600), + "USD", + Decimal("0.04"), + Decimal("7"), + "USD", + datetime.timedelta(days=360), + Decimal(1), datetime.timedelta(seconds=0), - { - "USD": Decimal(400) - }, - { - "USD": dict(available=Decimal(10000), borrowed=Decimal(9600)) - }, - { - "USD": dict(available=Decimal(399), borrowed=Decimal(0)) - }, + {"USD": Decimal(400)}, + {"USD": dict(available=Decimal(10000), borrowed=Decimal(9600))}, + {"USD": dict(available=Decimal(399), borrowed=Decimal(0))}, Decimal("99.76"), # Minimum interest accrued in margin level calculation. {"USD": Decimal(1)}, {"USD": Decimal(1)}, @@ -74,19 +73,22 @@ async def impl(): # Borrow BTC using USD as collateral. # No interest. ( - Decimal("0.8"), "BTC", Decimal("0.2"), - Decimal("0"), "USD", datetime.timedelta(seconds=0), Decimal("0"), + Decimal("0.8"), + "BTC", + Decimal("0.2"), + Decimal("0"), + "USD", datetime.timedelta(seconds=0), - { - "BTC": Decimal(0), "USD": Decimal(14000) - }, + Decimal("0"), + datetime.timedelta(seconds=0), + {"BTC": Decimal(0), "USD": Decimal(14000)}, { "BTC": dict(available=Decimal("0.8"), borrowed=Decimal("0.8")), - "USD": dict(available=Decimal(14000), borrowed=Decimal(0)) + "USD": dict(available=Decimal(14000), borrowed=Decimal(0)), }, { "BTC": dict(available=Decimal(0), borrowed=Decimal(0)), - "USD": dict(available=Decimal(14000), borrowed=Decimal(0)) + "USD": dict(available=Decimal(14000), borrowed=Decimal(0)), }, Decimal(100), {}, @@ -94,34 +96,45 @@ async def impl(): ), # Borrow BTC using BTC as collateral, with interest. ( - Decimal("0.9"), "BTC", Decimal("0.1"), - Decimal("10"), "USD", datetime.timedelta(days=360), Decimal("0"), + Decimal("0.9"), + "BTC", + Decimal("0.1"), + Decimal("10"), + "USD", + datetime.timedelta(days=360), + Decimal("0"), datetime.timedelta(days=180), - { - "BTC": Decimal("0.1"), - "USD": Decimal("3150") - }, + {"BTC": Decimal("0.1"), "USD": Decimal("3150")}, { "BTC": dict(available=Decimal("1"), borrowed=Decimal("0.9")), - "USD": dict(available=Decimal("3150"), borrowed=Decimal("0")) + "USD": dict(available=Decimal("3150"), borrowed=Decimal("0")), }, { "BTC": dict(available=Decimal("0.1"), borrowed=Decimal(0)), - "USD": dict(available=Decimal("0"), borrowed=Decimal(0)) + "USD": dict(available=Decimal("0"), borrowed=Decimal(0)), }, Decimal(100), {"USD": Decimal("3150")}, {"USD": Decimal("3150")}, ), - ] + ], ) def test_borrow_and_repay( - loan_amount, loan_symbol, margin_requirement, - interest_percentage, interest_symbol, interest_period, min_interest, - repay_after, - initial_balances, intermediate_balances, final_balances, - intermediate_margin_level, intermediate_interest, paid_interest, - backtesting_dispatcher + loan_amount, + loan_symbol, + margin_requirement, + interest_percentage, + interest_symbol, + interest_period, + min_interest, + repay_after, + initial_balances, + intermediate_balances, + final_balances, + intermediate_margin_level, + intermediate_interest, + paid_interest, + backtesting_dispatcher, ): async def impl(): exchange_rates = { @@ -134,8 +147,10 @@ async def impl(): loan_conditions = { loan_symbol: margin.MarginLoanConditions( - interest_symbol=interest_symbol, interest_percentage=interest_percentage, - interest_period=interest_period, min_interest=min_interest + interest_symbol=interest_symbol, + interest_percentage=interest_percentage, + interest_period=interest_period, + min_interest=min_interest, ), } lending_strategy = margin.MarginLoans("USD", margin_requirement) @@ -151,13 +166,21 @@ async def impl(): # This is necessary to have prices and dates since we're not doing bar events. now = dt.local_now() for pair, exchange_rate in exchange_rates.items(): - e._prices.on_bar_event(BarEvent( - now, - Bar( - now, pair, exchange_rate, exchange_rate, exchange_rate, exchange_rate, Decimal(10), - datetime.timedelta(seconds=1) + e._prices.on_bar_event( + BarEvent( + now, + Bar( + now, + pair, + exchange_rate, + exchange_rate, + exchange_rate, + exchange_rate, + Decimal(10), + datetime.timedelta(seconds=1), + ), ) - )) + ) backtesting_dispatcher._set_now(now) # Create loan @@ -227,8 +250,10 @@ async def impl(): loan_conditions = { "USD": margin.MarginLoanConditions( - interest_symbol="USD", interest_percentage=Decimal("15"), - interest_period=datetime.timedelta(days=365), min_interest=Decimal(0) + interest_symbol="USD", + interest_percentage=Decimal("15"), + interest_period=datetime.timedelta(days=365), + min_interest=Decimal(0), ), } lending_strategy = margin.MarginLoans("USD", Decimal("0.2")) @@ -245,13 +270,21 @@ async def impl(): # This is necessary to have prices since we're not doing bar events. now = dt.local_now() for pair, exchange_rate in exchange_rates.items(): - e._prices.on_bar_event(BarEvent( - now, - Bar( - now, pair, exchange_rate, exchange_rate, exchange_rate, exchange_rate, Decimal(10), - datetime.timedelta(seconds=1) + e._prices.on_bar_event( + BarEvent( + now, + Bar( + now, + pair, + exchange_rate, + exchange_rate, + exchange_rate, + exchange_rate, + Decimal(10), + datetime.timedelta(seconds=1), + ), ) - )) + ) backtesting_dispatcher._set_now(dt.local_now()) # We should be able to borrow up to 4k. @@ -316,11 +349,14 @@ async def impl(): def test_repay_twice(backtesting_dispatcher): async def impl(): lending_strategy = margin.MarginLoans( - "USD", Decimal(0.5), + "USD", + Decimal(0.5), default_conditions=margin.MarginLoanConditions( - interest_symbol="USD", interest_percentage=Decimal(0), interest_period=datetime.timedelta(days=1), - min_interest=Decimal(0) - ) + interest_symbol="USD", + interest_percentage=Decimal(0), + interest_period=datetime.timedelta(days=1), + min_interest=Decimal(0), + ), ) e = exchange.Exchange(backtesting_dispatcher, {"USD": Decimal(100)}, lending_strategy=lending_strategy) e.set_symbol_precision("USD", 2) @@ -339,11 +375,14 @@ async def impl(): def test_not_enough_balance_to_repay(backtesting_dispatcher): async def impl(): lending_strategy = margin.MarginLoans( - "USD", Decimal(0.5), + "USD", + Decimal(0.5), default_conditions=margin.MarginLoanConditions( - interest_symbol="USD", interest_percentage=Decimal(10), interest_period=datetime.timedelta(days=1), - min_interest=Decimal(1) - ) + interest_symbol="USD", + interest_percentage=Decimal(10), + interest_period=datetime.timedelta(days=1), + min_interest=Decimal(1), + ), ) e = exchange.Exchange(backtesting_dispatcher, {"USD": Decimal(10)}, lending_strategy=lending_strategy) e.set_symbol_precision("USD", 2) @@ -364,11 +403,14 @@ async def impl(): def test_cancel_loan(backtesting_dispatcher): async def impl(): lending_strategy = margin.MarginLoans( - "USD", Decimal(0.1), + "USD", + Decimal(0.1), default_conditions=margin.MarginLoanConditions( - interest_symbol="USD", interest_percentage=Decimal(0), interest_period=datetime.timedelta(days=1), - min_interest=Decimal(1) - ) + interest_symbol="USD", + interest_percentage=Decimal(0), + interest_period=datetime.timedelta(days=1), + min_interest=Decimal(1), + ), ) e = exchange.Exchange(backtesting_dispatcher, {"USD": Decimal(1000)}, lending_strategy=lending_strategy) e.set_symbol_precision("USD", 2) diff --git a/tests/test_backtesting_exchange_orders.py b/tests/test_backtesting_exchange_orders.py index 78ce16b..23ce779 100644 --- a/tests/test_backtesting_exchange_orders.py +++ b/tests/test_backtesting_exchange_orders.py @@ -24,8 +24,7 @@ from .helpers import abs_data_path from .common import orcl_pair, aapl_pair, orcl_pair_info, btc_pair, btc_pair_info -from .backtesting_exchange_orders_test_data import \ - test_order_requests_data, test_invalid_requests_data +from .backtesting_exchange_orders_test_data import test_order_requests_data, test_invalid_requests_data from basana.backtesting import errors, exchange, fees, requests from basana.core import bar, dt, event, helpers from basana.core.enums import OrderOperation @@ -41,7 +40,7 @@ def test_create_get_and_cancel_order(backtesting_dispatcher): "BTC": Decimal("2"), "ETH": Decimal("0"), "USD": Decimal("50000"), - } + }, ) order_events = [] bar_events = [] @@ -94,16 +93,23 @@ async def impl(): e.subscribe_to_order_events(on_order_updated) e.add_bar_source( - event.FifoQueueEventSource(events=[ - bar.BarEvent( - dt.local_datetime(2001, 1, 3), - bar.Bar( - dt.local_datetime(2001, 1, 2), btc_pair, - Decimal(5), Decimal(10), Decimal(1), Decimal(5), Decimal(100), - datetime.timedelta(days=1) - ) - ), - ]) + event.FifoQueueEventSource( + events=[ + bar.BarEvent( + dt.local_datetime(2001, 1, 3), + bar.Bar( + dt.local_datetime(2001, 1, 2), + btc_pair, + Decimal(5), + Decimal(10), + Decimal(1), + Decimal(5), + Decimal(100), + datetime.timedelta(days=1), + ), + ), + ] + ) ) await backtesting_dispatcher.run() @@ -117,10 +123,7 @@ async def impl(): def test_order_updates_have_priority(backtesting_dispatcher): - e = exchange.Exchange( - backtesting_dispatcher, - {"USD": Decimal("50000")} - ) + e = exchange.Exchange(backtesting_dispatcher, {"USD": Decimal("50000")}) order_events = [] bar_events = [] all_events = [] @@ -138,16 +141,23 @@ async def impl(): e.subscribe_to_order_events(on_order_updated) e.add_bar_source( - event.FifoQueueEventSource(events=[ - bar.BarEvent( - dt.local_datetime(2001, 1, 3), - bar.Bar( - dt.local_datetime(2001, 1, 2), btc_pair, - Decimal(5), Decimal(10), Decimal(1), Decimal(5), Decimal(100), - datetime.timedelta(days=1) - ) - ), - ]) + event.FifoQueueEventSource( + events=[ + bar.BarEvent( + dt.local_datetime(2001, 1, 3), + bar.Bar( + dt.local_datetime(2001, 1, 2), + btc_pair, + Decimal(5), + Decimal(10), + Decimal(1), + Decimal(5), + Decimal(100), + datetime.timedelta(days=1), + ), + ), + ] + ) ) await e.create_limit_order(OrderOperation.BUY, btc_pair, Decimal(1), Decimal(10)) @@ -179,7 +189,7 @@ async def impl(): "BTC": Decimal("2"), "ETH": Decimal("0"), "USD": Decimal("50000"), - } + }, ) open_orders = await e.get_open_orders() @@ -211,7 +221,7 @@ async def impl(): "BTC": Decimal("0"), "ETH": Decimal("0"), "USD": Decimal("1000"), - } + }, ) with pytest.raises(exchange.Error): await e.cancel_order("1234") @@ -225,7 +235,7 @@ def test_order_requests(order_plan, immediate_order_processing, backtesting_disp backtesting_dispatcher, {"USD": Decimal("1e6")}, fee_strategy=fees.Percentage(percentage=Decimal("0.25")), - immediate_order_processing=immediate_order_processing + immediate_order_processing=immediate_order_processing, ) expected = {} order_events = defaultdict(list) @@ -248,48 +258,70 @@ async def impl(): e.add_bar_source(bars.CSVBarSource(orcl_pair, abs_data_path("orcl-2000-yahoo-sorted.csv"))) # AAPL bars. - src = event.FifoQueueEventSource(events=[ - bar.BarEvent( - dt.local_datetime(2001, 1, 3), - bar.Bar( - dt.local_datetime(2001, 1, 2), aapl_pair, - Decimal(5), Decimal(10), Decimal(1), Decimal(5), Decimal("100"), - datetime.timedelta(days=1) - ) - ), - bar.BarEvent( - dt.local_datetime(2001, 1, 4), - bar.Bar( - dt.local_datetime(2001, 1, 3), aapl_pair, - Decimal(5), Decimal(10), Decimal(1), Decimal(5), Decimal("100"), - datetime.timedelta(days=1) - ) - ), - bar.BarEvent( - dt.local_datetime(2001, 1, 5), - bar.Bar( - dt.local_datetime(2001, 1, 4), aapl_pair, - Decimal(5), Decimal(10), Decimal(1), Decimal(5), Decimal("100"), - datetime.timedelta(days=1) - ) - ), - bar.BarEvent( - dt.local_datetime(2001, 1, 6), - bar.Bar( - dt.local_datetime(2001, 1, 5), aapl_pair, - Decimal(5), Decimal(10), Decimal(1), Decimal(5), Decimal("100"), - datetime.timedelta(days=1) - ) - ), - # bar.BarEvent( - # dt.local_datetime(2001, 1, 7), - # bar.Bar( - # dt.local_datetime(2001, 1, 6), aapl_p, - # Decimal(500), Decimal(1000), Decimal(100), Decimal(500), Decimal("10000"), - # datetime.timedelta(days=1) - # ) - # ), - ]) + src = event.FifoQueueEventSource( + events=[ + bar.BarEvent( + dt.local_datetime(2001, 1, 3), + bar.Bar( + dt.local_datetime(2001, 1, 2), + aapl_pair, + Decimal(5), + Decimal(10), + Decimal(1), + Decimal(5), + Decimal("100"), + datetime.timedelta(days=1), + ), + ), + bar.BarEvent( + dt.local_datetime(2001, 1, 4), + bar.Bar( + dt.local_datetime(2001, 1, 3), + aapl_pair, + Decimal(5), + Decimal(10), + Decimal(1), + Decimal(5), + Decimal("100"), + datetime.timedelta(days=1), + ), + ), + bar.BarEvent( + dt.local_datetime(2001, 1, 5), + bar.Bar( + dt.local_datetime(2001, 1, 4), + aapl_pair, + Decimal(5), + Decimal(10), + Decimal(1), + Decimal(5), + Decimal("100"), + datetime.timedelta(days=1), + ), + ), + bar.BarEvent( + dt.local_datetime(2001, 1, 6), + bar.Bar( + dt.local_datetime(2001, 1, 5), + aapl_pair, + Decimal(5), + Decimal(10), + Decimal(1), + Decimal(5), + Decimal("100"), + datetime.timedelta(days=1), + ), + ), + # bar.BarEvent( + # dt.local_datetime(2001, 1, 7), + # bar.Bar( + # dt.local_datetime(2001, 1, 6), aapl_p, + # Decimal(500), Decimal(1000), Decimal(100), Decimal(500), Decimal("10000"), + # datetime.timedelta(days=1) + # ) + # ), + ] + ) e.add_bar_source(src) e.subscribe_to_bar_events(orcl_pair, on_bar) @@ -336,7 +368,7 @@ def test_invalid_requests(order_request, backtesting_dispatcher): { "USD": Decimal("1e6"), }, - fee_strategy=fees.Percentage(percentage=Decimal("0.25")) + fee_strategy=fees.Percentage(percentage=Decimal("0.25")), ) e.set_pair_info(btc_pair, btc_pair_info) @@ -351,26 +383,27 @@ async def impl(): asyncio.run(impl()) -@pytest.mark.parametrize("order_request", [ - requests.MarketOrder(OrderOperation.SELL, orcl_pair, orcl_pair_info, Decimal(1)), - requests.LimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1000), Decimal("1")), - requests.LimitOrder(OrderOperation.SELL, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1")), - requests.StopOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1000), Decimal("1")), - requests.StopOrder(OrderOperation.SELL, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1")), - requests.StopLimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1000), Decimal("1"), Decimal("1") - ), - requests.StopLimitOrder( - OrderOperation.SELL, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1"), Decimal("1") - ), -]) +@pytest.mark.parametrize( + "order_request", + [ + requests.MarketOrder(OrderOperation.SELL, orcl_pair, orcl_pair_info, Decimal(1)), + requests.LimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1000), Decimal("1")), + requests.LimitOrder(OrderOperation.SELL, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1")), + requests.StopOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1000), Decimal("1")), + requests.StopOrder(OrderOperation.SELL, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1")), + requests.StopLimitOrder( + OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1000), Decimal("1"), Decimal("1") + ), + requests.StopLimitOrder(OrderOperation.SELL, orcl_pair, orcl_pair_info, Decimal(1), Decimal("1"), Decimal("1")), + ], +) def test_not_enough_balance(order_request, backtesting_dispatcher): e = exchange.Exchange( backtesting_dispatcher, { "USD": Decimal("1e3"), }, - fee_strategy=fees.Percentage(percentage=Decimal("0.25")) + fee_strategy=fees.Percentage(percentage=Decimal("0.25")), ) e.set_pair_info(order_request.pair, orcl_pair_info) @@ -388,24 +421,38 @@ async def impl(): def test_small_fill_is_ignored_after_rounding(backtesting_dispatcher): e = exchange.Exchange(backtesting_dispatcher, {"USD": Decimal("1e6")}) - bs = event.FifoQueueEventSource(events=[ - # This one should be ignored since quote amount should be removed after rounding. - bar.BarEvent( - dt.local_datetime(2000, 1, 3, 23, 59, 59), - bar.Bar( - dt.local_datetime(2000, 1, 3), btc_pair, Decimal(1), Decimal(1), Decimal(1), Decimal(1), - Decimal("0.01"), datetime.timedelta(days=1) - ) - ), - # This one should be used during fill. - bar.BarEvent( - dt.local_datetime(2000, 1, 4, 23, 59, 59), - bar.Bar( - dt.local_datetime(2000, 1, 4), btc_pair, Decimal(2), Decimal(2), Decimal(2), Decimal(2), Decimal(10), - datetime.timedelta(days=1) - ) - ) - ]) + bs = event.FifoQueueEventSource( + events=[ + # This one should be ignored since quote amount should be removed after rounding. + bar.BarEvent( + dt.local_datetime(2000, 1, 3, 23, 59, 59), + bar.Bar( + dt.local_datetime(2000, 1, 3), + btc_pair, + Decimal(1), + Decimal(1), + Decimal(1), + Decimal(1), + Decimal("0.01"), + datetime.timedelta(days=1), + ), + ), + # This one should be used during fill. + bar.BarEvent( + dt.local_datetime(2000, 1, 4, 23, 59, 59), + bar.Bar( + dt.local_datetime(2000, 1, 4), + btc_pair, + Decimal(2), + Decimal(2), + Decimal(2), + Decimal(2), + Decimal(10), + datetime.timedelta(days=1), + ), + ), + ] + ) e.add_bar_source(bs) e.set_pair_info(btc_pair, btc_pair_info) @@ -432,34 +479,39 @@ async def impl(): def test_liquidity_is_exhausted_and_order_is_canceled(backtesting_dispatcher): - e = exchange.Exchange( - backtesting_dispatcher, - { - "USD": Decimal("1e12") - } - ) + e = exchange.Exchange(backtesting_dispatcher, {"USD": Decimal("1e12")}) async def impl(): - bs = event.FifoQueueEventSource(events=[ - bar.BarEvent( - dt.local_datetime(2000, 1, 3, 23, 59, 59), - bar.Bar( - dt.local_datetime(2000, 1, 3), orcl_pair, - Decimal("124.62"), Decimal("125.19"), Decimal("111.62"), Decimal("118.12"), - Decimal("98122000"), - datetime.timedelta(days=1) - ) - ), - bar.BarEvent( - dt.local_datetime(2000, 1, 7, 23, 59, 59), - bar.Bar( - dt.local_datetime(2000, 1, 7), orcl_pair, - Decimal("95.00"), Decimal("103.50"), Decimal("93.56"), Decimal("103.37"), - Decimal("91775600"), - datetime.timedelta(days=1) - ) - ), - ]) + bs = event.FifoQueueEventSource( + events=[ + bar.BarEvent( + dt.local_datetime(2000, 1, 3, 23, 59, 59), + bar.Bar( + dt.local_datetime(2000, 1, 3), + orcl_pair, + Decimal("124.62"), + Decimal("125.19"), + Decimal("111.62"), + Decimal("118.12"), + Decimal("98122000"), + datetime.timedelta(days=1), + ), + ), + bar.BarEvent( + dt.local_datetime(2000, 1, 7, 23, 59, 59), + bar.Bar( + dt.local_datetime(2000, 1, 7), + orcl_pair, + Decimal("95.00"), + Decimal("103.50"), + Decimal("93.56"), + Decimal("103.37"), + Decimal("91775600"), + datetime.timedelta(days=1), + ), + ), + ] + ) e.add_bar_source(bs) # Should get filled in the first bar. @@ -495,12 +547,7 @@ async def impl(): def test_balance_is_on_hold_while_order_is_open(backtesting_dispatcher): async def impl(): - e = exchange.Exchange( - backtesting_dispatcher, - { - "USD": Decimal(1000) - } - ) + e = exchange.Exchange(backtesting_dispatcher, {"USD": Decimal(1000)}) created_order_1 = await e.create_order( requests.LimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal(750)) @@ -509,17 +556,17 @@ async def impl(): assert (await e.get_balance("USD")).available == Decimal(250) assert (await e.get_balance("USD")).hold == Decimal(750) - created_order_2 = await e.create_order(requests.LimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal(200) - )) + created_order_2 = await e.create_order( + requests.LimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal(1), Decimal(200)) + ) assert (await e.get_balance("ORCL")).available == Decimal(0) assert (await e.get_balance("USD")).available == Decimal(50) assert (await e.get_balance("USD")).hold == Decimal(950) with pytest.raises(exchange.Error): - await e.create_order(requests.LimitOrder( - OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal("1"), Decimal(760) - )) + await e.create_order( + requests.LimitOrder(OrderOperation.BUY, orcl_pair, orcl_pair_info, Decimal("1"), Decimal(760)) + ) await e.cancel_order(created_order_2.id) assert (await e.get_balance("ORCL")).available == Decimal(0) diff --git a/tests/test_backtesting_fees.py b/tests/test_backtesting_fees.py index 24c7a2a..c7935f5 100644 --- a/tests/test_backtesting_fees.py +++ b/tests/test_backtesting_fees.py @@ -32,9 +32,7 @@ def test_percentage_fee_with_partial_fills(): "USD": Decimal("-0.9"), } assert fee_strategy.calculate_fees(order, balance_updates) == {"USD": Decimal("-0.009")} - order.add_fill( - Fill(dt.utc_now(), balance_updates, {"USD": Decimal("-0.01")}, Decimal(9)) - ) + order.add_fill(Fill(dt.utc_now(), balance_updates, {"USD": Decimal("-0.01")}, Decimal(9))) # Fill #2 - A 0.008 fee gets rounded to 0.01 balance_updates = { @@ -42,9 +40,7 @@ def test_percentage_fee_with_partial_fills(): "USD": Decimal("-0.9"), } assert fee_strategy.calculate_fees(order, balance_updates) == {"USD": Decimal("-0.008")} - order.add_fill( - Fill(dt.utc_now(), balance_updates, {"USD": Decimal("-0.01")}, Decimal(9)) - ) + order.add_fill(Fill(dt.utc_now(), balance_updates, {"USD": Decimal("-0.01")}, Decimal(9))) # Fill #3 - Final fill. Total fees, prior to rounding, should be 0.118, but we charged 0.02 already, so the last # chunk, prior to rounding, should be 0.098. @@ -53,9 +49,7 @@ def test_percentage_fee_with_partial_fills(): "USD": Decimal("-10"), } assert fee_strategy.calculate_fees(order, balance_updates) == {"USD": Decimal("-0.098")} - order.add_fill( - Fill(dt.utc_now(), balance_updates, {"USD": Decimal("-0.1")}, Decimal(9)) - ) + order.add_fill(Fill(dt.utc_now(), balance_updates, {"USD": Decimal("-0.1")}, Decimal(9))) def test_percentage_fee_with_minium(): diff --git a/tests/test_backtesting_liquidity.py b/tests/test_backtesting_liquidity.py index 92c8600..88764cf 100644 --- a/tests/test_backtesting_liquidity.py +++ b/tests/test_backtesting_liquidity.py @@ -27,9 +27,14 @@ def test_infinite_liquidity(): strat = liquidity.InfiniteLiquidity() strat.on_bar( bar.Bar( - dt.utc_now(), pair.Pair("BTC", "USD"), - Decimal("50000"), Decimal("70000"), Decimal("49900"), Decimal("69999.07"), Decimal("0.00000001"), - datetime.timedelta(seconds=1) + dt.utc_now(), + pair.Pair("BTC", "USD"), + Decimal("50000"), + Decimal("70000"), + Decimal("49900"), + Decimal("69999.07"), + Decimal("0.00000001"), + datetime.timedelta(seconds=1), ) ) @@ -49,9 +54,14 @@ def test_volume_share_impact(): strat = liquidity.VolumeShareImpact() strat.on_bar( bar.Bar( - dt.utc_now(), pair.Pair("BTC", "USD"), - Decimal("50000"), Decimal("70000"), Decimal("49900"), Decimal("69999.07"), Decimal("10000"), - datetime.timedelta(seconds=1) + dt.utc_now(), + pair.Pair("BTC", "USD"), + Decimal("50000"), + Decimal("70000"), + Decimal("49900"), + Decimal("69999.07"), + Decimal("10000"), + datetime.timedelta(seconds=1), ) ) @@ -82,9 +92,14 @@ def test_volume_share_impact_without_liquidity(): strat = liquidity.VolumeShareImpact() strat.on_bar( bar.Bar( - dt.utc_now(), pair.Pair("BTC", "USD"), - Decimal("50000"), Decimal("70000"), Decimal("49900"), Decimal("69999.07"), Decimal(0), - datetime.timedelta(seconds=1) + dt.utc_now(), + pair.Pair("BTC", "USD"), + Decimal("50000"), + Decimal("70000"), + Decimal("49900"), + Decimal("69999.07"), + Decimal(0), + datetime.timedelta(seconds=1), ) ) @@ -106,9 +121,14 @@ def test_volume_share_impact_with_zero_price_impact(): strat = liquidity.VolumeShareImpact(price_impact=Decimal(0)) strat.on_bar( bar.Bar( - dt.utc_now(), pair.Pair("BTC", "USD"), - Decimal("50000"), Decimal("70000"), Decimal("49900"), Decimal("69999.07"), Decimal("100"), - datetime.timedelta(seconds=1) + dt.utc_now(), + pair.Pair("BTC", "USD"), + Decimal("50000"), + Decimal("70000"), + Decimal("49900"), + Decimal("69999.07"), + Decimal("100"), + datetime.timedelta(seconds=1), ) ) diff --git a/tests/test_backtesting_margin.py b/tests/test_backtesting_margin.py index 9a50b13..6cf35fc 100644 --- a/tests/test_backtesting_margin.py +++ b/tests/test_backtesting_margin.py @@ -40,28 +40,43 @@ ), [ ( - "USDT", Decimal("1000"), datetime.timedelta(days=0), Decimal("1"), + "USDT", + Decimal("1000"), + datetime.timedelta(days=0), + Decimal("1"), {"USDT": Decimal("1000")}, - "USDT", Decimal("0"), datetime.timedelta(days=30), Decimal("0"), Decimal("0.5"), - - Decimal("100"), Decimal("0") + "USDT", + Decimal("0"), + datetime.timedelta(days=30), + Decimal("0"), + Decimal("0.5"), + Decimal("100"), + Decimal("0"), ) - ] + ], ) def test_loans( - borrowed_symbol, borrowed_amount, loan_age, current_price, initial_balances, - interest_symbol, interest_percentage, interest_period, min_interest, margin_requirement, - expected_margin_level, expected_interest + borrowed_symbol, + borrowed_amount, + loan_age, + current_price, + initial_balances, + interest_symbol, + interest_percentage, + interest_period, + min_interest, + margin_requirement, + expected_margin_level, + expected_interest, ): quote_symbol = "USDT" # Setup config and prices config = Config( - default_symbol_info=SymbolInfo(precision=2), - default_pair_info=PairInfo(base_precision=2, quote_precision=2) + default_symbol_info=SymbolInfo(precision=2), default_pair_info=PairInfo(base_precision=2, quote_precision=2) ) pair = Pair(borrowed_symbol, quote_symbol) - prices = Prices(bid_ask_spread_pct=Decimal('0.01'), config=config) + prices = Prices(bid_ask_spread_pct=Decimal("0.01"), config=config) # Set a bar for price conversion bar = Bar( @@ -71,8 +86,8 @@ def test_loans( high=current_price, low=current_price, close=current_price, - volume=Decimal('1000'), - duration=datetime.timedelta(days=1) + volume=Decimal("1000"), + duration=datetime.timedelta(days=1), ) prices.on_bar_event(BarEvent(bar.datetime, bar)) @@ -85,10 +100,7 @@ def test_loans( # Setup exchange context exchange_ctx = ExchangeContext( - dispatcher=dispatcher, - account_balances=account_balances, - prices=prices, - config=config + dispatcher=dispatcher, account_balances=account_balances, prices=prices, config=config ) # Setup margin loan conditions and strategy @@ -96,7 +108,7 @@ def test_loans( interest_symbol=interest_symbol, interest_percentage=interest_percentage, interest_period=interest_period, - min_interest=min_interest + min_interest=min_interest, ) margin_loans = MarginLoans(quote_symbol, margin_requirement) margin_loans.set_conditions(borrowed_symbol, loan_conditions) @@ -116,10 +128,10 @@ def test_loans( if loan.id == loan_info.id: loan_obj = loan break - assert loan_obj is not None, 'Loan object not found' + assert loan_obj is not None, "Loan object not found" interest = loan_obj.calculate_interest(bar.datetime + loan_age, prices) # If interest is a dict, get the value for interest_symbol - interest_value = interest.get(interest_symbol, Decimal('0')) + interest_value = interest.get(interest_symbol, Decimal("0")) assert margin_level == expected_margin_level assert interest_value == expected_interest diff --git a/tests/test_backtesting_orders.py b/tests/test_backtesting_orders.py index 9be426f..0f129bd 100644 --- a/tests/test_backtesting_orders.py +++ b/tests/test_backtesting_orders.py @@ -21,8 +21,7 @@ import pytest from basana.backtesting import exchange, liquidity, value_map, order_mgr -from basana.backtesting.orders import OrderOperation, MarketOrder, LimitOrder, StopOrder, \ - StopLimitOrder +from basana.backtesting.orders import OrderOperation, MarketOrder, LimitOrder, StopOrder, StopLimitOrder from basana.core import bar, dt from basana.core.pair import Pair, PairInfo @@ -32,278 +31,287 @@ pair_info = PairInfo(8, 2) -@pytest.mark.parametrize("order, ohlcv, expected_balance_updates", [ - # Buy market - ( - MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("1"), - quote_currency: Decimal("-40001.76"), - } - ), - # Buy market. Rounding should take place. - ( - MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1.61")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("1.61"), - quote_currency: Decimal("-64402.83"), - } - ), - ( - MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1.61")), - (Decimal("3.14"), Decimal("3.14"), Decimal("3.14"), Decimal("3.14"), Decimal("1")), - { - base_currency: Decimal("1.61"), - quote_currency: Decimal("-5.06"), - } - ), - # Buy limit - ( - LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("39000.01")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("1"), - quote_currency: Decimal("-39000.01"), - } - ), - ( - LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("4")), - (Decimal("3.14"), Decimal("3.14"), Decimal("3.14"), Decimal("3.14"), Decimal("1")), - { - base_currency: Decimal("1"), - quote_currency: Decimal("-3.14"), - } - ), - # Buy limit uses open price which is better. - ( - LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("42000")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("2"), - quote_currency: Decimal("-80003.52"), - } - ), - # Buy limit uses open price which is better. Rounding should take place - ( - LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("3.8"), Decimal("42000")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("3.8"), - quote_currency: Decimal("-152006.69"), - } - ), - # Buy limit price not hit. - ( - LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("29000")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - {} - ), - ( - LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("50")), - (Decimal("100"), Decimal("100"), Decimal("100"), Decimal("100"), Decimal("500")), - {} - ), - # Buy stop not hit. - ( - StopOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("50401.02")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - {} - ), - ( - StopOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("100.01")), - (Decimal("100"), Decimal("100"), Decimal("100"), Decimal("100"), Decimal("500")), - {} - ), - # Buy stop hit on open. - ( - StopOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("30000.01")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("1"), - quote_currency: Decimal("-40001.76"), - } - ), - ( - StopOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("99.99")), - (Decimal("100"), Decimal("100"), Decimal("100"), Decimal("100"), Decimal("500")), - { - base_currency: Decimal("1"), - quote_currency: Decimal("-100"), - } - ), - # Buy stop hit after open. - ( - StopOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("40001.77")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("1"), - quote_currency: Decimal("-40001.77"), - } - ), - # Buy stop limit not hit. - ( - StopLimitOrder( - uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("50401.02"), Decimal("60000") +@pytest.mark.parametrize( + "order, ohlcv, expected_balance_updates", + [ + # Buy market + ( + MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("1"), + quote_currency: Decimal("-40001.76"), + }, ), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - {} - ), - ( - StopLimitOrder( - uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("50401.02"), Decimal("60000") + # Buy market. Rounding should take place. + ( + MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1.61")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("1.61"), + quote_currency: Decimal("-64402.83"), + }, ), - (Decimal("50401.01"), Decimal("50401.01"), Decimal("50401.01"), Decimal("50401.01"), Decimal("10")), - {} - ), - # Buy stop limit not hit. - ( - StopLimitOrder( - uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("20000"), Decimal("21000") + ( + MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1.61")), + (Decimal("3.14"), Decimal("3.14"), Decimal("3.14"), Decimal("3.14"), Decimal("1")), + { + base_currency: Decimal("1.61"), + quote_currency: Decimal("-5.06"), + }, ), - (Decimal("21100"), Decimal("21900"), Decimal("21000.01"), Decimal("21000.01"), Decimal("100")), - {} - ), - ( - StopLimitOrder( - uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("20000"), Decimal("21000") + # Buy limit + ( + LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("39000.01")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("1"), + quote_currency: Decimal("-39000.01"), + }, ), - (Decimal("25000"), Decimal("25000"), Decimal("25000"), Decimal("25000"), Decimal("100")), - {} - ), - # Buy stop limit hit on open. - ( - StopLimitOrder( - uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("40000"), Decimal("42000") - ), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("1"), - quote_currency: Decimal("-40001.76"), - } - ), - # Buy stop limit hit after open. - ( - StopLimitOrder( - uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("41000"), Decimal("42000") - ), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("2"), - quote_currency: Decimal("-84000"), - } - ), - ( - StopLimitOrder( - uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("41000"), Decimal("42000") - ), - (Decimal("41900"), Decimal("41900"), Decimal("41900"), Decimal("41900"), Decimal("100")), - { - base_currency: Decimal("1"), - quote_currency: Decimal("-41900"), - } - ), - # Sell market - ( - MarketOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("-1"), - quote_currency: Decimal("40001.76"), - } - ), - # Sell limit - ( - LimitOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("41200.02")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("-1"), - quote_currency: Decimal("41200.02"), - } - ), - # Sell limit uses open price which is better. - ( - LimitOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("2"), Decimal("39000")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("-2"), - quote_currency: Decimal("80003.52"), - } - ), - # Sell limit price not hit. - ( - LimitOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("2"), Decimal("50401.02")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - {} - ), - # Sell stop not hit. - ( - StopOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("2"), Decimal("29999.99")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - {} - ), - # Sell stop hit on open. - ( - StopOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("40002")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("-1"), - quote_currency: Decimal("40001.76"), - } - ), - # Sell stop hit on open. Rounding should take place - ( - StopOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("3.6"), Decimal("40002")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("-3.6"), - quote_currency: Decimal("144006.34"), - } - ), - # Sell stop hit after open. - ( - StopOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("35000")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("-1"), - quote_currency: Decimal("35000"), - } - ), - # Sell stop limit hit on open. - ( - StopLimitOrder( - uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("41000"), Decimal("40000") - ), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("-1"), - quote_currency: Decimal("40001.76"), - } - ), - # Sell stop limit hit after open. - ( - StopLimitOrder( - uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("35000"), Decimal("41000") - ), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("-1"), - quote_currency: Decimal("41000"), - } - ), - # Sell stop limit hit after open. Rounding should take place. - ( - StopLimitOrder( - uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1.07"), Decimal("35000"), Decimal("41000.09") - ), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("-1.07"), - quote_currency: Decimal("43870.10"), - } - ), -]) + ( + LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("4")), + (Decimal("3.14"), Decimal("3.14"), Decimal("3.14"), Decimal("3.14"), Decimal("1")), + { + base_currency: Decimal("1"), + quote_currency: Decimal("-3.14"), + }, + ), + # Buy limit uses open price which is better. + ( + LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("42000")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("2"), + quote_currency: Decimal("-80003.52"), + }, + ), + # Buy limit uses open price which is better. Rounding should take place + ( + LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("3.8"), Decimal("42000")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("3.8"), + quote_currency: Decimal("-152006.69"), + }, + ), + # Buy limit price not hit. + ( + LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("29000")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + {}, + ), + ( + LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("50")), + (Decimal("100"), Decimal("100"), Decimal("100"), Decimal("100"), Decimal("500")), + {}, + ), + # Buy stop not hit. + ( + StopOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("50401.02")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + {}, + ), + ( + StopOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("100.01")), + (Decimal("100"), Decimal("100"), Decimal("100"), Decimal("100"), Decimal("500")), + {}, + ), + # Buy stop hit on open. + ( + StopOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("30000.01")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("1"), + quote_currency: Decimal("-40001.76"), + }, + ), + ( + StopOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("99.99")), + (Decimal("100"), Decimal("100"), Decimal("100"), Decimal("100"), Decimal("500")), + { + base_currency: Decimal("1"), + quote_currency: Decimal("-100"), + }, + ), + # Buy stop hit after open. + ( + StopOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("40001.77")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("1"), + quote_currency: Decimal("-40001.77"), + }, + ), + # Buy stop limit not hit. + ( + StopLimitOrder( + uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("50401.02"), Decimal("60000") + ), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + {}, + ), + ( + StopLimitOrder( + uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("50401.02"), Decimal("60000") + ), + (Decimal("50401.01"), Decimal("50401.01"), Decimal("50401.01"), Decimal("50401.01"), Decimal("10")), + {}, + ), + # Buy stop limit not hit. + ( + StopLimitOrder( + uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("20000"), Decimal("21000") + ), + (Decimal("21100"), Decimal("21900"), Decimal("21000.01"), Decimal("21000.01"), Decimal("100")), + {}, + ), + ( + StopLimitOrder( + uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("20000"), Decimal("21000") + ), + (Decimal("25000"), Decimal("25000"), Decimal("25000"), Decimal("25000"), Decimal("100")), + {}, + ), + # Buy stop limit hit on open. + ( + StopLimitOrder( + uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("40000"), Decimal("42000") + ), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("1"), + quote_currency: Decimal("-40001.76"), + }, + ), + # Buy stop limit hit after open. + ( + StopLimitOrder( + uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2"), Decimal("41000"), Decimal("42000") + ), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("2"), + quote_currency: Decimal("-84000"), + }, + ), + ( + StopLimitOrder( + uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("41000"), Decimal("42000") + ), + (Decimal("41900"), Decimal("41900"), Decimal("41900"), Decimal("41900"), Decimal("100")), + { + base_currency: Decimal("1"), + quote_currency: Decimal("-41900"), + }, + ), + # Sell market + ( + MarketOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("-1"), + quote_currency: Decimal("40001.76"), + }, + ), + # Sell limit + ( + LimitOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("41200.02")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("-1"), + quote_currency: Decimal("41200.02"), + }, + ), + # Sell limit uses open price which is better. + ( + LimitOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("2"), Decimal("39000")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("-2"), + quote_currency: Decimal("80003.52"), + }, + ), + # Sell limit price not hit. + ( + LimitOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("2"), Decimal("50401.02")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + {}, + ), + # Sell stop not hit. + ( + StopOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("2"), Decimal("29999.99")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + {}, + ), + # Sell stop hit on open. + ( + StopOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("40002")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("-1"), + quote_currency: Decimal("40001.76"), + }, + ), + # Sell stop hit on open. Rounding should take place + ( + StopOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("3.6"), Decimal("40002")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("-3.6"), + quote_currency: Decimal("144006.34"), + }, + ), + # Sell stop hit after open. + ( + StopOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("35000")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("-1"), + quote_currency: Decimal("35000"), + }, + ), + # Sell stop limit hit on open. + ( + StopLimitOrder( + uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("41000"), Decimal("40000") + ), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("-1"), + quote_currency: Decimal("40001.76"), + }, + ), + # Sell stop limit hit after open. + ( + StopLimitOrder( + uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("35000"), Decimal("41000") + ), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("-1"), + quote_currency: Decimal("41000"), + }, + ), + # Sell stop limit hit after open. Rounding should take place. + ( + StopLimitOrder( + uuid4().hex, + OrderOperation.SELL, + pair, + pair_info, + Decimal("1.07"), + Decimal("35000"), + Decimal("41000.09"), + ), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("-1.07"), + quote_currency: Decimal("43870.10"), + }, + ), + ], +) def test_try_fill_with_infinite_liquidity(order, ohlcv, expected_balance_updates, backtesting_dispatcher): e = exchange.Exchange(backtesting_dispatcher, {}) # Just for rounding purposes e.set_pair_info(pair, PairInfo(8, 2)) @@ -315,68 +323,66 @@ def test_try_fill_with_infinite_liquidity(order, ohlcv, expected_balance_updates assert balance_updates == expected_balance_updates -@pytest.mark.parametrize("order, ohlcv, expected_balance_updates", [ - # Buy market but there is not enough volume. - ( - MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2000")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - {} - ), - ( - MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("10")), - (Decimal("1000"), Decimal("1000"), Decimal("1000"), Decimal("1000"), Decimal("1")), - {} - ), - # Buy market but there is not enough volume. 250.075 should be available, which should get truncated to 250.07. - ( - MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("250.08")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - {} - ), - # Buy market with slippage. - ( - MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("10")), - (Decimal("50000"), Decimal("51000.01"), Decimal("49000"), Decimal("50500"), Decimal("40")), - { - base_currency: Decimal("10"), - quote_currency: Decimal("-550000"), - } - ), - ( - MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("10")), - (Decimal("50000"), Decimal("50000"), Decimal("50000"), Decimal("50000"), Decimal("40")), - { - base_currency: Decimal("10"), - quote_currency: Decimal("-550000"), - } - ), - # Sell market. Rounding takes place. 250.075 should be available, which should get truncated to 250.07. - ( - MarketOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("250.07")), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - { - base_currency: Decimal("-250.07000000"), - quote_currency: Decimal("9002955.12"), - } - ), - # Sell stop but there is not enough volume. - ( - StopOrder( - uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1004"), Decimal("35000") - ), - (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), - {} - ), -]) +@pytest.mark.parametrize( + "order, ohlcv, expected_balance_updates", + [ + # Buy market but there is not enough volume. + ( + MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("2000")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + {}, + ), + ( + MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("10")), + (Decimal("1000"), Decimal("1000"), Decimal("1000"), Decimal("1000"), Decimal("1")), + {}, + ), + # Buy market but there is not enough volume. 250.075 should be available, which should get truncated to 250.07. + ( + MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("250.08")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + {}, + ), + # Buy market with slippage. + ( + MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("10")), + (Decimal("50000"), Decimal("51000.01"), Decimal("49000"), Decimal("50500"), Decimal("40")), + { + base_currency: Decimal("10"), + quote_currency: Decimal("-550000"), + }, + ), + ( + MarketOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("10")), + (Decimal("50000"), Decimal("50000"), Decimal("50000"), Decimal("50000"), Decimal("40")), + { + base_currency: Decimal("10"), + quote_currency: Decimal("-550000"), + }, + ), + # Sell market. Rounding takes place. 250.075 should be available, which should get truncated to 250.07. + ( + MarketOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("250.07")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + { + base_currency: Decimal("-250.07000000"), + quote_currency: Decimal("9002955.12"), + }, + ), + # Sell stop but there is not enough volume. + ( + StopOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1004"), Decimal("35000")), + (Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("1000.3")), + {}, + ), + ], +) def test_try_fill_with_finite_liquidity(order, ohlcv, expected_balance_updates, backtesting_dispatcher): e = exchange.Exchange(backtesting_dispatcher, {}) # Just for rounding purposes e.set_pair_info(pair, PairInfo(8, 2)) ls = liquidity.VolumeShareImpact() - b = bar.Bar( - dt.local_now(), pair, *ohlcv, - datetime.timedelta(seconds=1) - ) + b = bar.Bar(dt.local_now(), pair, *ohlcv, datetime.timedelta(seconds=1)) ls.on_bar(b) fill = order.try_fill(b, ls) @@ -385,27 +391,30 @@ def test_try_fill_with_finite_liquidity(order, ohlcv, expected_balance_updates, assert balance_updates == expected_balance_updates -@pytest.mark.parametrize("order", [ - LimitOrder( - uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("39000.01") - ), - LimitOrder( - uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("49000.01") - ), - StopLimitOrder( - uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("39000.01"), - Decimal("39000.01") - ), -]) +@pytest.mark.parametrize( + "order", + [ + LimitOrder(uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("39000.01")), + LimitOrder(uuid4().hex, OrderOperation.BUY, pair, pair_info, Decimal("1"), Decimal("49000.01")), + StopLimitOrder( + uuid4().hex, OrderOperation.SELL, pair, pair_info, Decimal("1"), Decimal("39000.01"), Decimal("39000.01") + ), + ], +) def test_no_liquidity_calculating_balance_updates(order, backtesting_dispatcher): e = exchange.Exchange(backtesting_dispatcher, {}) # Just for rounding purposes e.set_pair_info(pair, PairInfo(8, 2)) ls = liquidity.VolumeShareImpact() b = bar.Bar( - dt.local_now(), pair, - Decimal("40001.76"), Decimal("50401.01"), Decimal("30000"), Decimal("45157.09"), Decimal("0"), - datetime.timedelta(seconds=1) + dt.local_now(), + pair, + Decimal("40001.76"), + Decimal("50401.01"), + Decimal("30000"), + Decimal("45157.09"), + Decimal("0"), + datetime.timedelta(seconds=1), ) ls.on_bar(b) diff --git a/tests/test_backtesting_prices.py b/tests/test_backtesting_prices.py index 087d665..b6816c6 100644 --- a/tests/test_backtesting_prices.py +++ b/tests/test_backtesting_prices.py @@ -48,9 +48,15 @@ def test_prices(): BarEvent( now, Bar( - now, pair, Decimal(10), Decimal(10), Decimal(10), Decimal(10), Decimal(10), - datetime.timedelta(seconds=1) - ) + now, + pair, + Decimal(10), + Decimal(10), + Decimal(10), + Decimal(10), + Decimal(10), + datetime.timedelta(seconds=1), + ), ) ) @@ -79,34 +85,52 @@ def test_convert_value_map(): BarEvent( now, Bar( - now, btc_usdt, Decimal(50000), Decimal(50000), Decimal(50000), Decimal(50000), Decimal(50000), - datetime.timedelta(seconds=1) - ) + now, + btc_usdt, + Decimal(50000), + Decimal(50000), + Decimal(50000), + Decimal(50000), + Decimal(50000), + datetime.timedelta(seconds=1), + ), ) ) p.on_bar_event( BarEvent( now, Bar( - now, eth_usdt, Decimal(3000), Decimal(3000), Decimal(3000), Decimal(3000), Decimal(3000), - datetime.timedelta(seconds=1) - ) + now, + eth_usdt, + Decimal(3000), + Decimal(3000), + Decimal(3000), + Decimal(3000), + Decimal(3000), + datetime.timedelta(seconds=1), + ), ) ) p.on_bar_event( BarEvent( now, Bar( - now, eth_btc, Decimal("0.06"), Decimal("0.06"), Decimal("0.06"), Decimal("0.06"), Decimal("0.06"), - datetime.timedelta(seconds=1) - ) + now, + eth_btc, + Decimal("0.06"), + Decimal("0.06"), + Decimal("0.06"), + Decimal("0.06"), + Decimal("0.06"), + datetime.timedelta(seconds=1), + ), ) ) # Test converting a value map to USDT values = { - "BTC": Decimal("2.0"), # 2 BTC - "ETH": Decimal("10.0"), # 10 ETH + "BTC": Decimal("2.0"), # 2 BTC + "ETH": Decimal("10.0"), # 10 ETH "USDT": Decimal("1000.0"), # 1000 USDT } diff --git a/tests/test_backtesting_value_map.py b/tests/test_backtesting_value_map.py index cad3ad6..e2acf4b 100644 --- a/tests/test_backtesting_value_map.py +++ b/tests/test_backtesting_value_map.py @@ -22,14 +22,17 @@ from basana.backtesting.value_map import ValueMap -@pytest.mark.parametrize("lhs, rhs, expected_result", [ - ({}, {}, {}), - ( - {"BTC": Decimal("1.1"), "USD": Decimal("1")}, - {"BTC": Decimal("1.1"), "ETH": Decimal("3")}, - {"BTC": Decimal("2.2"), "USD": Decimal("1"), "ETH": Decimal("3")}, - ), -]) +@pytest.mark.parametrize( + "lhs, rhs, expected_result", + [ + ({}, {}, {}), + ( + {"BTC": Decimal("1.1"), "USD": Decimal("1")}, + {"BTC": Decimal("1.1"), "ETH": Decimal("3")}, + {"BTC": Decimal("2.2"), "USD": Decimal("1"), "ETH": Decimal("3")}, + ), + ], +) def test_add(lhs, rhs, expected_result): assert (ValueMap(lhs) + rhs) == expected_result assert (lhs + ValueMap(rhs)) == expected_result @@ -39,14 +42,17 @@ def test_add(lhs, rhs, expected_result): assert res == expected_result -@pytest.mark.parametrize("lhs, rhs, expected_result", [ - ({}, {}, {}), - ( - {"BTC": Decimal("1.1"), "USD": Decimal("1")}, - {"BTC": Decimal("1.1"), "ETH": Decimal("3")}, - {"BTC": Decimal("0"), "USD": Decimal("1"), "ETH": Decimal("-3")}, - ), -]) +@pytest.mark.parametrize( + "lhs, rhs, expected_result", + [ + ({}, {}, {}), + ( + {"BTC": Decimal("1.1"), "USD": Decimal("1")}, + {"BTC": Decimal("1.1"), "ETH": Decimal("3")}, + {"BTC": Decimal("0"), "USD": Decimal("1"), "ETH": Decimal("-3")}, + ), + ], +) def test_sub(lhs, rhs, expected_result): assert (ValueMap(lhs) - rhs) == expected_result assert (lhs - ValueMap(rhs)) == expected_result @@ -56,14 +62,17 @@ def test_sub(lhs, rhs, expected_result): assert res == expected_result -@pytest.mark.parametrize("lhs, rhs, expected_result", [ - ({}, {}, {}), - ( - {"BTC": Decimal("-1.1"), "USD": Decimal("1")}, - {"BTC": Decimal("3"), "ETH": Decimal("3")}, - {"BTC": Decimal("-3.3"), "USD": Decimal("0"), "ETH": Decimal("0")}, - ), -]) +@pytest.mark.parametrize( + "lhs, rhs, expected_result", + [ + ({}, {}, {}), + ( + {"BTC": Decimal("-1.1"), "USD": Decimal("1")}, + {"BTC": Decimal("3"), "ETH": Decimal("3")}, + {"BTC": Decimal("-3.3"), "USD": Decimal("0"), "ETH": Decimal("0")}, + ), + ], +) def test_mul(lhs, rhs, expected_result): assert (ValueMap(lhs) * rhs) == expected_result assert (lhs * ValueMap(rhs)) == expected_result @@ -73,25 +82,27 @@ def test_mul(lhs, rhs, expected_result): assert res == expected_result -@pytest.mark.parametrize("lhs, rhs, expected_result", [ - ({}, {}, {}), - ( - {"BTC": Decimal("-3")}, - {"BTC": Decimal("2"), "ETH": Decimal("3")}, - {"BTC": Decimal("-1.5"), "ETH": Decimal("0")}, - ), - ( - {}, - {"BTC": Decimal("0"), "ETH": Decimal("3")}, - {"BTC": Decimal("0"), "ETH": Decimal("0")}, - ), - ( - {"BTC": Decimal("0")}, - {"BTC": Decimal("0"), "ETH": Decimal("3")}, - {"BTC": Decimal("0"), "ETH": Decimal("0")}, - ), - -]) +@pytest.mark.parametrize( + "lhs, rhs, expected_result", + [ + ({}, {}, {}), + ( + {"BTC": Decimal("-3")}, + {"BTC": Decimal("2"), "ETH": Decimal("3")}, + {"BTC": Decimal("-1.5"), "ETH": Decimal("0")}, + ), + ( + {}, + {"BTC": Decimal("0"), "ETH": Decimal("3")}, + {"BTC": Decimal("0"), "ETH": Decimal("0")}, + ), + ( + {"BTC": Decimal("0")}, + {"BTC": Decimal("0"), "ETH": Decimal("3")}, + {"BTC": Decimal("0"), "ETH": Decimal("0")}, + ), + ], +) def test_div(lhs, rhs, expected_result): # div assert (ValueMap(lhs) / rhs) == expected_result @@ -103,16 +114,13 @@ def test_div(lhs, rhs, expected_result): assert res == expected_result -@pytest.mark.parametrize("lhs, rhs", [ - ( - {"BTC": Decimal(1)}, - {} - ), - ( - {"ETH": Decimal(1)}, - {"ETH": Decimal(0)} - ), -]) +@pytest.mark.parametrize( + "lhs, rhs", + [ + ({"BTC": Decimal(1)}, {}), + ({"ETH": Decimal(1)}, {"ETH": Decimal(0)}), + ], +) def test_zero_div_error(lhs, rhs): # div with pytest.raises(ZeroDivisionError): @@ -127,11 +135,13 @@ def test_zero_div_error(lhs, rhs): def test_prune(): - values = ValueMap({ - "BTC": Decimal(1), - "USD": Decimal(1), - "ETH": Decimal(1), - }) + values = ValueMap( + { + "BTC": Decimal(1), + "USD": Decimal(1), + "ETH": Decimal(1), + } + ) values.prune() assert values == { diff --git a/tests/test_binance_bars.py b/tests/test_binance_bars.py index 506887a..e0d816f 100644 --- a/tests/test_binance_bars.py +++ b/tests/test_binance_bars.py @@ -42,28 +42,28 @@ async def server_main(websocket): await websocket.send(json.dumps({"result": None, "id": message["id"]})) kline_event = { - "e": "kline", # Event type - "E": 123456789, # Event time - "s": "BNBBTC", # Symbol + "e": "kline", # Event type + "E": 123456789, # Event time + "s": "BNBBTC", # Symbol "k": { "t": 123400000, # Kline start time "T": 123460000, # Kline close time - "s": "BNBBTC", # Symbol - "i": "1m", # Interval - "f": 100, # First trade ID - "L": 200, # Last trade ID - "o": "0.0010", # Open price - "c": "0.0020", # Close price - "h": "0.0025", # High price - "l": "0.0005", # Low price - "v": "1000", # Base asset volume - "n": 100, # Number of trades - "x": False, # Is this kline closed? - "q": "1.0000", # Quote asset volume - "V": "500", # Taker buy base asset volume - "Q": "0.500", # Taker buy quote asset volume - "B": "123456" # Ignore - } + "s": "BNBBTC", # Symbol + "i": "1m", # Interval + "f": 100, # First trade ID + "L": 200, # Last trade ID + "o": "0.0010", # Open price + "c": "0.0020", # Close price + "h": "0.0025", # High price + "l": "0.0005", # Low price + "v": "1000", # Base asset volume + "n": 100, # Number of trades + "x": False, # Is this kline closed? + "q": "1.0000", # Quote asset volume + "V": "500", # Taker buy base asset volume + "Q": "0.500", # Taker buy quote asset volume + "B": "123456", # Ignore + }, } while websocket.state == websockets.protocol.State.OPEN: @@ -71,10 +71,14 @@ async def server_main(websocket): for kline_closed in (False, True): kline_event["E"] = int(timestamp * 1e3) kline_event["k"]["x"] = kline_closed - await websocket.send(json.dumps({ - "stream": "bnbbtc@kline_1m", - "data": kline_event, - })) + await websocket.send( + json.dumps( + { + "stream": "bnbbtc@kline_1m", + "data": kline_event, + } + ) + ) await asyncio.sleep(0.4) async def test_main(): diff --git a/tests/test_binance_client.py b/tests/test_binance_client.py index 93c248c..53abadf 100644 --- a/tests/test_binance_client.py +++ b/tests/test_binance_client.py @@ -29,14 +29,17 @@ def binance_http_api_mock(): yield m -@pytest.mark.parametrize("status_code, response_body, expected", [ - (403, {}, "403 Forbidden"), - ( - 400, - {"code": -1022, "msg": "Signature for this request is not valid."}, - "Signature for this request is not valid." - ), -]) +@pytest.mark.parametrize( + "status_code, response_body, expected", + [ + (403, {}, "403 Forbidden"), + ( + 400, + {"code": -1022, "msg": "Signature for this request is not valid."}, + "Signature for this request is not valid.", + ), + ], +) def test_error_parsing(status_code, response_body, expected, binance_http_api_mock): binance_http_api_mock.get( re.compile(r"http://binance.mock/api/v3/account\\?.*"), status=status_code, payload=response_body diff --git a/tests/test_binance_exchange.py b/tests/test_binance_exchange.py index 69ff133..29de2c3 100644 --- a/tests/test_binance_exchange.py +++ b/tests/test_binance_exchange.py @@ -27,42 +27,13 @@ DEPTH_RESPONSE = { "lastUpdateId": 27229732069, - "bids": [ - [ - "16757.47000000", - "0.04893000" - ], - [ - "16757.41000000", - "0.00073000" - ], - [ - "16756.52000000", - "0.00690000" - ] - ], - "asks": [ - [ - "16758.13000000", - "0.00682000" - ], - [ - "16758.55000000", - "0.04963000" - ], - [ - "16759.25000000", - "0.00685000" - ] - ] + "bids": [["16757.47000000", "0.04893000"], ["16757.41000000", "0.00073000"], ["16756.52000000", "0.00690000"]], + "asks": [["16758.13000000", "0.00682000"], ["16758.55000000", "0.04963000"], ["16759.25000000", "0.00685000"]], } def test_bid_ask(binance_http_api_mock, binance_exchange): - binance_http_api_mock.get( - re.compile(r"http://binance.mock/api/v3/depth\\?.*"), status=200, - payload=DEPTH_RESPONSE - ) + binance_http_api_mock.get(re.compile(r"http://binance.mock/api/v3/depth\\?.*"), status=200, payload=DEPTH_RESPONSE) async def test_main(): bid, ask = await binance_exchange.get_bid_ask(pair.Pair("BTC", "USDT")) @@ -74,15 +45,19 @@ async def test_main(): def test_pair_info_explicit_session(binance_http_api_mock, realtime_dispatcher): binance_http_api_mock.get( - re.compile(r"http://binance.mock/api/v3/exchangeInfo\\?.*"), status=200, - payload=helpers.load_json("binance_btc_usdt_exchange_info.json") + re.compile(r"http://binance.mock/api/v3/exchangeInfo\\?.*"), + status=200, + payload=helpers.load_json("binance_btc_usdt_exchange_info.json"), ) async def test_main(): async with aiohttp.ClientSession() as session: e = exchange.Exchange( - realtime_dispatcher, "api_key", "api_secret", session=session, - config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}} + realtime_dispatcher, + "api_key", + "api_secret", + session=session, + config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}, ) pair_info = await e.get_pair_info(pair.Pair("BTC", "USDT")) @@ -95,15 +70,19 @@ async def test_main(): def test_symbol_to_pair(binance_http_api_mock, realtime_dispatcher): binance_http_api_mock.get( - re.compile(r"http://binance.mock/api/v3/exchangeInfo\\?.*"), status=200, - payload=helpers.load_json("binance_btc_usdt_exchange_info.json") + re.compile(r"http://binance.mock/api/v3/exchangeInfo\\?.*"), + status=200, + payload=helpers.load_json("binance_btc_usdt_exchange_info.json"), ) async def test_main(): async with aiohttp.ClientSession() as session: e = exchange.Exchange( - realtime_dispatcher, "api_key", "api_secret", session=session, - config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}} + realtime_dispatcher, + "api_key", + "api_secret", + session=session, + config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}, ) p = await e.symbol_to_pair("BTCUSDT") diff --git a/tests/test_binance_exchange_cross_margin.py b/tests/test_binance_exchange_cross_margin.py index 01957bf..d88aea2 100644 --- a/tests/test_binance_exchange_cross_margin.py +++ b/tests/test_binance_exchange_cross_margin.py @@ -31,7 +31,7 @@ "orderId": 3286471275, "clientOrderId": "76168451F9EE4C9DAADC16D74B874E4B", "transactTime": 1671232394634, - "isIsolated": False + "isIsolated": False, } ORDER_INFO_3286471275 = { @@ -52,7 +52,7 @@ "updateTime": 1671232429037, "isWorking": False, "accountId": 207887936, - "isIsolated": False + "isIsolated": False, } CREATE_ORDER_RESPONSE_12010477623 = { @@ -68,17 +68,10 @@ "timeInForce": "GTC", "type": "MARKET", "side": "BUY", - "fills": [ - { - "price": "1198.41", - "qty": "0.2503", - "commission": "0.0002503", - "commissionAsset": "ETH" - } - ], + "fills": [{"price": "1198.41", "qty": "0.2503", "commission": "0.0002503", "commissionAsset": "ETH"}], "marginBuyBorrowAsset": "USDT", "marginBuyBorrowAmount": "299.9548032", - "isIsolated": False + "isIsolated": False, } @@ -100,7 +93,7 @@ "time": 1671217574815, "timeInForce": "GTC", "type": "MARKET", - "updateTime": 1671217574815 + "updateTime": 1671217574815, } @@ -118,7 +111,7 @@ "isBuyer": True, "isMaker": False, "isBestMatch": True, - "isIsolated": False + "isIsolated": False, } ] @@ -129,7 +122,7 @@ "fills": [ {"commission": "0", "commissionAsset": "BNB", "price": "17775.62", "qty": "0.00417"}, {"commission": "0", "commissionAsset": "BNB", "price": "17775.66", "qty": "0.00298"}, - {"commission": "0", "commissionAsset": "BNB", "price": "17775.66", "qty": "0.00967"} + {"commission": "0", "commissionAsset": "BNB", "price": "17775.66", "qty": "0.00967"}, ], "isIsolated": False, "marginBuyBorrowAmount": "100.0869656", @@ -142,7 +135,7 @@ "symbol": "BTCUSDT", "timeInForce": "GTC", "transactTime": 1670986059521, - "type": "LIMIT" + "type": "LIMIT", } ORDER_INFO_16422505508 = { @@ -163,7 +156,7 @@ "updateTime": 1670986059521, "isWorking": True, "accountId": 207887936, - "isIsolated": False + "isIsolated": False, } TRADES_ORDER_16422505508 = [ @@ -180,7 +173,7 @@ "isBuyer": True, "isMaker": False, "isBestMatch": True, - "isIsolated": False + "isIsolated": False, }, { "symbol": "BTCUSDT", @@ -195,7 +188,7 @@ "isBuyer": True, "isMaker": False, "isBestMatch": True, - "isIsolated": False + "isIsolated": False, }, { "symbol": "BTCUSDT", @@ -210,8 +203,8 @@ "isBuyer": True, "isMaker": False, "isBestMatch": True, - "isIsolated": False - } + "isIsolated": False, + }, ] OCO_ORDER_INFO_79592962 = { @@ -224,17 +217,9 @@ "symbol": "CHZUSDT", "isIsolated": False, "orders": [ - { - "symbol": "CHZUSDT", - "orderId": 1420705016, - "clientOrderId": "MXTA6ncsHoWNyViZ5FQ2Cj" - }, - { - "symbol": "CHZUSDT", - "orderId": 1420705017, - "clientOrderId": "0EfhMRl4mHgY2yqvsJRV7e" - } - ] + {"symbol": "CHZUSDT", "orderId": 1420705016, "clientOrderId": "MXTA6ncsHoWNyViZ5FQ2Cj"}, + {"symbol": "CHZUSDT", "orderId": 1420705017, "clientOrderId": "0EfhMRl4mHgY2yqvsJRV7e"}, + ], } CANCEL_ORDER_RESPONSE_16582318135 = { @@ -250,7 +235,7 @@ "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", - "isIsolated": False + "isIsolated": False, } CANCEL_OCO_ORDER_RESPONSE_79680111 = { @@ -263,16 +248,8 @@ "symbol": "BTCUSDT", "isIsolated": False, "orders": [ - { - "symbol": "BTCUSDT", - "orderId": 16583687189, - "clientOrderId": "dyLZCprGrE0we3SAdg1tqP" - }, - { - "symbol": "BTCUSDT", - "orderId": 16583687190, - "clientOrderId": "LhuSdrzXqqtsZsGNFrCRhw" - } + {"symbol": "BTCUSDT", "orderId": 16583687189, "clientOrderId": "dyLZCprGrE0we3SAdg1tqP"}, + {"symbol": "BTCUSDT", "orderId": 16583687190, "clientOrderId": "LhuSdrzXqqtsZsGNFrCRhw"}, ], "orderReports": [ { @@ -288,7 +265,7 @@ "timeInForce": "GTC", "type": "STOP_LOSS_LIMIT", "side": "BUY", - "stopPrice": "19000.00000000" + "stopPrice": "19000.00000000", }, { "symbol": "BTCUSDT", @@ -302,16 +279,17 @@ "status": "CANCELED", "timeInForce": "GTC", "type": "LIMIT_MAKER", - "side": "BUY" - } - ] + "side": "BUY", + }, + ], } def test_account_balances(binance_http_api_mock, binance_exchange): binance_http_api_mock.get( - re.compile(r"http://binance.mock/sapi/v1/margin/account\\?.*"), status=200, - payload=helpers.load_json("binance_cross_margin_account_details.json") + re.compile(r"http://binance.mock/sapi/v1/margin/account\\?.*"), + status=200, + payload=helpers.load_json("binance_cross_margin_account_details.json"), ) async def test_main(): @@ -325,90 +303,103 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("create_order_fun, response_payload, expected_attrs, expected_fills", [ - ( - lambda e: e.cross_margin_account.create_limit_order( - exchange.OrderOperation.BUY, Pair("BTC", "USDT"), amount=Decimal("0.01682"), - limit_price=Decimal("17841.08"), side_effect_type="MARGIN_BUY", - client_order_id="B28B24EE482A425EA1A07F343FB2F3EE" - ), - CREATE_ORDER_RESPONSE_16422505508, - { - "id": "16422505508", - "datetime": datetime.datetime(2022, 12, 14, 2, 47, 39, 521000).replace(tzinfo=datetime.timezone.utc), - "client_order_id": "B28B24EE482A425EA1A07F343FB2F3EE", - "limit_price": Decimal("17841.08"), - "amount": Decimal("0.01682"), - "amount_filled": Decimal("0.01682"), - "quote_amount_filled": Decimal("298.9864344"), - "status": "FILLED", - "time_in_force": "GTC", - "is_open": False, - }, - [ - { - "price": Decimal("17775.62"), - "amount": Decimal("0.00417"), - "commission": Decimal("0"), - "commission_asset": "BNB", - }, +@pytest.mark.parametrize( + "create_order_fun, response_payload, expected_attrs, expected_fills", + [ + ( + lambda e: e.cross_margin_account.create_limit_order( + exchange.OrderOperation.BUY, + Pair("BTC", "USDT"), + amount=Decimal("0.01682"), + limit_price=Decimal("17841.08"), + side_effect_type="MARGIN_BUY", + client_order_id="B28B24EE482A425EA1A07F343FB2F3EE", + ), + CREATE_ORDER_RESPONSE_16422505508, { - "price": Decimal("17775.66"), - "amount": Decimal("0.00298"), - "commission": Decimal("0"), - "commission_asset": "BNB", + "id": "16422505508", + "datetime": datetime.datetime(2022, 12, 14, 2, 47, 39, 521000).replace(tzinfo=datetime.timezone.utc), + "client_order_id": "B28B24EE482A425EA1A07F343FB2F3EE", + "limit_price": Decimal("17841.08"), + "amount": Decimal("0.01682"), + "amount_filled": Decimal("0.01682"), + "quote_amount_filled": Decimal("298.9864344"), + "status": "FILLED", + "time_in_force": "GTC", + "is_open": False, }, + [ + { + "price": Decimal("17775.62"), + "amount": Decimal("0.00417"), + "commission": Decimal("0"), + "commission_asset": "BNB", + }, + { + "price": Decimal("17775.66"), + "amount": Decimal("0.00298"), + "commission": Decimal("0"), + "commission_asset": "BNB", + }, + { + "price": Decimal("17775.66"), + "amount": Decimal("0.00967"), + "commission": Decimal("0"), + "commission_asset": "BNB", + }, + ], + ), + ( + lambda e: e.cross_margin_account.create_market_order( + exchange.OrderOperation.BUY, + Pair("ETH", "USDT"), + quote_amount=Decimal("300"), + side_effect_type="MARGIN_BUY", + client_order_id="E1547A6979EA49A9A849A457BD751D38", + ), + CREATE_ORDER_RESPONSE_12010477623, { - "price": Decimal("17775.66"), - "amount": Decimal("0.00967"), - "commission": Decimal("0"), - "commission_asset": "BNB", + "id": "12010477623", + "datetime": datetime.datetime(2022, 12, 16, 19, 6, 14, 815000).replace(tzinfo=datetime.timezone.utc), + "client_order_id": "E1547A6979EA49A9A849A457BD751D38", + "limit_price": None, + "amount": Decimal("0.2503"), + "amount_filled": Decimal("0.2503"), + "quote_amount_filled": Decimal("299.962023"), + "status": "FILLED", + "is_open": False, }, - ], - ), - ( - lambda e: e.cross_margin_account.create_market_order( - exchange.OrderOperation.BUY, Pair("ETH", "USDT"), quote_amount=Decimal("300"), - side_effect_type="MARGIN_BUY", - client_order_id="E1547A6979EA49A9A849A457BD751D38" + [ + { + "price": Decimal("1198.41"), + "amount": Decimal("0.2503"), + "commission": Decimal("0.0002503"), + "commission_asset": "ETH", + }, + ], ), - CREATE_ORDER_RESPONSE_12010477623, - { - "id": "12010477623", - "datetime": datetime.datetime(2022, 12, 16, 19, 6, 14, 815000).replace(tzinfo=datetime.timezone.utc), - "client_order_id": "E1547A6979EA49A9A849A457BD751D38", - "limit_price": None, - "amount": Decimal("0.2503"), - "amount_filled": Decimal("0.2503"), - "quote_amount_filled": Decimal("299.962023"), - "status": "FILLED", - "is_open": False, - }, - [ + ( + lambda e: e.cross_margin_account.create_stop_limit_order( + exchange.OrderOperation.SELL, + Pair("DOGE", "USDT"), + Decimal("1000"), + Decimal("0.05"), + Decimal("0.05"), + side_effect_type="MARGIN_BUY", + client_order_id="76168451F9EE4C9DAADC16D74B874E4B", + ), + CREATE_ORDER_RESPONSE_3286471275, { - "price": Decimal("1198.41"), - "amount": Decimal("0.2503"), - "commission": Decimal("0.0002503"), - "commission_asset": "ETH", + "id": "3286471275", + "datetime": datetime.datetime(2022, 12, 16, 23, 13, 14, 634000).replace(tzinfo=datetime.timezone.utc), + "client_order_id": "76168451F9EE4C9DAADC16D74B874E4B", }, - ], - ), - ( - lambda e: e.cross_margin_account.create_stop_limit_order( - exchange.OrderOperation.SELL, Pair("DOGE", "USDT"), Decimal("1000"), Decimal("0.05"), Decimal("0.05"), - side_effect_type="MARGIN_BUY", client_order_id="76168451F9EE4C9DAADC16D74B874E4B" + [], ), - CREATE_ORDER_RESPONSE_3286471275, - { - "id": "3286471275", - "datetime": datetime.datetime(2022, 12, 16, 23, 13, 14, 634000).replace(tzinfo=datetime.timezone.utc), - "client_order_id": "76168451F9EE4C9DAADC16D74B874E4B", - }, - [], - ), -]) + ], +) def test_create_order( - create_order_fun, response_payload, expected_attrs, expected_fills, binance_http_api_mock, binance_exchange + create_order_fun, response_payload, expected_attrs, expected_fills, binance_http_api_mock, binance_exchange ): binance_http_api_mock.post( re.compile(r"http://binance.mock/sapi/v1/margin/order\\?.*"), status=200, payload=response_payload @@ -429,7 +420,9 @@ async def test_main(): "pair, order_id, client_order_id, order_payload, trades_payload, expected_attrs, expected_first_trade", [ ( - Pair("BTC", "USDT"), "16422505508", None, + Pair("BTC", "USDT"), + "16422505508", + None, ORDER_INFO_16422505508, TRADES_ORDER_16422505508, { @@ -458,7 +451,9 @@ async def test_main(): }, ), ( - Pair("ETH", "USDT"), None, "E1547A6979EA49A9A849A457BD751D38", + Pair("ETH", "USDT"), + None, + "E1547A6979EA49A9A849A457BD751D38", ORDER_INFO_12010477623, TRADES_ORDER_12010477623, { @@ -484,14 +479,21 @@ async def test_main(): "is_buyer": True, "is_maker": False, "is_best_match": True, - "is_isolated": False - } + "is_isolated": False, + }, ), - ] + ], ) def test_order_info( - pair, order_id, client_order_id, order_payload, trades_payload, expected_attrs, expected_first_trade, - binance_http_api_mock, binance_exchange + pair, + order_id, + client_order_id, + order_payload, + trades_payload, + expected_attrs, + expected_first_trade, + binance_http_api_mock, + binance_exchange, ): binance_http_api_mock.get( re.compile(r"http://binance.mock/sapi/v1/margin/order\\?.*"), status=200, payload=order_payload @@ -536,7 +538,7 @@ async def test_main(): "time": 1676487828466, "timeInForce": "GTC", "type": "LIMIT", - "updateTime": 1676487828466 + "updateTime": 1676487828466, } ], { @@ -554,11 +556,9 @@ async def test_main(): "type": "LIMIT", }, ), - ] + ], ) -def test_get_open_orders( - pair, open_orders_payload, expected_first_open_order, binance_http_api_mock, binance_exchange -): +def test_get_open_orders(pair, open_orders_payload, expected_first_open_order, binance_http_api_mock, binance_exchange): binance_http_api_mock.get( re.compile(r"http://binance.mock/sapi/v1/margin/openOrders\\?.*"), status=200, payload=open_orders_payload ) @@ -570,35 +570,42 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("pair, order_id, client_order_id, response_payload, expected_attrs", [ - ( - Pair("BTC", "USDT"), "16582318135", None, - CANCEL_ORDER_RESPONSE_16582318135, - { - "id": "16582318135", - "is_open": False, - "order_list_id": None, - "limit_price": Decimal(15000), - "amount": Decimal("0.001"), - "amount_filled": Decimal("0"), - "quote_amount_filled": Decimal("0"), - "status": "CANCELED", - "time_in_force": "GTC", - "operation": exchange.OrderOperation.BUY, - "type": "LIMIT", - } - ), - ( - Pair("BTC", "USDT"), None, "FCE43038586A45EBB0DBF8AD0F360E5A", - CANCEL_ORDER_RESPONSE_16582318135, - { - "id": "16582318135", - "is_open": False, - } - ) -]) +@pytest.mark.parametrize( + "pair, order_id, client_order_id, response_payload, expected_attrs", + [ + ( + Pair("BTC", "USDT"), + "16582318135", + None, + CANCEL_ORDER_RESPONSE_16582318135, + { + "id": "16582318135", + "is_open": False, + "order_list_id": None, + "limit_price": Decimal(15000), + "amount": Decimal("0.001"), + "amount_filled": Decimal("0"), + "quote_amount_filled": Decimal("0"), + "status": "CANCELED", + "time_in_force": "GTC", + "operation": exchange.OrderOperation.BUY, + "type": "LIMIT", + }, + ), + ( + Pair("BTC", "USDT"), + None, + "FCE43038586A45EBB0DBF8AD0F360E5A", + CANCEL_ORDER_RESPONSE_16582318135, + { + "id": "16582318135", + "is_open": False, + }, + ), + ], +) def test_cancel_order( - pair, order_id, client_order_id, response_payload, expected_attrs, binance_http_api_mock, binance_exchange + pair, order_id, client_order_id, response_payload, expected_attrs, binance_http_api_mock, binance_exchange ): binance_http_api_mock.delete( re.compile(r"http://binance.mock/sapi/v1/margin/order\\?.*"), status=200, payload=response_payload @@ -614,72 +621,78 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("create_order_fun, response_payload, expected_attrs", [ - ( - lambda e: e.cross_margin_account.create_oco_order( - exchange.OrderOperation.SELL, Pair("BTC", "USDT"), Decimal("0.01682"), Decimal("18125.4"), - Decimal("17592.3"), stop_limit_price=Decimal("15833.07"), side_effect_type="AUTO_REPAY" +@pytest.mark.parametrize( + "create_order_fun, response_payload, expected_attrs", + [ + ( + lambda e: e.cross_margin_account.create_oco_order( + exchange.OrderOperation.SELL, + Pair("BTC", "USDT"), + Decimal("0.01682"), + Decimal("18125.4"), + Decimal("17592.3"), + stop_limit_price=Decimal("15833.07"), + side_effect_type="AUTO_REPAY", + ), + { + "contingencyType": "OCO", + "isIsolated": False, + "listClientOrderId": "8FFCC716C91841F199AD1D5DD14752E5", + "listOrderStatus": "EXECUTING", + "listStatusType": "EXEC_STARTED", + "orderListId": 79453975, + "orderReports": [ + { + "clientOrderId": "AjmD6hDJzPVeS6nmmE1ZDo", + "cummulativeQuoteQty": "0", + "executedQty": "0", + "orderId": 16422653053, + "orderListId": 79453975, + "origQty": "0.01682000", + "price": "15833.07000000", + "side": "SELL", + "status": "NEW", + "stopPrice": "17592.30000000", + "symbol": "BTCUSDT", + "timeInForce": "GTC", + "transactTime": 1670986467587, + "type": "STOP_LOSS_LIMIT", + }, + { + "clientOrderId": "O5xo6nf8Ua0HbNMtpyqvv3", + "cummulativeQuoteQty": "0", + "executedQty": "0", + "orderId": 16422653054, + "orderListId": 79453975, + "origQty": "0.01682000", + "price": "18125.40000000", + "side": "SELL", + "status": "NEW", + "symbol": "BTCUSDT", + "timeInForce": "GTC", + "transactTime": 1670986467587, + "type": "LIMIT_MAKER", + }, + ], + "orders": [ + {"clientOrderId": "AjmD6hDJzPVeS6nmmE1ZDo", "orderId": 16422653053, "symbol": "BTCUSDT"}, + {"clientOrderId": "O5xo6nf8Ua0HbNMtpyqvv3", "orderId": 16422653054, "symbol": "BTCUSDT"}, + ], + "symbol": "BTCUSDT", + "transactionTime": 1670986467587, + }, + { + "order_list_id": "79453975", + "client_order_list_id": "8FFCC716C91841F199AD1D5DD14752E5", + "datetime": datetime.datetime(2022, 12, 14, 2, 54, 27, 587000).replace(tzinfo=datetime.timezone.utc), + "is_open": True, + "limit_order_id": "16422653054", + "stop_loss_order_id": "16422653053", + }, ), - { - "contingencyType": "OCO", - "isIsolated": False, - "listClientOrderId": "8FFCC716C91841F199AD1D5DD14752E5", - "listOrderStatus": "EXECUTING", - "listStatusType": "EXEC_STARTED", - "orderListId": 79453975, - "orderReports": [ - { - "clientOrderId": "AjmD6hDJzPVeS6nmmE1ZDo", - "cummulativeQuoteQty": "0", - "executedQty": "0", - "orderId": 16422653053, - "orderListId": 79453975, - "origQty": "0.01682000", - "price": "15833.07000000", - "side": "SELL", - "status": "NEW", - "stopPrice": "17592.30000000", - "symbol": "BTCUSDT", - "timeInForce": "GTC", - "transactTime": 1670986467587, - "type": "STOP_LOSS_LIMIT" - }, - { - "clientOrderId": "O5xo6nf8Ua0HbNMtpyqvv3", - "cummulativeQuoteQty": "0", - "executedQty": "0", - "orderId": 16422653054, - "orderListId": 79453975, - "origQty": "0.01682000", - "price": "18125.40000000", - "side": "SELL", - "status": "NEW", - "symbol": "BTCUSDT", - "timeInForce": "GTC", - "transactTime": 1670986467587, - "type": "LIMIT_MAKER" - } - ], - "orders": [ - {"clientOrderId": "AjmD6hDJzPVeS6nmmE1ZDo", "orderId": 16422653053, "symbol": "BTCUSDT"}, - {"clientOrderId": "O5xo6nf8Ua0HbNMtpyqvv3", "orderId": 16422653054, "symbol": "BTCUSDT"} - ], - "symbol": "BTCUSDT", - "transactionTime": 1670986467587 - }, - { - "order_list_id": "79453975", - "client_order_list_id": "8FFCC716C91841F199AD1D5DD14752E5", - "datetime": datetime.datetime(2022, 12, 14, 2, 54, 27, 587000).replace(tzinfo=datetime.timezone.utc), - "is_open": True, - "limit_order_id": "16422653054", - "stop_loss_order_id": "16422653053", - }, - ), -]) -def test_create_oco_order( - create_order_fun, response_payload, expected_attrs, binance_http_api_mock, binance_exchange -): + ], +) +def test_create_oco_order(create_order_fun, response_payload, expected_attrs, binance_http_api_mock, binance_exchange): binance_http_api_mock.post( re.compile(r"http://binance.mock/sapi/v1/margin/order/oco\\?.*"), status=200, payload=response_payload ) @@ -692,28 +705,33 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("order_list_id, client_order_list_id, response_payload, expected_attrs", [ - ( - "79592962", None, - OCO_ORDER_INFO_79592962, - { - "order_list_id": "79592962", - "client_order_list_id": "3hwrDggYdWhtiNAzm5bNDf", - "is_open": False, - } - ), - ( - None, "3hwrDggYdWhtiNAzm5bNDf", - OCO_ORDER_INFO_79592962, - { - "order_list_id": "79592962", - "client_order_list_id": "3hwrDggYdWhtiNAzm5bNDf", - "is_open": False, - } - ), -]) +@pytest.mark.parametrize( + "order_list_id, client_order_list_id, response_payload, expected_attrs", + [ + ( + "79592962", + None, + OCO_ORDER_INFO_79592962, + { + "order_list_id": "79592962", + "client_order_list_id": "3hwrDggYdWhtiNAzm5bNDf", + "is_open": False, + }, + ), + ( + None, + "3hwrDggYdWhtiNAzm5bNDf", + OCO_ORDER_INFO_79592962, + { + "order_list_id": "79592962", + "client_order_list_id": "3hwrDggYdWhtiNAzm5bNDf", + "is_open": False, + }, + ), + ], +) def test_oco_order_info( - order_list_id, client_order_list_id, response_payload, expected_attrs, binance_http_api_mock, binance_exchange + order_list_id, client_order_list_id, response_payload, expected_attrs, binance_http_api_mock, binance_exchange ): binance_http_api_mock.get( re.compile(r"http://binance.mock/sapi/v1/margin/orderList\\?.*"), status=200, payload=response_payload @@ -729,31 +747,37 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("pair, order_list_id, client_order_list_id, response_payload, expected_attrs", [ - ( - Pair("BTC", "USDT"), "79680111", None, - CANCEL_OCO_ORDER_RESPONSE_79680111, - { - "order_list_id": "79680111", - "client_order_list_id": "B3331893C53A4F1487EB78F2E16D4FDD", - "datetime": datetime.datetime(2022, 12, 19, 15, 24, 35, 963000).replace(tzinfo=datetime.timezone.utc), - "is_open": False, - } - ), - ( - Pair("BTC", "USDT"), None, "B3331893C53A4F1487EB78F2E16D4FDD", - CANCEL_OCO_ORDER_RESPONSE_79680111, - { - "order_list_id": "79680111", - "client_order_list_id": "B3331893C53A4F1487EB78F2E16D4FDD", - "datetime": datetime.datetime(2022, 12, 19, 15, 24, 35, 963000).replace(tzinfo=datetime.timezone.utc), - "is_open": False, - } - ) -]) +@pytest.mark.parametrize( + "pair, order_list_id, client_order_list_id, response_payload, expected_attrs", + [ + ( + Pair("BTC", "USDT"), + "79680111", + None, + CANCEL_OCO_ORDER_RESPONSE_79680111, + { + "order_list_id": "79680111", + "client_order_list_id": "B3331893C53A4F1487EB78F2E16D4FDD", + "datetime": datetime.datetime(2022, 12, 19, 15, 24, 35, 963000).replace(tzinfo=datetime.timezone.utc), + "is_open": False, + }, + ), + ( + Pair("BTC", "USDT"), + None, + "B3331893C53A4F1487EB78F2E16D4FDD", + CANCEL_OCO_ORDER_RESPONSE_79680111, + { + "order_list_id": "79680111", + "client_order_list_id": "B3331893C53A4F1487EB78F2E16D4FDD", + "datetime": datetime.datetime(2022, 12, 19, 15, 24, 35, 963000).replace(tzinfo=datetime.timezone.utc), + "is_open": False, + }, + ), + ], +) def test_cancel_oco_order( - pair, order_list_id, client_order_list_id, response_payload, expected_attrs, - binance_http_api_mock, binance_exchange + pair, order_list_id, client_order_list_id, response_payload, expected_attrs, binance_http_api_mock, binance_exchange ): binance_http_api_mock.delete( re.compile(r"http://binance.mock/sapi/v1/margin/orderList\\?.*"), status=200, payload=response_payload @@ -772,13 +796,17 @@ async def test_main(): def test_transfer(binance_http_api_mock, binance_exchange): async def test_main(): binance_http_api_mock.post( - re.compile(r"http://binance.mock/sapi/v1/margin/transfer\\?.*"), status=200, - payload={"clientTag": "", "tranId": 124735427615}, repeat=True + re.compile(r"http://binance.mock/sapi/v1/margin/transfer\\?.*"), + status=200, + payload={"clientTag": "", "tranId": 124735427615}, + repeat=True, ) - assert (await binance_exchange.cross_margin_account.transfer_from_spot_account( - "USDT", Decimal("100")))["tranId"] == 124735427615 - assert (await binance_exchange.cross_margin_account.transfer_to_spot_account( - "USDT", Decimal("100")))["tranId"] == 124735427615 + assert (await binance_exchange.cross_margin_account.transfer_from_spot_account("USDT", Decimal("100")))[ + "tranId" + ] == 124735427615 + assert (await binance_exchange.cross_margin_account.transfer_to_spot_account("USDT", Decimal("100")))[ + "tranId" + ] == 124735427615 asyncio.run(test_main()) diff --git a/tests/test_binance_exchange_isolated_margin.py b/tests/test_binance_exchange_isolated_margin.py index 5a13235..8fcb032 100644 --- a/tests/test_binance_exchange_isolated_margin.py +++ b/tests/test_binance_exchange_isolated_margin.py @@ -40,28 +40,37 @@ "type": "LIMIT", "side": "BUY", "fills": [], - "isIsolated": True + "isIsolated": True, } def test_transfer(binance_http_api_mock, binance_exchange): async def test_main(): binance_http_api_mock.post( - re.compile(r"http://binance.mock/sapi/v1/margin/isolated/transfer\\?.*"), status=200, - payload={"tranId": 124739543934, "clientTag": ""}, repeat=True + re.compile(r"http://binance.mock/sapi/v1/margin/isolated/transfer\\?.*"), + status=200, + payload={"tranId": 124739543934, "clientTag": ""}, + repeat=True, ) - assert (await binance_exchange.isolated_margin_account.transfer_from_spot_account( - "USDT", Pair("BTC", "USDT"), Decimal("100")))["tranId"] == 124739543934 - assert (await binance_exchange.isolated_margin_account.transfer_to_spot_account( - "USDT", Pair("BTC", "USDT"), Decimal("100")))["tranId"] == 124739543934 + assert ( + await binance_exchange.isolated_margin_account.transfer_from_spot_account( + "USDT", Pair("BTC", "USDT"), Decimal("100") + ) + )["tranId"] == 124739543934 + assert ( + await binance_exchange.isolated_margin_account.transfer_to_spot_account( + "USDT", Pair("BTC", "USDT"), Decimal("100") + ) + )["tranId"] == 124739543934 asyncio.run(test_main()) def test_account_balances(binance_http_api_mock, binance_exchange): binance_http_api_mock.get( - re.compile(r"http://binance.mock/sapi/v1/margin/isolated/account\\?.*"), status=200, - payload=helpers.load_json("binance_isolated_margin_account_details.json") + re.compile(r"http://binance.mock/sapi/v1/margin/isolated/account\\?.*"), + status=200, + payload=helpers.load_json("binance_isolated_margin_account_details.json"), ) async def test_main(): @@ -79,30 +88,36 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("create_order_fun, response_payload, expected_attrs, expected_fills", [ - ( - lambda e: e.isolated_margin_account.create_limit_order( - exchange.OrderOperation.BUY, Pair("BTC", "USDT"), amount=Decimal("0.001"), - limit_price=Decimal("14000"), client_order_id="8174E476E48943AE95D005BAFA473792" +@pytest.mark.parametrize( + "create_order_fun, response_payload, expected_attrs, expected_fills", + [ + ( + lambda e: e.isolated_margin_account.create_limit_order( + exchange.OrderOperation.BUY, + Pair("BTC", "USDT"), + amount=Decimal("0.001"), + limit_price=Decimal("14000"), + client_order_id="8174E476E48943AE95D005BAFA473792", + ), + CREATE_ORDER_RESPONSE_16620163621, + { + "id": "16620163621", + "datetime": datetime.datetime(2022, 12, 20, 15, 21, 13, 288000).replace(tzinfo=datetime.timezone.utc), + "client_order_id": "8174E476E48943AE95D005BAFA473792", + "limit_price": Decimal("14000"), + "amount": Decimal("0.001"), + "amount_filled": Decimal("0"), + "quote_amount_filled": Decimal("0"), + "status": "NEW", + "time_in_force": "GTC", + "is_open": True, + }, + [], ), - CREATE_ORDER_RESPONSE_16620163621, - { - "id": "16620163621", - "datetime": datetime.datetime(2022, 12, 20, 15, 21, 13, 288000).replace(tzinfo=datetime.timezone.utc), - "client_order_id": "8174E476E48943AE95D005BAFA473792", - "limit_price": Decimal("14000"), - "amount": Decimal("0.001"), - "amount_filled": Decimal("0"), - "quote_amount_filled": Decimal("0"), - "status": "NEW", - "time_in_force": "GTC", - "is_open": True, - }, - [], - ), -]) + ], +) def test_create_order( - create_order_fun, response_payload, expected_attrs, expected_fills, binance_http_api_mock, binance_exchange + create_order_fun, response_payload, expected_attrs, expected_fills, binance_http_api_mock, binance_exchange ): binance_http_api_mock.post( re.compile(r"http://binance.mock/sapi/v1/margin/order\\?.*"), status=200, payload=response_payload diff --git a/tests/test_binance_exchange_spot.py b/tests/test_binance_exchange_spot.py index a8e9f85..302cf64 100644 --- a/tests/test_binance_exchange_spot.py +++ b/tests/test_binance_exchange_spot.py @@ -36,7 +36,7 @@ "symbol": "BTCUSDT", "orders": [ {"symbol": "BTCUSDT", "orderId": 15558250268, "clientOrderId": "ZDlLvguLRpgGRfcwLLeATp"}, - {"symbol": "BTCUSDT", "orderId": 15558250269, "clientOrderId": "AdLeXmt1ChmIloCJJKC0xu"} + {"symbol": "BTCUSDT", "orderId": 15558250269, "clientOrderId": "AdLeXmt1ChmIloCJJKC0xu"}, ], "orderReports": [ { @@ -53,7 +53,7 @@ "timeInForce": "GTC", "type": "STOP_LOSS_LIMIT", "side": "SELL", - "stopPrice": "10000.00000000" + "stopPrice": "10000.00000000", }, { "symbol": "BTCUSDT", @@ -68,9 +68,9 @@ "status": "CANCELED", "timeInForce": "GTC", "type": "LIMIT_MAKER", - "side": "SELL" - } - ] + "side": "SELL", + }, + ], } OCO_ORDER_INFO_RESPONSE = { @@ -80,16 +80,18 @@ "listOrderStatus": "ALL_DONE", "listClientOrderId": "9wQuvisHlbJPQOwrJLhqgn", "transactionTime": 1668478867902, - "symbol": "BTCUSDT", "orders": [ + "symbol": "BTCUSDT", + "orders": [ {"symbol": "BTCUSDT", "orderId": 15535167360, "clientOrderId": "bUDxpWHguNICQ7MGe4bAxK"}, - {"symbol": "BTCUSDT", "orderId": 15535167361, "clientOrderId": "Eq4WPPmCLE0Vp1P4XuXG3G"} - ] + {"symbol": "BTCUSDT", "orderId": 15535167361, "clientOrderId": "Eq4WPPmCLE0Vp1P4XuXG3G"}, + ], } def test_account_balances(binance_http_api_mock, binance_exchange): binance_http_api_mock.get( - re.compile(r"http://binance.mock/api/v3/account\\?.*"), status=200, + re.compile(r"http://binance.mock/api/v3/account\\?.*"), + status=200, payload={ "makerCommission": 10, "takerCommission": 10, @@ -102,21 +104,11 @@ def test_account_balances(binance_http_api_mock, binance_exchange): "updateTime": 1667518291196, "accountType": "SPOT", "balances": [ - { - "asset": "BTC", - "free": "0.00000053", - "locked": "1.00000000" - }, - { - "asset": "LTC", - "free": "0.00000000", - "locked": "0.00000000" - }, + {"asset": "BTC", "free": "0.00000053", "locked": "1.00000000"}, + {"asset": "LTC", "free": "0.00000000", "locked": "0.00000000"}, ], - "permissions": [ - "SPOT" - ] - } + "permissions": ["SPOT"], + }, ) async def test_main(): @@ -130,164 +122,171 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("create_order_fun, response_payload, expected_attrs, expected_fills", [ - ( - lambda e: e.spot_account.create_market_order( - exchange.OrderOperation.BUY, pair.Pair("BTC", "USDT"), amount=Decimal("0.001") - ), - { - "symbol": "BTCUSDT", - "orderId": 15374780716, - "orderListId": -1, - "clientOrderId": "w4PHTG4wsaN6bEKoBMNK7O", - "transactTime": 1668129070269, - "price": "0.00000000", - "origQty": "0.00100000", - "executedQty": "0.00100000", - "cummulativeQuoteQty": "17.62721000", - "status": "FILLED", - "timeInForce": "GTC", - "type": "MARKET", - "side": "BUY", - "fills": [ - { - "price": "17627.21000000", - "qty": "0.00100000", - "commission": "0.00000000", - "commissionAsset": "BNB", - "tradeId": 2156194389 - } - ] - }, - { - "id": "15374780716", - "datetime": datetime.datetime(2022, 11, 11, 1, 11, 10, 269000).replace(tzinfo=datetime.timezone.utc), - "client_order_id": "w4PHTG4wsaN6bEKoBMNK7O", - "limit_price": None, - "amount": Decimal("0.001"), - "amount_filled": Decimal("0.001"), - "quote_amount_filled": Decimal("17.62721"), - "status": "FILLED", - "time_in_force": "GTC", - "order_list_id": None, - "is_open": False, - }, - [ +@pytest.mark.parametrize( + "create_order_fun, response_payload, expected_attrs, expected_fills", + [ + ( + lambda e: e.spot_account.create_market_order( + exchange.OrderOperation.BUY, pair.Pair("BTC", "USDT"), amount=Decimal("0.001") + ), + { + "symbol": "BTCUSDT", + "orderId": 15374780716, + "orderListId": -1, + "clientOrderId": "w4PHTG4wsaN6bEKoBMNK7O", + "transactTime": 1668129070269, + "price": "0.00000000", + "origQty": "0.00100000", + "executedQty": "0.00100000", + "cummulativeQuoteQty": "17.62721000", + "status": "FILLED", + "timeInForce": "GTC", + "type": "MARKET", + "side": "BUY", + "fills": [ + { + "price": "17627.21000000", + "qty": "0.00100000", + "commission": "0.00000000", + "commissionAsset": "BNB", + "tradeId": 2156194389, + } + ], + }, { - "price": Decimal("17627.21"), + "id": "15374780716", + "datetime": datetime.datetime(2022, 11, 11, 1, 11, 10, 269000).replace(tzinfo=datetime.timezone.utc), + "client_order_id": "w4PHTG4wsaN6bEKoBMNK7O", + "limit_price": None, "amount": Decimal("0.001"), - "commission": Decimal("0"), - "commission_asset": "BNB", - "trade_id": "2156194389", - } - ], - ), - ( - lambda e: e.spot_account.create_market_order( - exchange.OrderOperation.BUY, pair.Pair("BTC", "USDT"), quote_amount=Decimal("30") - ), - { - "symbol": "BTCUSDT", - "orderId": 15455625561, - "orderListId": -1, - "clientOrderId": "kTjLDEuQdFO5VZmWTbAT7b", - "transactTime": 1668306875519, - "price": "0.00000000", - "origQty": "0.00177000", - "executedQty": "0.00177000", - "cummulativeQuoteQty": "29.87237850", - "status": "FILLED", - "timeInForce": "GTC", - "type": "MARKET", - "side": "BUY", - "fills": [ + "amount_filled": Decimal("0.001"), + "quote_amount_filled": Decimal("17.62721"), + "status": "FILLED", + "time_in_force": "GTC", + "order_list_id": None, + "is_open": False, + }, + [ { - "price": "16877.05000000", - "qty": "0.00177000", - "commission": "0.00000000", - "commissionAsset": "BNB", - "tradeId": 2171115716 + "price": Decimal("17627.21"), + "amount": Decimal("0.001"), + "commission": Decimal("0"), + "commission_asset": "BNB", + "trade_id": "2156194389", } - ] - }, - { - "id": "15455625561", - "datetime": datetime.datetime(2022, 11, 13, 2, 34, 35, 519000).replace(tzinfo=datetime.timezone.utc), - "client_order_id": "kTjLDEuQdFO5VZmWTbAT7b", - "limit_price": None, - "amount": Decimal("0.00177"), - "amount_filled": Decimal("0.00177"), - "quote_amount_filled": Decimal("29.87237850"), - "status": "FILLED", - "time_in_force": "GTC", - "order_list_id": None, - "is_open": False, - }, - [ + ], + ), + ( + lambda e: e.spot_account.create_market_order( + exchange.OrderOperation.BUY, pair.Pair("BTC", "USDT"), quote_amount=Decimal("30") + ), { - "price": Decimal("16877.05"), + "symbol": "BTCUSDT", + "orderId": 15455625561, + "orderListId": -1, + "clientOrderId": "kTjLDEuQdFO5VZmWTbAT7b", + "transactTime": 1668306875519, + "price": "0.00000000", + "origQty": "0.00177000", + "executedQty": "0.00177000", + "cummulativeQuoteQty": "29.87237850", + "status": "FILLED", + "timeInForce": "GTC", + "type": "MARKET", + "side": "BUY", + "fills": [ + { + "price": "16877.05000000", + "qty": "0.00177000", + "commission": "0.00000000", + "commissionAsset": "BNB", + "tradeId": 2171115716, + } + ], + }, + { + "id": "15455625561", + "datetime": datetime.datetime(2022, 11, 13, 2, 34, 35, 519000).replace(tzinfo=datetime.timezone.utc), + "client_order_id": "kTjLDEuQdFO5VZmWTbAT7b", + "limit_price": None, "amount": Decimal("0.00177"), - "commission": Decimal("0"), - "commission_asset": "BNB", - "trade_id": "2171115716", - } - ], - ), - ( - lambda e: e.spot_account.create_limit_order( - exchange.OrderOperation.SELL, pair.Pair("BTC", "USDT"), Decimal("0.001"), Decimal("17498") + "amount_filled": Decimal("0.00177"), + "quote_amount_filled": Decimal("29.87237850"), + "status": "FILLED", + "time_in_force": "GTC", + "order_list_id": None, + "is_open": False, + }, + [ + { + "price": Decimal("16877.05"), + "amount": Decimal("0.00177"), + "commission": Decimal("0"), + "commission_asset": "BNB", + "trade_id": "2171115716", + } + ], ), - { - "symbol": "BTCUSDT", - "orderId": 15456494165, - "orderListId": -1, - "clientOrderId": "x7frvT9IHW5ogIKYUnrH6L", - "transactTime": 1668309613411, - "price": "17498.00000000", - "origQty": "0.00100000", - "executedQty": "0.00000000", - "cummulativeQuoteQty": "0.00000000", - "status": "NEW", - "timeInForce": "GTC", - "type": "LIMIT", - "side": "SELL", - "fills": [], - "is_open": True, - }, - { - "id": "15456494165", - "datetime": datetime.datetime(2022, 11, 13, 3, 20, 13, 411000).replace(tzinfo=datetime.timezone.utc), - "client_order_id": "x7frvT9IHW5ogIKYUnrH6L", - "limit_price": Decimal("17498"), - "amount": Decimal("0.001"), - "amount_filled": Decimal("0"), - "quote_amount_filled": Decimal("0"), - "status": "NEW", - "time_in_force": "GTC", - "order_list_id": None, - }, - [], - ), - ( - lambda e: e.spot_account.create_stop_limit_order( - exchange.OrderOperation.SELL, pair.Pair("BTC", "USDT"), Decimal("0.001"), Decimal("15000"), Decimal("20000") + ( + lambda e: e.spot_account.create_limit_order( + exchange.OrderOperation.SELL, pair.Pair("BTC", "USDT"), Decimal("0.001"), Decimal("17498") + ), + { + "symbol": "BTCUSDT", + "orderId": 15456494165, + "orderListId": -1, + "clientOrderId": "x7frvT9IHW5ogIKYUnrH6L", + "transactTime": 1668309613411, + "price": "17498.00000000", + "origQty": "0.00100000", + "executedQty": "0.00000000", + "cummulativeQuoteQty": "0.00000000", + "status": "NEW", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "SELL", + "fills": [], + "is_open": True, + }, + { + "id": "15456494165", + "datetime": datetime.datetime(2022, 11, 13, 3, 20, 13, 411000).replace(tzinfo=datetime.timezone.utc), + "client_order_id": "x7frvT9IHW5ogIKYUnrH6L", + "limit_price": Decimal("17498"), + "amount": Decimal("0.001"), + "amount_filled": Decimal("0"), + "quote_amount_filled": Decimal("0"), + "status": "NEW", + "time_in_force": "GTC", + "order_list_id": None, + }, + [], ), - { - "symbol": "BTCUSDT", - "orderId": 15533662777, - "orderListId": -1, - "clientOrderId": "guCHQcaPSzFFEk5Dr7Fsgq", - "transactTime": 1668475978860 - }, - { - "id": "15533662777", - "client_order_id": "guCHQcaPSzFFEk5Dr7Fsgq", - }, - [], - ) -]) + ( + lambda e: e.spot_account.create_stop_limit_order( + exchange.OrderOperation.SELL, + pair.Pair("BTC", "USDT"), + Decimal("0.001"), + Decimal("15000"), + Decimal("20000"), + ), + { + "symbol": "BTCUSDT", + "orderId": 15533662777, + "orderListId": -1, + "clientOrderId": "guCHQcaPSzFFEk5Dr7Fsgq", + "transactTime": 1668475978860, + }, + { + "id": "15533662777", + "client_order_id": "guCHQcaPSzFFEk5Dr7Fsgq", + }, + [], + ), + ], +) def test_create_order( - create_order_fun, response_payload, expected_attrs, expected_fills, binance_http_api_mock, binance_exchange + create_order_fun, response_payload, expected_attrs, expected_fills, binance_http_api_mock, binance_exchange ): binance_http_api_mock.post( re.compile(r"http://binance.mock/api/v3/order\\?.*"), status=200, payload=response_payload @@ -308,7 +307,9 @@ async def test_main(): "pair, order_id, client_order_id, order_payload, trades_payload, expected_attrs, expected_first_trade", [ ( - pair.Pair("BTC", "USDT"), "15456494165", None, + pair.Pair("BTC", "USDT"), + "15456494165", + None, { "symbol": "BTCUSDT", "orderId": 15456494165, @@ -327,7 +328,7 @@ async def test_main(): "time": 1668309613411, "updateTime": 1668309613411, "isWorking": True, - "origQuoteOrderQty": "0.00000000" + "origQuoteOrderQty": "0.00000000", }, [], { @@ -344,7 +345,9 @@ async def test_main(): None, ), ( - pair.Pair("BTC", "USDT"), None, "kTjLDEuQdFO5VZmWTbAT7b", + pair.Pair("BTC", "USDT"), + None, + "kTjLDEuQdFO5VZmWTbAT7b", { "symbol": "BTCUSDT", "orderId": 15455625561, @@ -363,7 +366,7 @@ async def test_main(): "time": 1668306875519, "updateTime": 1668306875519, "isWorking": True, - "origQuoteOrderQty": "30.00000000" + "origQuoteOrderQty": "30.00000000", }, [ { @@ -379,7 +382,7 @@ async def test_main(): "qty": "0.00177000", "quoteQty": "29.87237850", "symbol": "BTCUSDT", - "time": 1668306875519 + "time": 1668306875519, } ], { @@ -392,7 +395,7 @@ async def test_main(): "stop_price": None, "limit_price": None, "fill_price": Decimal("16877.05"), - "fees": {"BNB": Decimal("0.01")} + "fees": {"BNB": Decimal("0.01")}, }, { "id": "2171115716", @@ -409,15 +412,20 @@ async def test_main(): "datetime": datetime.datetime(2022, 11, 13, 2, 34, 35, 519000).replace(tzinfo=datetime.timezone.utc), }, ), - ] + ], ) def test_order_info( - pair, order_id, client_order_id, order_payload, trades_payload, expected_attrs, expected_first_trade, - binance_http_api_mock, binance_exchange + pair, + order_id, + client_order_id, + order_payload, + trades_payload, + expected_attrs, + expected_first_trade, + binance_http_api_mock, + binance_exchange, ): - binance_http_api_mock.get( - re.compile(r"http://binance.mock/api/v3/order\\?.*"), status=200, payload=order_payload - ) + binance_http_api_mock.get(re.compile(r"http://binance.mock/api/v3/order\\?.*"), status=200, payload=order_payload) binance_http_api_mock.get( re.compile(r"http://binance.mock/api/v3/myTrades\\?.*"), status=200, payload=trades_payload ) @@ -435,10 +443,7 @@ async def test_main(): def test_error_retrieving_order_info(binance_http_api_mock, binance_exchange): - binance_http_api_mock.get( - re.compile(r"http://binance.mock/api/v3/order\\?.*"), status=500, - payload={} - ) + binance_http_api_mock.get(re.compile(r"http://binance.mock/api/v3/order\\?.*"), status=500, payload={}) async def test_main(): with pytest.raises(exchange.Error) as excinfo: @@ -450,63 +455,70 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("pair, order_id, client_order_id, response_payload, expected_attrs", [ - ( - pair.Pair("BTC", "USDT"), "15456494165", None, - { - "symbol": "BTCUSDT", - "origClientOrderId": "x7frvT9IHW5ogIKYUnrH6L", - "orderId": 15456494165, - "orderListId": -1, - "clientOrderId": "OS6KUQ0RDJ9DVrxRYAqYn9", - "price": "17498.00000000", - "origQty": "0.00100000", - "executedQty": "0.00000000", - "cummulativeQuoteQty": "0.00000000", - "status": "CANCELED", - "timeInForce": "GTC", - "type": "LIMIT", - "side": "SELL" - }, - { - "id": "15456494165", - "is_open": False, - } - ), - ( - pair.Pair("BTC", "USDT"), None, "wmJ3mL6libCN8N7rEjCTIk", - { - "symbol": "BTCUSDT", - "origClientOrderId": "wmJ3mL6libCN8N7rEjCTIk", - "orderId": 15523683385, - "orderListId": -1, - "clientOrderId": "cVks1NPde0Lt9XFZYdCIOQ", - "price": "18998.00000000", - "origQty": "0.00100000", - "executedQty": "0.00000000", - "cummulativeQuoteQty": "0.00000000", - "status": "CANCELED", - "timeInForce": "GTC", - "type": "LIMIT", - "side": "SELL" - }, - { - "id": "15523683385", - "is_open": False, - "order_list_id": None, - "limit_price": Decimal(18998), - "amount": Decimal("0.001"), - "amount_filled": Decimal("0"), - "quote_amount_filled": Decimal("0"), - "status": "CANCELED", - "time_in_force": "GTC", - "operation": exchange.OrderOperation.SELL, - "type": "LIMIT", - } - ) -]) +@pytest.mark.parametrize( + "pair, order_id, client_order_id, response_payload, expected_attrs", + [ + ( + pair.Pair("BTC", "USDT"), + "15456494165", + None, + { + "symbol": "BTCUSDT", + "origClientOrderId": "x7frvT9IHW5ogIKYUnrH6L", + "orderId": 15456494165, + "orderListId": -1, + "clientOrderId": "OS6KUQ0RDJ9DVrxRYAqYn9", + "price": "17498.00000000", + "origQty": "0.00100000", + "executedQty": "0.00000000", + "cummulativeQuoteQty": "0.00000000", + "status": "CANCELED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "SELL", + }, + { + "id": "15456494165", + "is_open": False, + }, + ), + ( + pair.Pair("BTC", "USDT"), + None, + "wmJ3mL6libCN8N7rEjCTIk", + { + "symbol": "BTCUSDT", + "origClientOrderId": "wmJ3mL6libCN8N7rEjCTIk", + "orderId": 15523683385, + "orderListId": -1, + "clientOrderId": "cVks1NPde0Lt9XFZYdCIOQ", + "price": "18998.00000000", + "origQty": "0.00100000", + "executedQty": "0.00000000", + "cummulativeQuoteQty": "0.00000000", + "status": "CANCELED", + "timeInForce": "GTC", + "type": "LIMIT", + "side": "SELL", + }, + { + "id": "15523683385", + "is_open": False, + "order_list_id": None, + "limit_price": Decimal(18998), + "amount": Decimal("0.001"), + "amount_filled": Decimal("0"), + "quote_amount_filled": Decimal("0"), + "status": "CANCELED", + "time_in_force": "GTC", + "operation": exchange.OrderOperation.SELL, + "type": "LIMIT", + }, + ), + ], +) def test_cancel_order( - pair, order_id, client_order_id, response_payload, expected_attrs, binance_http_api_mock, binance_exchange + pair, order_id, client_order_id, response_payload, expected_attrs, binance_http_api_mock, binance_exchange ): binance_http_api_mock.delete( re.compile(r"http://binance.mock/api/v3/order\\?.*"), status=200, payload=response_payload @@ -548,7 +560,7 @@ async def test_main(): "timeInForce": "GTC", "type": "LIMIT", "updateTime": 1676482455273, - "workingTime": 1676482455273 + "workingTime": 1676482455273, } ], { @@ -568,11 +580,9 @@ async def test_main(): "type": "LIMIT", }, ), - ] + ], ) -def test_get_open_orders( - pair, open_orders_payload, expected_first_open_order, binance_http_api_mock, binance_exchange -): +def test_get_open_orders(pair, open_orders_payload, expected_first_open_order, binance_http_api_mock, binance_exchange): binance_http_api_mock.get( re.compile(r"http://binance.mock/api/v3/openOrders\\?.*"), status=200, payload=open_orders_payload ) @@ -584,71 +594,76 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("create_order_fun, response_payload, expected_attrs", [ - ( - lambda e: e.spot_account.create_oco_order( - exchange.OrderOperation.SELL, pair.Pair("BTC", "USDT"), Decimal("0.001"), Decimal("23000"), - Decimal("10000"), stop_limit_price=Decimal("10000") +@pytest.mark.parametrize( + "create_order_fun, response_payload, expected_attrs", + [ + ( + lambda e: e.spot_account.create_oco_order( + exchange.OrderOperation.SELL, + pair.Pair("BTC", "USDT"), + Decimal("0.001"), + Decimal("23000"), + Decimal("10000"), + stop_limit_price=Decimal("10000"), + ), + { + "orderListId": 77826706, + "contingencyType": "OCO", + "listStatusType": "EXEC_STARTED", + "listOrderStatus": "EXECUTING", + "listClientOrderId": "9wQuvisHlbJPQOwrJLhqgn", + "transactionTime": 1668478867902, + "symbol": "BTCUSDT", + "orders": [ + {"symbol": "BTCUSDT", "orderId": 15535167360, "clientOrderId": "bUDxpWHguNICQ7MGe4bAxK"}, + {"symbol": "BTCUSDT", "orderId": 15535167361, "clientOrderId": "Eq4WPPmCLE0Vp1P4XuXG3G"}, + ], + "orderReports": [ + { + "symbol": "BTCUSDT", + "orderId": 15535167360, + "orderListId": 77826706, + "clientOrderId": "bUDxpWHguNICQ7MGe4bAxK", + "transactTime": 1668478867902, + "price": "10000.00000000", + "origQty": "0.00100000", + "executedQty": "0.00000000", + "cummulativeQuoteQty": "0.00000000", + "status": "NEW", + "timeInForce": "GTC", + "type": "STOP_LOSS_LIMIT", + "side": "SELL", + "stopPrice": "10000.00000000", + }, + { + "symbol": "BTCUSDT", + "orderId": 15535167361, + "orderListId": 77826706, + "clientOrderId": "Eq4WPPmCLE0Vp1P4XuXG3G", + "transactTime": 1668478867902, + "price": "23000.00000000", + "origQty": "0.00100000", + "executedQty": "0.00000000", + "cummulativeQuoteQty": "0.00000000", + "status": "NEW", + "timeInForce": "GTC", + "type": "LIMIT_MAKER", + "side": "SELL", + }, + ], + }, + { + "order_list_id": "77826706", + "client_order_list_id": "9wQuvisHlbJPQOwrJLhqgn", + "datetime": datetime.datetime(2022, 11, 15, 2, 21, 7, 902000).replace(tzinfo=datetime.timezone.utc), + "is_open": True, + "limit_order_id": "15535167361", + "stop_loss_order_id": "15535167360", + }, ), - { - "orderListId": 77826706, - "contingencyType": "OCO", - "listStatusType": "EXEC_STARTED", - "listOrderStatus": "EXECUTING", - "listClientOrderId": "9wQuvisHlbJPQOwrJLhqgn", - "transactionTime": 1668478867902, - "symbol": "BTCUSDT", - "orders": [ - {"symbol": "BTCUSDT", "orderId": 15535167360, "clientOrderId": "bUDxpWHguNICQ7MGe4bAxK"}, - {"symbol": "BTCUSDT", "orderId": 15535167361, "clientOrderId": "Eq4WPPmCLE0Vp1P4XuXG3G"} - ], - "orderReports": [ - { - "symbol": "BTCUSDT", - "orderId": 15535167360, - "orderListId": 77826706, - "clientOrderId": "bUDxpWHguNICQ7MGe4bAxK", - "transactTime": 1668478867902, - "price": "10000.00000000", - "origQty": "0.00100000", - "executedQty": "0.00000000", - "cummulativeQuoteQty": "0.00000000", - "status": "NEW", - "timeInForce": "GTC", - "type": "STOP_LOSS_LIMIT", - "side": "SELL", - "stopPrice": "10000.00000000" - }, - { - "symbol": "BTCUSDT", - "orderId": 15535167361, - "orderListId": 77826706, - "clientOrderId": "Eq4WPPmCLE0Vp1P4XuXG3G", - "transactTime": 1668478867902, - "price": "23000.00000000", - "origQty": "0.00100000", - "executedQty": "0.00000000", - "cummulativeQuoteQty": "0.00000000", - "status": "NEW", - "timeInForce": "GTC", - "type": "LIMIT_MAKER", - "side": "SELL" - } - ] - }, - { - "order_list_id": "77826706", - "client_order_list_id": "9wQuvisHlbJPQOwrJLhqgn", - "datetime": datetime.datetime(2022, 11, 15, 2, 21, 7, 902000).replace(tzinfo=datetime.timezone.utc), - "is_open": True, - "limit_order_id": "15535167361", - "stop_loss_order_id": "15535167360", - }, - ), -]) -def test_create_oco_order( - create_order_fun, response_payload, expected_attrs, binance_http_api_mock, binance_exchange -): + ], +) +def test_create_oco_order(create_order_fun, response_payload, expected_attrs, binance_http_api_mock, binance_exchange): binance_http_api_mock.post( re.compile(r"http://binance.mock/api/v3/order/oco\\?.*"), status=200, payload=response_payload ) @@ -661,31 +676,37 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("pair, order_list_id, client_order_list_id, response_payload, expected_attrs", [ - ( - pair.Pair("BTC", "USDT"), "77862615", None, - CANCELED_OCO_ORDER_RESPONSE, - { - "order_list_id": "77862615", - "client_order_list_id": "0A8oF9T3k96l6lqzqGOIfB", - "datetime": datetime.datetime(2022, 11, 15, 15, 53, 3, 935000).replace(tzinfo=datetime.timezone.utc), - "is_open": False, - } - ), - ( - pair.Pair("BTC", "USDT"), None, "0A8oF9T3k96l6lqzqGOIfB", - CANCELED_OCO_ORDER_RESPONSE, - { - "order_list_id": "77862615", - "client_order_list_id": "0A8oF9T3k96l6lqzqGOIfB", - "datetime": datetime.datetime(2022, 11, 15, 15, 53, 3, 935000).replace(tzinfo=datetime.timezone.utc), - "is_open": False, - } - ) -]) +@pytest.mark.parametrize( + "pair, order_list_id, client_order_list_id, response_payload, expected_attrs", + [ + ( + pair.Pair("BTC", "USDT"), + "77862615", + None, + CANCELED_OCO_ORDER_RESPONSE, + { + "order_list_id": "77862615", + "client_order_list_id": "0A8oF9T3k96l6lqzqGOIfB", + "datetime": datetime.datetime(2022, 11, 15, 15, 53, 3, 935000).replace(tzinfo=datetime.timezone.utc), + "is_open": False, + }, + ), + ( + pair.Pair("BTC", "USDT"), + None, + "0A8oF9T3k96l6lqzqGOIfB", + CANCELED_OCO_ORDER_RESPONSE, + { + "order_list_id": "77862615", + "client_order_list_id": "0A8oF9T3k96l6lqzqGOIfB", + "datetime": datetime.datetime(2022, 11, 15, 15, 53, 3, 935000).replace(tzinfo=datetime.timezone.utc), + "is_open": False, + }, + ), + ], +) def test_cancel_oco_order( - pair, order_list_id, client_order_list_id, response_payload, expected_attrs, - binance_http_api_mock, binance_exchange + pair, order_list_id, client_order_list_id, response_payload, expected_attrs, binance_http_api_mock, binance_exchange ): binance_http_api_mock.delete( re.compile(r"http://binance.mock/api/v3/orderList\\?.*"), status=200, payload=response_payload @@ -701,28 +722,33 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("order_list_id, client_order_list_id, response_payload, expected_attrs", [ - ( - "77826706", None, - OCO_ORDER_INFO_RESPONSE, - { - "order_list_id": "77826706", - "client_order_list_id": "9wQuvisHlbJPQOwrJLhqgn", - "is_open": False, - } - ), - ( - None, "9wQuvisHlbJPQOwrJLhqgn", - OCO_ORDER_INFO_RESPONSE, - { - "order_list_id": "77826706", - "client_order_list_id": "9wQuvisHlbJPQOwrJLhqgn", - "is_open": False, - } - ), -]) +@pytest.mark.parametrize( + "order_list_id, client_order_list_id, response_payload, expected_attrs", + [ + ( + "77826706", + None, + OCO_ORDER_INFO_RESPONSE, + { + "order_list_id": "77826706", + "client_order_list_id": "9wQuvisHlbJPQOwrJLhqgn", + "is_open": False, + }, + ), + ( + None, + "9wQuvisHlbJPQOwrJLhqgn", + OCO_ORDER_INFO_RESPONSE, + { + "order_list_id": "77826706", + "client_order_list_id": "9wQuvisHlbJPQOwrJLhqgn", + "is_open": False, + }, + ), + ], +) def test_oco_order_info( - order_list_id, client_order_list_id, response_payload, expected_attrs, binance_http_api_mock, binance_exchange + order_list_id, client_order_list_id, response_payload, expected_attrs, binance_http_api_mock, binance_exchange ): binance_http_api_mock.get( re.compile(r"http://binance.mock/api/v3/orderList\\?.*"), status=200, payload=response_payload diff --git a/tests/test_binance_order_book.py b/tests/test_binance_order_book.py index a31efa0..f61fc1a 100644 --- a/tests/test_binance_order_book.py +++ b/tests/test_binance_order_book.py @@ -32,34 +32,8 @@ ORDER_BOOK = { "lastUpdateId": 27229732069, - "bids": [ - [ - "16757.47000000", - "0.04893000" - ], - [ - "16757.41000000", - "0.00073000" - ], - [ - "16756.52000000", - "0.00690000" - ] - ], - "asks": [ - [ - "16758.13000000", - "0.00682000" - ], - [ - "16758.55000000", - "0.04963000" - ], - [ - "16759.25000000", - "0.00685000" - ] - ] + "bids": [["16757.47000000", "0.04893000"], ["16757.41000000", "0.00073000"], ["16756.52000000", "0.00690000"]], + "asks": [["16758.13000000", "0.00682000"], ["16758.55000000", "0.04963000"], ["16759.25000000", "0.00685000"]], } @@ -84,10 +58,13 @@ async def test_main(): async with aiohttp.ClientSession() as session: realtime_dispatcher.subscribe( order_book.PollOrderBook( - p, 0.5, limit=100, session=session, - config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}} + p, + 0.5, + limit=100, + session=session, + config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}, ), - on_order_book_event + on_order_book_event, ) await realtime_dispatcher.run() @@ -102,9 +79,12 @@ async def test_main(): assert last_ob.asks[1].volume == Decimal("0.04963") -@pytest.mark.parametrize("response_status, response_body", [ - (500, {}), -]) +@pytest.mark.parametrize( + "response_status, response_body", + [ + (500, {}), + ], +) def test_unhandled_exception_during_poll( response_status, response_body, binance_http_api_mock, realtime_dispatcher, caplog ): @@ -124,8 +104,10 @@ async def on_error(self, error): async def test_main(): async with aiohttp.ClientSession() as session: poller = order_book.PollOrderBook( - pair.Pair("BTC", "USDT"), 0.5, session=session, - config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}} + pair.Pair("BTC", "USDT"), + 0.5, + session=session, + config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}, ) poller.on_error = lambda error: on_error(poller, error) realtime_dispatcher.subscribe(poller, on_order_book_event) diff --git a/tests/test_binance_tools.py b/tests/test_binance_tools.py index b34641f..8ff8271 100644 --- a/tests/test_binance_tools.py +++ b/tests/test_binance_tools.py @@ -46,7 +46,7 @@ def test_download_ohlc(binance_http_api_mock, capsys): 194010, "8946.95553500", "64597785.21233434", - "0" + "0", ], [ 1577923200000, @@ -60,7 +60,7 @@ def test_download_ohlc(binance_http_api_mock, capsys): 302667, "15141.61134000", "107060829.07806464", - "0" + "0", ], # This one closes in the future and should be skipped. [ @@ -75,19 +75,21 @@ def test_download_ohlc(binance_http_api_mock, capsys): 194010, "8946.95553500", "64597785.21233434", - "0" + "0", ], - - ] + ], ) async def test_main(): await download_bars.main( params=["-c", "BTCUSDT", "-p", "1d", "-s", "2020-01-01", "-e", "2020-01-01"], - config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}} + config_overrides={"api": {"http": {"base_url": "http://binance.mock/"}}}, ) - assert capsys.readouterr().out == """datetime,open,high,low,close,volume + assert ( + capsys.readouterr().out + == """datetime,open,high,low,close,volume 2020-01-01 00:00:00,7195.24000000,7255.00000000,7175.15000000,7200.85000000,16792.38816500 """ + ) asyncio.run(test_main()) diff --git a/tests/test_binance_trades.py b/tests/test_binance_trades.py index 2a7771a..99b61d3 100644 --- a/tests/test_binance_trades.py +++ b/tests/test_binance_trades.py @@ -38,8 +38,8 @@ "a": 16081955890, "T": 1669932275174, "m": False, - "M": True - } + "M": True, + }, } diff --git a/tests/test_binance_user_data.py b/tests/test_binance_user_data.py index ad68bed..783f848 100644 --- a/tests/test_binance_user_data.py +++ b/tests/test_binance_user_data.py @@ -70,7 +70,7 @@ "w": False, "x": "TRADE", "z": "0.00010000", - } + }, } OTHER_MSG = { @@ -79,12 +79,12 @@ "B": [ {"a": "BTC", "f": "0.00020639", "l": "0.00000000"}, {"a": "BNB", "f": "0.00000324", "l": "0.00000000"}, - {"a": "USDT", "f": "2495.07648830", "l": "0.00000000"} + {"a": "USDT", "f": "2495.07648830", "l": "0.00000000"}, ], "E": 1735070948134, "e": "outboundAccountPosition", - "u": 1735070948133 - } + "u": 1735070948133, + }, } LISTEN_KEY_EXPIRED_MSG = { @@ -93,15 +93,18 @@ "e": "listenKeyExpired", "E": "1699596037418", "listenKey": "OfYGbUzi3PraNagEkdKuFwUHn48brFsItTdsuiIXrucEvD0rhRXZ7I6URWfE8YE8", - } + }, } -@pytest.mark.parametrize("account_attr, user_data_stream_url", [ - ("spot_account", "http://binance.mock/api/v3/userDataStream"), - ("cross_margin_account", "http://binance.mock/sapi/v1/userDataStream"), - ("isolated_margin_account", "http://binance.mock/sapi/v1/userDataStream/isolated"), -]) +@pytest.mark.parametrize( + "account_attr, user_data_stream_url", + [ + ("spot_account", "http://binance.mock/api/v3/userDataStream"), + ("cross_margin_account", "http://binance.mock/sapi/v1/userDataStream"), + ("isolated_margin_account", "http://binance.mock/sapi/v1/userDataStream/isolated"), + ], +) def test_websocket_ok(account_attr, user_data_stream_url, realtime_dispatcher, binance_http_api_mock, caplog): caplog.set_level(logging.DEBUG) order_update_event = None @@ -154,10 +157,7 @@ async def server_main(websocket): async def test_main(): for meth in [binance_http_api_mock.post, binance_http_api_mock.put]: - meth( - re.compile(fr"{user_data_stream_url}.*"), - status=200, payload={"listenKey": listen_key}, repeat=True - ) + meth(re.compile(rf"{user_data_stream_url}.*"), status=200, payload={"listenKey": listen_key}, repeat=True) async with websockets.serve(server_main, "127.0.0.1", 0) as server: ws_uri = "ws://{}:{}/".format(*server.sockets[0].getsockname()) diff --git a/tests/test_bitstamp_bars.py b/tests/test_bitstamp_bars.py index 6107459..686b226 100644 --- a/tests/test_bitstamp_bars.py +++ b/tests/test_bitstamp_bars.py @@ -45,35 +45,38 @@ async def server_main(websocket): # Keep on sending trade events while the connection is open. while websocket.state == websockets.protocol.State.OPEN: timestamp = time.time() - await websocket.send(json.dumps({ - "event": "trade", - "channel": channel, - "data": { - "id": 246612672, - "timestamp": str(int(timestamp)), - "amount": 1, - "amount_str": "1", - "price": 1000, - "price_str": "1000", - "type": 0, - "microtimestamp": str(int(timestamp * 1e6)), - "buy_order_id": 1530834271539201, - "sell_order_id": 1530834150440960 - } - })) + await websocket.send( + json.dumps( + { + "event": "trade", + "channel": channel, + "data": { + "id": 246612672, + "timestamp": str(int(timestamp)), + "amount": 1, + "amount_str": "1", + "price": 1000, + "price_str": "1000", + "type": 0, + "microtimestamp": str(int(timestamp * 1e6)), + "buy_order_id": 1530834271539201, + "sell_order_id": 1530834150440960, + }, + } + ) + ) await asyncio.sleep(0.4) async def test_main(): async with websockets.serve(server_main, "127.0.0.1", 0) as server: ws_uri = "ws://{}:{}/".format(*server.sockets[0].getsockname()) e = exchange.Exchange( - realtime_dispatcher, "key", "secret", + realtime_dispatcher, + "key", + "secret", config_overrides={ - "api": { - "http": {"base_url": "http://bitstamp.mock/"}, - "websockets": {"base_url": ws_uri} - } - } + "api": {"http": {"base_url": "http://bitstamp.mock/"}, "websockets": {"base_url": ws_uri}} + }, ) e.subscribe_to_bar_events(p, 1, on_bar_event) diff --git a/tests/test_bitstamp_client.py b/tests/test_bitstamp_client.py index 3d6314c..3f186dc 100644 --- a/tests/test_bitstamp_client.py +++ b/tests/test_bitstamp_client.py @@ -30,7 +30,8 @@ def bitstamp_http_api_mock(): def test_get_ohlc_data_using_end(bitstamp_http_api_mock): bitstamp_http_api_mock.get( - "http://bitstamp.mock/api/v2/ohlc/btcusd/?end=1451606400&limit=1&step=60", status=200, + "http://bitstamp.mock/api/v2/ohlc/btcusd/?end=1451606400&limit=1&step=60", + status=200, payload={ "data": { "ohlc": [ @@ -40,39 +41,38 @@ def test_get_ohlc_data_using_end(bitstamp_http_api_mock): "low": "430.89", "open": "430.89", "timestamp": "1451606400", - "volume": "0.00000000" + "volume": "0.00000000", } ], - "pair": "BTC/USD" + "pair": "BTC/USD", } - } + }, ) async def test_main(): - c = client.APIClient( - config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}} - ) + c = client.APIClient(config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}}) response = await c.get_ohlc_data("btcusd", 60, end=1451606400, limit=1) assert len(response["data"]["ohlc"]) == 1 asyncio.run(test_main()) -@pytest.mark.parametrize("status_code, response_body, expected", [ - (403, {}, "403 Forbidden"), - (200, {"status": "error", "reason": "Order not found"}, "Order not found"), - (200, {"error": "Order not found"}, "Order not found"), - (200, {"code": "Order not found", "errors": "blabla"}, "Order not found"), -]) +@pytest.mark.parametrize( + "status_code, response_body, expected", + [ + (403, {}, "403 Forbidden"), + (200, {"status": "error", "reason": "Order not found"}, "Order not found"), + (200, {"error": "Order not found"}, "Order not found"), + (200, {"code": "Order not found", "errors": "blabla"}, "Order not found"), + ], +) def test_error_parsing(status_code, response_body, expected, bitstamp_http_api_mock): bitstamp_http_api_mock.get( "http://bitstamp.mock/api/v2/order_book/btcusd/", status=status_code, payload=response_body ) async def test_main(): - c = client.APIClient( - config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}} - ) + c = client.APIClient(config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}}) with pytest.raises(client.Error) as excinfo: await c.get_order_book("btcusd") assert str(excinfo.value) == expected diff --git a/tests/test_bitstamp_csv_bars.py b/tests/test_bitstamp_csv_bars.py index dca705d..3dfa664 100644 --- a/tests/test_bitstamp_csv_bars.py +++ b/tests/test_bitstamp_csv_bars.py @@ -25,10 +25,13 @@ from basana.external.bitstamp.csv import bars as csv_bars -@pytest.mark.parametrize("filename", [ - "bitstamp_btcusd_day_2015.csv", - "bitstamp_btcusd_day_2015.csv.utf16", -]) +@pytest.mark.parametrize( + "filename", + [ + "bitstamp_btcusd_day_2015.csv", + "bitstamp_btcusd_day_2015.csv.utf16", + ], +) def test_daily_bars_from_csv(filename, backtesting_dispatcher): bars = [] events = [] diff --git a/tests/test_bitstamp_exchange.py b/tests/test_bitstamp_exchange.py index 24f317b..95a29ef 100644 --- a/tests/test_bitstamp_exchange.py +++ b/tests/test_bitstamp_exchange.py @@ -42,7 +42,7 @@ "usdt": "4900.13562", "fee": "0.00000", "tid": 248447671, - "type": 2 + "type": 2, }, { "usd": "2500.03351899", @@ -51,7 +51,7 @@ "usdt": "2500.18353", "fee": "0.00000", "tid": 248447672, - "type": 2 + "type": 2, }, { "usd": "2306.24286734", @@ -60,7 +60,7 @@ "usdt": "2306.26593", "fee": "0.00000", "tid": 248447673, - "type": 2 + "type": 2, }, { "usd": "5315.35100298", @@ -69,8 +69,8 @@ "usdt": "5315.29785", "fee": "0.00000", "tid": 248447674, - "type": 2 - } + "type": 2, + }, ] ORDER_STATUS_99999999 = { @@ -87,7 +87,7 @@ "usdt": "4900.13562", "fee": "0.12000", "tid": 1, - "type": 2 + "type": 2, }, ] @@ -114,12 +114,7 @@ def order_status(url, **kwargs): def cancel_order(url, **kwargs): if int(kwargs["data"]["id"]) == 1538604691881987: status = 200 - response = { - "id": 1538604691881987, - "amount": 0.00319028, - "price": 17500, - "type": 0 - } + response = {"id": 1538604691881987, "amount": 0.00319028, "price": 17500, "type": 0} else: status = 200 response = {"error": "Order not found."} @@ -128,19 +123,23 @@ def cancel_order(url, **kwargs): with aioresponses.aioresponses() as m: m.post( - "http://bitstamp.mock/api/v2/account_balances/", status=200, payload=[ + "http://bitstamp.mock/api/v2/account_balances/", + status=200, + payload=[ {"available": "0.01374", "currency": "pax", "total": "0.01374", "reserved": "0.00000"}, {"available": "28.92", "currency": "usd", "total": "28.92", "reserved": "0.00"}, - {"available": "0.97899", "currency": "usdc", "total": "0.97899", "reserved": "0.00000"} - ] + {"available": "0.97899", "currency": "usdc", "total": "0.97899", "reserved": "0.00000"}, + ], ) m.post( - "http://bitstamp.mock/api/v2/account_balances/usd/", status=200, payload={ - "available": "28.92", "currency": "usd", "total": "28.92", "reserved": "0.00" - } + "http://bitstamp.mock/api/v2/account_balances/usd/", + status=200, + payload={"available": "28.92", "currency": "usd", "total": "28.92", "reserved": "0.00"}, ) m.post( - "http://bitstamp.mock/api/v2/open_orders/all/", status=200, payload=[ + "http://bitstamp.mock/api/v2/open_orders/all/", + status=200, + payload=[ { "price": "1200.0", "currency_pair": "ETH/USD", @@ -148,12 +147,14 @@ def cancel_order(url, **kwargs): "amount": "0.01204166", "amount_at_create": "0.01204166", "type": "0", - "id": "1535407273615360" + "id": "1535407273615360", } - ] + ], ) m.post( - "http://bitstamp.mock/api/v2/open_orders/ethusd/", status=200, payload=[ + "http://bitstamp.mock/api/v2/open_orders/ethusd/", + status=200, + payload=[ { "price": "1200.0", "currency_pair": "ETH/USD", @@ -161,46 +162,54 @@ def cancel_order(url, **kwargs): "amount": "0.01204166", "amount_at_create": "0.01204166", "type": "0", - "id": "1535407273615360" + "id": "1535407273615360", } - ] + ], ) m.post("http://bitstamp.mock/api/v2/order_status/", callback=order_status) m.post("http://bitstamp.mock/api/v2/cancel_order/", callback=cancel_order) m.post( - "http://bitstamp.mock/api/v2/buy/market/btcpax/", status=200, payload={ + "http://bitstamp.mock/api/v2/buy/market/btcpax/", + status=200, + payload={ "id": "1539419698798592", "datetime": "2022-09-30 16:47:12.583000", "type": "0", "amount": "0.00100000", "price": "19381", "client_order_id": "51557545381C4997BC452AE1E48E0D88", - } + }, ) m.post( - "http://bitstamp.mock/api/v2/buy/btcpax/", status=200, payload={ + "http://bitstamp.mock/api/v2/buy/btcpax/", + status=200, + payload={ "id": "1538955091660800", "datetime": "2022-09-30 16:47:12.583000", "type": "0", "amount": "1.00000000", "price": "10", "client_order_id": "51557545381C4997BC452AE1E48E0D88", - } + }, ) m.post( - "http://bitstamp.mock/api/v2/sell/instant/btcpax/", status=200, payload={ + "http://bitstamp.mock/api/v2/sell/instant/btcpax/", + status=200, + payload={ "id": "1540371958628352", "datetime": "2022-09-30 16:47:12.583000", "type": "1", "amount": "13.00000000", "price": "19954", "client_order_id": "51557545381C4997BC452AE1E48E0D88", - } + }, ) m.get( - "http://bitstamp.mock/api/v2/ticker/btcusd/", status=200, payload={ + "http://bitstamp.mock/api/v2/ticker/btcusd/", + status=200, + payload={ "timestamp": "1666808829", "open": "20096", "high": "21012", @@ -211,23 +220,27 @@ def cancel_order(url, **kwargs): "bid": "20781", "ask": "20782", "open_24": "20250", - "percent_change_24": "2.72" - } + "percent_change_24": "2.72", + }, ) - m.get("http://bitstamp.mock/api/v2/trading-pairs-info/", status=200, payload=[ - { - "name": "BTC/USD", - "url_symbol": "btcusd", - "base_decimals": 8, - "counter_decimals": 0, - "instant_order_counter_decimals": 2, - "minimum_order": "10 USD", - "trading": "Enabled", - "instant_and_market_orders": "Enabled", - "description": "Bitcoin / U.S. dollar" - }, - ]) + m.get( + "http://bitstamp.mock/api/v2/trading-pairs-info/", + status=200, + payload=[ + { + "name": "BTC/USD", + "url_symbol": "btcusd", + "base_decimals": 8, + "counter_decimals": 0, + "instant_order_counter_decimals": 2, + "minimum_order": "10 USD", + "trading": "Enabled", + "instant_and_market_orders": "Enabled", + "description": "Bitcoin / U.S. dollar", + }, + ], + ) yield m @@ -235,8 +248,11 @@ def cancel_order(url, **kwargs): @pytest.fixture() def bitstamp_exchange(realtime_dispatcher): return exchange.Exchange( - realtime_dispatcher, "api_key", "api_secret", tb=token_bucket.TokenBucketLimiter(10, 1, 10), - config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}} + realtime_dispatcher, + "api_key", + "api_secret", + tb=token_bucket.TokenBucketLimiter(10, 1, 10), + config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}}, ) @@ -276,24 +292,33 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("order_id, expected_attrs", [ - (1536137941123072, { - "id": "1536137941123072", - "is_open": False, - "amount_filled": Decimal("15021.88293"), - "amount_remaining": Decimal("0"), - "fill_price": Decimal("0.99996"), - "fees": {}, - }), - (99999999, { - "id": "99999999", - "is_open": False, - "amount_filled": Decimal("4900.13562"), - "amount_remaining": Decimal("0"), - "fill_price": Decimal("0.99993"), - "fees": {"USD": Decimal("0.12")}, - }), -]) +@pytest.mark.parametrize( + "order_id, expected_attrs", + [ + ( + 1536137941123072, + { + "id": "1536137941123072", + "is_open": False, + "amount_filled": Decimal("15021.88293"), + "amount_remaining": Decimal("0"), + "fill_price": Decimal("0.99996"), + "fees": {}, + }, + ), + ( + 99999999, + { + "id": "99999999", + "is_open": False, + "amount_filled": Decimal("4900.13562"), + "amount_remaining": Decimal("0"), + "fill_price": Decimal("0.99993"), + "fees": {"USD": Decimal("0.12")}, + }, + ), + ], +) def test_order_info(order_id, expected_attrs, bitstamp_http_api_mock, bitstamp_exchange): async def test_main(): order_info = await bitstamp_exchange.get_order_info(pair.Pair("USDT", "USD"), order_id=order_id) @@ -357,8 +382,11 @@ def test_explicit_session(bitstamp_http_api_mock, realtime_dispatcher): async def test_main(): async with aiohttp.ClientSession() as session: e = exchange.Exchange( - realtime_dispatcher, "api_key", "api_secret", session=session, - config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}} + realtime_dispatcher, + "api_key", + "api_secret", + session=session, + config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}}, ) balance = await e.get_balance("USD") @@ -387,32 +415,57 @@ async def test_main(): asyncio.run(test_main()) -@pytest.mark.parametrize("create_order_fun, expected_op, expected_amount, expected_price, expected_id", [ - ( - lambda e: e.create_market_order( - exchange.OrderOperation.BUY, pair.Pair("BTC", "PAX"), Decimal("0.00100000"), - client_order_id="51557545381C4997BC452AE1E48E0D88" +@pytest.mark.parametrize( + "create_order_fun, expected_op, expected_amount, expected_price, expected_id", + [ + ( + lambda e: e.create_market_order( + exchange.OrderOperation.BUY, + pair.Pair("BTC", "PAX"), + Decimal("0.00100000"), + client_order_id="51557545381C4997BC452AE1E48E0D88", + ), + exchange.OrderOperation.BUY, + Decimal("0.00100000"), + Decimal("19381"), + "1539419698798592", ), - exchange.OrderOperation.BUY, Decimal("0.00100000"), Decimal("19381"), "1539419698798592" - ), - ( - lambda e: e.create_limit_order( - exchange.OrderOperation.BUY, pair.Pair("BTC", "PAX"), Decimal("1"), Decimal("10"), - client_order_id="51557545381C4997BC452AE1E48E0D88" + ( + lambda e: e.create_limit_order( + exchange.OrderOperation.BUY, + pair.Pair("BTC", "PAX"), + Decimal("1"), + Decimal("10"), + client_order_id="51557545381C4997BC452AE1E48E0D88", + ), + exchange.OrderOperation.BUY, + Decimal("1"), + Decimal("10"), + "1538955091660800", ), - exchange.OrderOperation.BUY, Decimal("1"), Decimal("10"), "1538955091660800" - ), - ( - lambda e: e.create_instant_order( - exchange.OrderOperation.SELL, pair.Pair("BTC", "PAX"), Decimal("13"), amount_in_counter=True, - client_order_id="51557545381C4997BC452AE1E48E0D88" + ( + lambda e: e.create_instant_order( + exchange.OrderOperation.SELL, + pair.Pair("BTC", "PAX"), + Decimal("13"), + amount_in_counter=True, + client_order_id="51557545381C4997BC452AE1E48E0D88", + ), + exchange.OrderOperation.SELL, + Decimal("13"), + Decimal("19954"), + "1540371958628352", ), - exchange.OrderOperation.SELL, Decimal("13"), Decimal("19954"), "1540371958628352" - ), -]) + ], +) def test_order_requests( - create_order_fun, expected_op, expected_amount, expected_price, expected_id, - bitstamp_http_api_mock, bitstamp_exchange + create_order_fun, + expected_op, + expected_amount, + expected_price, + expected_id, + bitstamp_http_api_mock, + bitstamp_exchange, ): async def test_main(): order_created = await create_order_fun(bitstamp_exchange) diff --git a/tests/test_bitstamp_order_book.py b/tests/test_bitstamp_order_book.py index 1cd7bec..a8e9448 100644 --- a/tests/test_bitstamp_order_book.py +++ b/tests/test_bitstamp_order_book.py @@ -33,30 +33,22 @@ @pytest.fixture() def bitstamp_http_api_mock(): with aioresponses.aioresponses() as m: - m.get(re.compile(r'http://bitstamp.mock/api/v2/order_book/btcusd/.*'), status=200, payload={ - "timestamp": "1662146819", - "microtimestamp": "1662146819514365", - "bids": [ - [ - "19822", - "0.15000000" + m.get( + re.compile(r"http://bitstamp.mock/api/v2/order_book/btcusd/.*"), + status=200, + payload={ + "timestamp": "1662146819", + "microtimestamp": "1662146819514365", + "bids": [ + ["19822", "0.15000000"], + ["19820", "0.88755147"], ], - [ - "19820", - "0.88755147" + "asks": [ + ["19834", "0.81049238"], + ["19835", "0.15000000"], ], - ], - "asks": [ - [ - "19834", - "0.81049238" - ], - [ - "19835", - "0.15000000" - ], - ] - }) + }, + ) yield m @@ -74,33 +66,34 @@ async def on_order_book_event(order_book_event): async def server_main(websocket): message = json.loads(await websocket.recv()) # We expect to receive a subscription request to start. - if message == { - "event": "bts:subscribe", - "data": {"channel": "order_book_btcusd"} - }: + if message == {"event": "bts:subscribe", "data": {"channel": "order_book_btcusd"}}: await websocket.send(json.dumps({"event": "bts:subscription_succeeded"})) # Keep on sending order book events while the connection is open. while websocket.state == websockets.protocol.State.OPEN: - await websocket.send(json.dumps({ - "event": "data", - "channel": "order_book_btcusd", - "data": { - "timestamp": "1662146819", - "microtimestamp": "1662146819514365", - "bids": [ - [ - "20000.01", - "0.00000001", - ] - ], - "asks": [ - [ - "19999.99", - "0.00000002", - ] - ] - } - })) + await websocket.send( + json.dumps( + { + "event": "data", + "channel": "order_book_btcusd", + "data": { + "timestamp": "1662146819", + "microtimestamp": "1662146819514365", + "bids": [ + [ + "20000.01", + "0.00000001", + ] + ], + "asks": [ + [ + "19999.99", + "0.00000002", + ] + ], + }, + } + ) + ) await asyncio.sleep(0.1) async def test_main(): @@ -123,10 +116,13 @@ async def test_main(): assert Decimal(last_ob.asks[0].volume) == Decimal("0.00000002") -@pytest.mark.parametrize("server_message", [ - {}, - {"event": "xyz"}, -]) +@pytest.mark.parametrize( + "server_message", + [ + {}, + {"event": "xyz"}, + ], +) def test_unknown_message_in_websocket(server_message, realtime_dispatcher, caplog): p = pair.Pair("BTC", "USD") @@ -140,10 +136,7 @@ async def on_order_book_event(*args, **kwargs): async def server_main(websocket): # We expect to receive a subscription request to start. message = json.loads(await websocket.recv()) - if message == { - "event": "bts:subscribe", - "data": {"channel": "order_book_btcusd"} - }: + if message == {"event": "bts:subscribe", "data": {"channel": "order_book_btcusd"}}: await websocket.send(json.dumps({"event": "bts:subscription_succeeded"})) await websocket.send(json.dumps(server_message)) else: @@ -206,10 +199,13 @@ async def test_main(): async with aiohttp.ClientSession() as session: realtime_dispatcher.subscribe( order_book.PollOrderBook( - p, 0.5, group=1, session=session, - config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}} + p, + 0.5, + group=1, + session=session, + config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}}, ), - on_order_book_event + on_order_book_event, ) await realtime_dispatcher.run() @@ -225,9 +221,12 @@ async def test_main(): assert last_ob.asks[1].volume == Decimal("0.15000000") -@pytest.mark.parametrize("response_status, response_body", [ - (500, {}), -]) +@pytest.mark.parametrize( + "response_status, response_body", + [ + (500, {}), + ], +) def test_unhandled_exception_during_poll( response_status, response_body, bitstamp_http_api_mock, realtime_dispatcher, caplog ): @@ -246,8 +245,10 @@ async def on_error(poller, error): async def test_main(): async with aiohttp.ClientSession() as session: poller = order_book.PollOrderBook( - pair.Pair("BTC", "PAX"), 0.5, session=session, - config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}} + pair.Pair("BTC", "PAX"), + 0.5, + session=session, + config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}}, ) poller.on_error = lambda error: on_error(poller, error) realtime_dispatcher.subscribe(poller, on_order_book_event) diff --git a/tests/test_bitstamp_orders.py b/tests/test_bitstamp_orders.py index eae0694..d7988af 100644 --- a/tests/test_bitstamp_orders.py +++ b/tests/test_bitstamp_orders.py @@ -54,35 +54,38 @@ async def server_main(websocket): # Keep on sending order events while the connection is open. while websocket.state == websockets.protocol.State.OPEN: for event in ["order_created", "order_changed", "order_deleted"]: - await websocket.send(json.dumps({ - "data": { - "id": 1531241723363332, - "id_str": "1531241723363332", - "order_type": 1, - "datetime": "1662673286", - "microtimestamp": "1662673286025000", - "amount": 0.28435528, - "amount_str": "0.28435528", - "amount_at_create": "1.28435528", - "price": 19342, - "price_str": "19342" - }, - "channel": channel, - "event": event - })) + await websocket.send( + json.dumps( + { + "data": { + "id": 1531241723363332, + "id_str": "1531241723363332", + "order_type": 1, + "datetime": "1662673286", + "microtimestamp": "1662673286025000", + "amount": 0.28435528, + "amount_str": "0.28435528", + "amount_at_create": "1.28435528", + "price": 19342, + "price_str": "19342", + }, + "channel": channel, + "event": event, + } + ) + ) await asyncio.sleep(0.1) async def test_main(): async with websockets.serve(server_main, "127.0.0.1", 0) as server: ws_uri = "ws://{}:{}/".format(*server.sockets[0].getsockname()) e = exchange.Exchange( - realtime_dispatcher, "key", "secret", + realtime_dispatcher, + "key", + "secret", config_overrides={ - "api": { - "http": {"base_url": "http://bitstamp.mock/"}, - "websockets": {"base_url": ws_uri} - } - } + "api": {"http": {"base_url": "http://bitstamp.mock/"}, "websockets": {"base_url": ws_uri}} + }, ) if public_events: e.subscribe_to_public_order_events(p, on_order_event) @@ -104,10 +107,13 @@ async def test_main(): assert last_order.amount_filled == Decimal("1") -@pytest.mark.parametrize("server_message, expected_log", [ - ({"event": "bts:subscription_failed"}, "Error"), - ({"event": "bts:error"}, "Error"), -]) +@pytest.mark.parametrize( + "server_message, expected_log", + [ + ({"event": "bts:subscription_failed"}, "Error"), + ({"event": "bts:error"}, "Error"), + ], +) def test_error_in_websocket(server_message, expected_log, realtime_dispatcher, caplog): p = pair.Pair("BTC", "USD") @@ -130,13 +136,12 @@ async def test_main(timeout): async with websockets.serve(server_main, "127.0.0.1", 0) as server: ws_uri = "ws://{}:{}/".format(*server.sockets[0].getsockname()) e = exchange.Exchange( - realtime_dispatcher, "key", "secret", + realtime_dispatcher, + "key", + "secret", config_overrides={ - "api": { - "http": {"base_url": "http://bitstamp.mock/"}, - "websockets": {"base_url": ws_uri} - } - } + "api": {"http": {"base_url": "http://bitstamp.mock/"}, "websockets": {"base_url": ws_uri}} + }, ) e.subscribe_to_public_order_events(p, on_order_event) await asyncio.gather(realtime_dispatcher.run(), stop_on_error(timeout)) @@ -146,8 +151,9 @@ async def test_main(timeout): def test_authentication_fails(bitstamp_http_api_mock, realtime_dispatcher, caplog): bitstamp_http_api_mock.post( - "http://bitstamp.mock/api/v2/websockets_token/", status=403, - payload={"status": "error", "reason": "Invalid signature", "code": "API0005"} + "http://bitstamp.mock/api/v2/websockets_token/", + status=403, + payload={"status": "error", "reason": "Invalid signature", "code": "API0005"}, ) p = pair.Pair("BTC", "USD") @@ -167,13 +173,12 @@ async def test_main(timeout): async with websockets.serve(server_main, "127.0.0.1", 0) as server: ws_uri = "ws://{}:{}/".format(*server.sockets[0].getsockname()) e = exchange.Exchange( - realtime_dispatcher, "key", "secret", + realtime_dispatcher, + "key", + "secret", config_overrides={ - "api": { - "http": {"base_url": "http://bitstamp.mock/"}, - "websockets": {"base_url": ws_uri} - } - } + "api": {"http": {"base_url": "http://bitstamp.mock/"}, "websockets": {"base_url": ws_uri}} + }, ) e.subscribe_to_private_order_events(p, on_order_event) diff --git a/tests/test_bitstamp_tools.py b/tests/test_bitstamp_tools.py index 027b1b4..fd25906 100644 --- a/tests/test_bitstamp_tools.py +++ b/tests/test_bitstamp_tools.py @@ -26,25 +26,41 @@ @pytest.fixture() def bitstamp_http_api_mock(): with aioresponses.aioresponses() as m: - m.get(re.compile(r'http://bitstamp.mock/api/v2/ohlc/btcusd/.*'), status=200, payload={ - "data": { - "ohlc": [ - { - "close": "433.82", "high": "436", "low": "427.2", "open": "430.89", - "timestamp": "1451606400", "volume": "3788.11117403" - }, - { - "close": "433.55", "high": "435.99", "low": "430.42", "open": "434.87", - "timestamp": "1451692800", "volume": "2972.06344935" - }, - { - "close": "431.04", "high": "434.09", "low": "424.06", "open": "433.2", - "timestamp": "1451779200", "volume": "4571.09703841" - } - ], - "pair": "BTC/USD" - } - }) + m.get( + re.compile(r"http://bitstamp.mock/api/v2/ohlc/btcusd/.*"), + status=200, + payload={ + "data": { + "ohlc": [ + { + "close": "433.82", + "high": "436", + "low": "427.2", + "open": "430.89", + "timestamp": "1451606400", + "volume": "3788.11117403", + }, + { + "close": "433.55", + "high": "435.99", + "low": "430.42", + "open": "434.87", + "timestamp": "1451692800", + "volume": "2972.06344935", + }, + { + "close": "431.04", + "high": "434.09", + "low": "424.06", + "open": "433.2", + "timestamp": "1451779200", + "volume": "4571.09703841", + }, + ], + "pair": "BTC/USD", + } + }, + ) yield m @@ -52,10 +68,13 @@ def test_download_ohlc(bitstamp_http_api_mock, capsys): async def test_main(): await download_bars.main( params=["-c", "btcusd", "-p", "day", "-s", "2016-01-01", "-e", "2016-01-01"], - config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}} + config_overrides={"api": {"http": {"base_url": "http://bitstamp.mock/"}}}, ) - assert capsys.readouterr().out == """datetime,open,high,low,close,volume + assert ( + capsys.readouterr().out + == """datetime,open,high,low,close,volume 2016-01-01 00:00:00,430.89,436,427.2,433.82,3788.11117403 """ + ) asyncio.run(test_main()) diff --git a/tests/test_bitstamp_trades.py b/tests/test_bitstamp_trades.py index 7699212..ec0060d 100644 --- a/tests/test_bitstamp_trades.py +++ b/tests/test_bitstamp_trades.py @@ -51,35 +51,38 @@ async def server_main(websocket): await websocket.send(json.dumps({"event": "bts:subscription_succeeded"})) # Keep on sending trade events while the connection is open. while websocket.state == websockets.protocol.State.OPEN: - await websocket.send(json.dumps({ - "event": "trade", - "channel": channel, - "data": { - "id": 246612672, - "timestamp": "1662573810", - "amount": 0.374, - "amount_str": "0.37400000", - "price": 19034, - "price_str": "19034", - "type": 0, - "microtimestamp": "1662573810482000", - "buy_order_id": 1530834271539201, - "sell_order_id": 1530834150440960 - } - })) + await websocket.send( + json.dumps( + { + "event": "trade", + "channel": channel, + "data": { + "id": 246612672, + "timestamp": "1662573810", + "amount": 0.374, + "amount_str": "0.37400000", + "price": 19034, + "price_str": "19034", + "type": 0, + "microtimestamp": "1662573810482000", + "buy_order_id": 1530834271539201, + "sell_order_id": 1530834150440960, + }, + } + ) + ) await asyncio.sleep(0.1) async def test_main(): async with websockets.serve(server_main, "127.0.0.1", 0) as server: ws_uri = "ws://{}:{}/".format(*server.sockets[0].getsockname()) e = exchange.Exchange( - realtime_dispatcher, "key", "secret", + realtime_dispatcher, + "key", + "secret", config_overrides={ - "api": { - "http": {"base_url": "http://bitstamp.mock/"}, - "websockets": {"base_url": ws_uri} - } - } + "api": {"http": {"base_url": "http://bitstamp.mock/"}, "websockets": {"base_url": ws_uri}} + }, ) if public_events: e.subscribe_to_public_trade_events(p, on_trade_event) diff --git a/tests/test_config.py b/tests/test_config.py index f12fe3f..7a095d9 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -26,17 +26,20 @@ }, "websockets": { "base_url": "wss://ws.bitstamp.net/", - } + }, } } -@pytest.mark.parametrize("path, default, overrides, expected", [ - ("api.http", None, {}, {"base_url": "https://www.bitstamp.net/"}), - ("api.http.base_url", "http://google.com/", {}, "https://www.bitstamp.net/"), - ("api.websockets.timeout", 10, {}, 10), - ("api.timeout", 10, {}, 10), - ("api.timeout", 10, {"api": {"timeout": 50}}, 50), -]) +@pytest.mark.parametrize( + "path, default, overrides, expected", + [ + ("api.http", None, {}, {"base_url": "https://www.bitstamp.net/"}), + ("api.http.base_url", "http://google.com/", {}, "https://www.bitstamp.net/"), + ("api.websockets.timeout", 10, {}, 10), + ("api.timeout", 10, {}, 10), + ("api.timeout", 10, {"api": {"timeout": 50}}, 50), + ], +) def test_get_config_value(path, default, overrides, expected): assert get_config_value(CONFIG_VALUES, path, default=default, overrides=overrides) == expected diff --git a/tests/test_core_helpers.py b/tests/test_core_helpers.py index e702164..ffe5f6b 100644 --- a/tests/test_core_helpers.py +++ b/tests/test_core_helpers.py @@ -45,13 +45,16 @@ async def cancel_task_group(task_group, wait: float = 0.0): task_group.cancel() -@pytest.mark.parametrize("amount, precision, expected", [ - ("1", 0, "1"), - ("1.1", 0, "1"), - ("-1.1", 0, "-1"), - ("-1.1999", 1, "-1.1"), - ("0.2999", 2, "0.29"), -]) +@pytest.mark.parametrize( + "amount, precision, expected", + [ + ("1", 0, "1"), + ("1.1", 0, "1"), + ("-1.1", 0, "-1"), + ("-1.1999", 1, "-1.1"), + ("0.2999", 2, "0.29"), + ], +) def test_truncate(amount, precision, expected): assert helpers.truncate_decimal(Decimal(amount), precision) == Decimal(expected) @@ -119,11 +122,14 @@ async def impl(): asyncio.run(asyncio.wait_for(impl(), 1)) -@pytest.mark.parametrize("pool_size, task_count", [ - (1, 1), - (1, 2), - (10, 200), -]) +@pytest.mark.parametrize( + "pool_size, task_count", + [ + (1, 1), + (1, 2), + (10, 200), + ], +) def test_task_pool(pool_size, task_count): task_calls = 0 @@ -232,10 +238,14 @@ async def test_main(): if sys.version_info >= (3, 12): - @pytest.mark.parametrize("task_factory", [ - None, - asyncio.eager_task_factory, - ]) + + @pytest.mark.parametrize( + "task_factory", + [ + None, + asyncio.eager_task_factory, + ], + ) def test_task_pool_with_eager_tasks(task_factory): tasks_found = 0 pool = helpers.TaskPool(10) @@ -258,10 +268,13 @@ async def test_main(): asyncio.run(asyncio.wait_for(test_main(), 1)) -@pytest.mark.parametrize("obj, expected_classpath", [ - ("hi", "builtins.str"), - (3, "builtins.int"), -]) +@pytest.mark.parametrize( + "obj, expected_classpath", + [ + ("hi", "builtins.str"), + (3, "builtins.int"), + ], +) def test_classpath(obj, expected_classpath): assert helpers.classpath(obj) == expected_classpath diff --git a/tests/test_dispatcher_core.py b/tests/test_dispatcher_core.py index 3eb5afe..9cc262e 100644 --- a/tests/test_dispatcher_core.py +++ b/tests/test_dispatcher_core.py @@ -74,4 +74,4 @@ def test_multiplexer_priority(): _, evnt = mux.pop(next_dt) events.append(evnt) - assert events == [event_1, event_2, event_3] \ No newline at end of file + assert events == [event_1, event_2, event_3] diff --git a/tests/test_realtime_dispatcher.py b/tests/test_realtime_dispatcher.py index 81918da..2e9b24a 100644 --- a/tests/test_realtime_dispatcher.py +++ b/tests/test_realtime_dispatcher.py @@ -77,9 +77,12 @@ async def finalize(self): def test_producers_and_events(realtime_dispatcher): shared_producer = Producer() event_sources = [ - event.FifoQueueEventSource(), event.FifoQueueEventSource(), - event.FifoQueueEventSource(producer=Producer()), event.FifoQueueEventSource(producer=Producer()), - event.FifoQueueEventSource(producer=shared_producer), event.FifoQueueEventSource(producer=shared_producer), + event.FifoQueueEventSource(), + event.FifoQueueEventSource(), + event.FifoQueueEventSource(producer=Producer()), + event.FifoQueueEventSource(producer=Producer()), + event.FifoQueueEventSource(producer=shared_producer), + event.FifoQueueEventSource(producer=shared_producer), ] events = [] @@ -109,11 +112,14 @@ async def test_main(): asyncio.run(asyncio.wait_for(test_main(), 2)) -@pytest.mark.parametrize("failing_producer, other_initialized, other_ran, other_stopped, other_finalized", [ - (FailingProducer(True, False, False), True, False, False, True), - (FailingProducer(False, True, False), True, True, True, True), - (FailingProducer(False, True, True), True, True, True, True), -]) +@pytest.mark.parametrize( + "failing_producer, other_initialized, other_ran, other_stopped, other_finalized", + [ + (FailingProducer(True, False, False), True, False, False, True), + (FailingProducer(False, True, False), True, True, True, True), + (FailingProducer(False, True, True), True, True, True, True), + ], +) def test_exceptions_in_producers( failing_producer, other_initialized, other_ran, other_stopped, other_finalized, realtime_dispatcher ): @@ -121,8 +127,10 @@ def test_exceptions_in_producers( event_sources = [ event.FifoQueueEventSource(producer=failing_producer), event.FifoQueueEventSource(), - event.FifoQueueEventSource(producer=Producer()), event.FifoQueueEventSource(producer=Producer()), - event.FifoQueueEventSource(producer=shared_producer), event.FifoQueueEventSource(producer=shared_producer), + event.FifoQueueEventSource(producer=Producer()), + event.FifoQueueEventSource(producer=Producer()), + event.FifoQueueEventSource(producer=shared_producer), + event.FifoQueueEventSource(producer=shared_producer), ] events = [] @@ -155,7 +163,7 @@ def test_out_of_order_events_are_skipped(realtime_dispatcher): event_dts = [ now, now - datetime.timedelta(hours=1), # This one should be skipped. - now + datetime.timedelta(milliseconds=250) + now + datetime.timedelta(milliseconds=250), ] async def stop_dispatcher(): @@ -179,11 +187,14 @@ async def test_main(): asyncio.run(asyncio.wait_for(test_main(), 2)) -@pytest.mark.parametrize("delta_seconds", [ - 0.5, - 1, - -0.5, -]) +@pytest.mark.parametrize( + "delta_seconds", + [ + 0.5, + 1, + -0.5, + ], +) def test_realtime_scheduler(delta_seconds, realtime_dispatcher): async def scheduled_job(): realtime_dispatcher.stop() @@ -207,10 +218,12 @@ async def on_event(event): async def on_idle(): realtime_dispatcher.stop() - src = event.FifoQueueEventSource(events=[ - event.Event(datetime.datetime(2000, 1, 1).replace(tzinfo=datetime.timezone.utc)), - event.Event(datetime.datetime(2000, 1, 2).replace(tzinfo=datetime.timezone.utc)), - ]) + src = event.FifoQueueEventSource( + events=[ + event.Event(datetime.datetime(2000, 1, 1).replace(tzinfo=datetime.timezone.utc)), + event.Event(datetime.datetime(2000, 1, 2).replace(tzinfo=datetime.timezone.utc)), + ] + ) realtime_dispatcher.subscribe(src, on_event) realtime_dispatcher.subscribe_idle(on_idle) asyncio.run(realtime_dispatcher.run()) diff --git a/tests/test_samples_backtesting_pos_info.py b/tests/test_samples_backtesting_pos_info.py index 35ebd54..bfd2fa8 100644 --- a/tests/test_samples_backtesting_pos_info.py +++ b/tests/test_samples_backtesting_pos_info.py @@ -31,12 +31,23 @@ def test_long_partially_filled(): target = Decimal(10) pair = bs.Pair("BTC", "USDT") order = exchange.OrderInfo( - id="1234", pair=pair, is_open=True, operation=bs.OrderOperation.BUY, amount=target, amount_filled=Decimal(0), - amount_remaining=target, quote_amount_filled=Decimal(0), fees={} + id="1234", + pair=pair, + is_open=True, + operation=bs.OrderOperation.BUY, + amount=target, + amount_filled=Decimal(0), + amount_remaining=target, + quote_amount_filled=Decimal(0), + fees={}, ) pos_info = PositionInfo( - pair=btc_usdt_pair, pair_info=btc_usdt_pair_info, initial=Decimal(0), initial_avg_price=Decimal(0), - target=target, order=order + pair=btc_usdt_pair, + pair_info=btc_usdt_pair_info, + initial=Decimal(0), + initial_avg_price=Decimal(0), + target=target, + order=order, ) assert pos_info.order_open is True @@ -74,12 +85,23 @@ def test_short_completely_filled(): target = Decimal(-10) pair = bs.Pair("BTC", "USDT") order = exchange.OrderInfo( - id="1234", pair=pair, is_open=True, operation=bs.OrderOperation.SELL, amount=target, amount_filled=Decimal(0), - amount_remaining=abs(target), quote_amount_filled=Decimal(0), fees={} + id="1234", + pair=pair, + is_open=True, + operation=bs.OrderOperation.SELL, + amount=target, + amount_filled=Decimal(0), + amount_remaining=abs(target), + quote_amount_filled=Decimal(0), + fees={}, ) pos_info = PositionInfo( - pair=btc_usdt_pair, pair_info=btc_usdt_pair_info, initial=Decimal(0), initial_avg_price=Decimal(0), - target=target, order=order + pair=btc_usdt_pair, + pair_info=btc_usdt_pair_info, + initial=Decimal(0), + initial_avg_price=Decimal(0), + target=target, + order=order, ) assert pos_info.order_open is True @@ -108,10 +130,13 @@ def test_short_completely_filled(): assert pos_info.avg_price == Decimal(10000) -@pytest.mark.parametrize("target_position", [ - bs.Position.LONG, - bs.Position.SHORT, -]) +@pytest.mark.parametrize( + "target_position", + [ + bs.Position.LONG, + bs.Position.SHORT, + ], +) def test_long_jump(target_position): assert target_position in [bs.Position.LONG, bs.Position.SHORT] @@ -129,12 +154,23 @@ def test_long_jump(target_position): # First order that jumps from one position to the opposite one. pair = bs.Pair("BTC", "USDT") order = exchange.OrderInfo( - id="1", pair=pair, is_open=True, operation=operation, amount=abs(target * 2), amount_filled=Decimal(0), - amount_remaining=abs(target * 2), quote_amount_filled=Decimal(0), fees={} + id="1", + pair=pair, + is_open=True, + operation=operation, + amount=abs(target * 2), + amount_filled=Decimal(0), + amount_remaining=abs(target * 2), + quote_amount_filled=Decimal(0), + fees={}, ) pos_info = PositionInfo( - pair=btc_usdt_pair, pair_info=btc_usdt_pair_info, initial=-target, initial_avg_price=Decimal(900), - target=target, order=order + pair=btc_usdt_pair, + pair_info=btc_usdt_pair_info, + initial=-target, + initial_avg_price=Decimal(900), + target=target, + order=order, ) assert pos_info.order_open is True @@ -164,8 +200,15 @@ def test_long_jump(target_position): # The last order was canceled and a new one will start at 3/-3. pos_info.initial, pos_info.initial_avg_price = pos_info.current, pos_info.avg_price order = exchange.OrderInfo( - id="2", pair=pair, is_open=True, operation=operation, amount=Decimal(7), amount_filled=Decimal(0), - amount_remaining=Decimal(7), quote_amount_filled=Decimal(0), fees={}, + id="2", + pair=pair, + is_open=True, + operation=operation, + amount=Decimal(7), + amount_filled=Decimal(0), + amount_remaining=Decimal(7), + quote_amount_filled=Decimal(0), + fees={}, ) pos_info.order = order @@ -197,8 +240,15 @@ def test_long_jump(target_position): pos_info.initial, pos_info.initial_avg_price = pos_info.current, pos_info.avg_price pos_info.target = Decimal(7) * sign order = exchange.OrderInfo( - id="3", pair=pair, is_open=True, operation=reverse_operation, amount=Decimal(1), amount_filled=Decimal(0), - amount_remaining=Decimal(1), quote_amount_filled=Decimal(0), fees={} + id="3", + pair=pair, + is_open=True, + operation=reverse_operation, + amount=Decimal(1), + amount_filled=Decimal(0), + amount_remaining=Decimal(1), + quote_amount_filled=Decimal(0), + fees={}, ) pos_info.order = order @@ -225,34 +275,33 @@ def test_long_jump(target_position): @pytest.mark.parametrize( - ( - "initial, initial_avg_price, target, order_operation, order_filled_amount, order_filled_price, " - "expected_avg_price" - ), - [ - # Long - (0, 0, 1, bs.OrderOperation.BUY, 0, 0, 0), - (0, 0, 1, bs.OrderOperation.BUY, 1, "100.10", "100.10"), - (1, 100, 2, bs.OrderOperation.BUY, 1, 100, 100), - (1, 100, 2, bs.OrderOperation.BUY, 1, 200, 150), - (2, 1000, 1, bs.OrderOperation.SELL, 1, 5000, 1000), - # Short - (0, 0, -1, bs.OrderOperation.SELL, 0, 0, 0), - (0, 0, -1, bs.OrderOperation.SELL, 1, "100.10", "100.10"), - (-1, 100, -2, bs.OrderOperation.SELL, 1, 100, 100), - (-1, 100, -2, bs.OrderOperation.SELL, 1, 200, 150), - (-2, 1000, -1, bs.OrderOperation.BUY, 1, 5000, 1000), - # Regression - (1, 1234, 0, bs.OrderOperation.SELL, 1, 55, 0), - ] + ( + "initial, initial_avg_price, target, order_operation, order_filled_amount, order_filled_price, " + "expected_avg_price" + ), + [ + # Long + (0, 0, 1, bs.OrderOperation.BUY, 0, 0, 0), + (0, 0, 1, bs.OrderOperation.BUY, 1, "100.10", "100.10"), + (1, 100, 2, bs.OrderOperation.BUY, 1, 100, 100), + (1, 100, 2, bs.OrderOperation.BUY, 1, 200, 150), + (2, 1000, 1, bs.OrderOperation.SELL, 1, 5000, 1000), + # Short + (0, 0, -1, bs.OrderOperation.SELL, 0, 0, 0), + (0, 0, -1, bs.OrderOperation.SELL, 1, "100.10", "100.10"), + (-1, 100, -2, bs.OrderOperation.SELL, 1, 100, 100), + (-1, 100, -2, bs.OrderOperation.SELL, 1, 200, 150), + (-2, 1000, -1, bs.OrderOperation.BUY, 1, 5000, 1000), + # Regression + (1, 1234, 0, bs.OrderOperation.SELL, 1, 55, 0), + ], ) def test_avg_price( - initial, initial_avg_price, target, order_operation, order_filled_amount, order_filled_price, expected_avg_price + initial, initial_avg_price, target, order_operation, order_filled_amount, order_filled_price, expected_avg_price ): initial, initial_avg_price, target, order_filled_amount, order_filled_price, expected_avg_price = [ - Decimal(value) for value in [ - initial, initial_avg_price, target, order_filled_amount, order_filled_price, expected_avg_price - ] + Decimal(value) + for value in [initial, initial_avg_price, target, order_filled_amount, order_filled_price, expected_avg_price] ] order_amount = abs(target - initial) is_open = order_amount != order_filled_amount @@ -260,9 +309,15 @@ def test_avg_price( pair = bs.Pair("BTC", "USDT") order = exchange.OrderInfo( - id="1", pair=pair, is_open=is_open, operation=order_operation, amount=order_amount, - amount_filled=order_filled_amount, amount_remaining=order_amount - order_filled_amount, - quote_amount_filled=order_filled_amount * order_filled_price, fees={}, + id="1", + pair=pair, + is_open=is_open, + operation=order_operation, + amount=order_amount, + amount_filled=order_filled_amount, + amount_remaining=order_amount - order_filled_amount, + quote_amount_filled=order_filled_amount * order_filled_price, + fees={}, fills=[ exchange.Fill( when=bs.utc_now(), @@ -273,11 +328,15 @@ def test_avg_price( fees={}, fill_price=Decimal(order_filled_price), ) - ] + ], ) pos_info = PositionInfo( - pair=btc_usdt_pair, pair_info=btc_usdt_pair_info, initial=initial, initial_avg_price=initial_avg_price, - target=target, order=order + pair=btc_usdt_pair, + pair_info=btc_usdt_pair_info, + initial=initial, + initial_avg_price=initial_avg_price, + target=target, + order=order, ) assert pos_info.avg_price == expected_avg_price @@ -285,9 +344,15 @@ def test_avg_price( def test_pnl_pct(): pair = bs.Pair("BTC", "USDT") order = exchange.OrderInfo( - id="1", pair=pair, is_open=False, operation=bs.OrderOperation.BUY, amount=Decimal(1), - amount_filled=Decimal(1), amount_remaining=Decimal(0), - quote_amount_filled=(Decimal(1000)), fees={}, + id="1", + pair=pair, + is_open=False, + operation=bs.OrderOperation.BUY, + amount=Decimal(1), + amount_filled=Decimal(1), + amount_remaining=Decimal(0), + quote_amount_filled=(Decimal(1000)), + fees={}, fills=[ exchange.Fill( when=bs.utc_now(), @@ -298,11 +363,15 @@ def test_pnl_pct(): fees={}, fill_price=Decimal(1000), ) - ] + ], ) pos_info = PositionInfo( - pair=btc_usdt_pair, pair_info=btc_usdt_pair_info, initial=Decimal(0), initial_avg_price=Decimal(0), - target=Decimal(1), order=order + pair=btc_usdt_pair, + pair_info=btc_usdt_pair_info, + initial=Decimal(0), + initial_avg_price=Decimal(0), + target=Decimal(1), + order=order, ) assert pos_info.avg_price == Decimal(1000) diff --git a/tests/test_samples_binance_order_book_mirror.py b/tests/test_samples_binance_order_book_mirror.py index 0fea1fa..c345f4e 100644 --- a/tests/test_samples_binance_order_book_mirror.py +++ b/tests/test_samples_binance_order_book_mirror.py @@ -20,33 +20,35 @@ import pytest -@pytest.mark.parametrize("updates, expected_bids, expected_asks", [ - ([], [], []), - ( - [ - (102000, 1, True), - (102000, "0.005", True), - (101900, 1, True), - (103000, 1, True), - (103000, 0, True), - (103000, 7, True), - - (103900, 2, False), - (104000, 1, False), - (103500, 4, False), - ], - [ - (103000, 7), - (102000, "0.005"), - (101900, 1), - ], - [ - (103500, 4), - (103900, 2), - (104000, 1), - ], - ), -]) +@pytest.mark.parametrize( + "updates, expected_bids, expected_asks", + [ + ([], [], []), + ( + [ + (102000, 1, True), + (102000, "0.005", True), + (101900, 1, True), + (103000, 1, True), + (103000, 0, True), + (103000, 7, True), + (103900, 2, False), + (104000, 1, False), + (103500, 4, False), + ], + [ + (103000, 7), + (102000, "0.005"), + (101900, 1), + ], + [ + (103500, 4), + (103900, 2), + (104000, 1), + ], + ), + ], +) def test_order_book(updates, expected_bids, expected_asks): order_book = OrderBook() for price, amount, is_bid in updates: diff --git a/tests/test_token_bucket.py b/tests/test_token_bucket.py index ff8fde2..76fd1ea 100644 --- a/tests/test_token_bucket.py +++ b/tests/test_token_bucket.py @@ -22,14 +22,17 @@ from basana.core.token_bucket import TokenBucketLimiter -@pytest.mark.parametrize("tokens_per_period, period_duration, initial_tokens, expected_wait", [ - (1, 1, 0, 1), - (1, 1, 1, 0), - (10, 1, 0, 0.1), - (10, 7, 0, 0.7), - (1, 2, 0, 2), - (0.5, 1, 0, 2), -]) +@pytest.mark.parametrize( + "tokens_per_period, period_duration, initial_tokens, expected_wait", + [ + (1, 1, 0, 1), + (1, 1, 1, 0), + (10, 1, 0, 0.1), + (10, 7, 0, 0.7), + (1, 2, 0, 2), + (0.5, 1, 0, 2), + ], +) def test_token_consume(tokens_per_period, period_duration, initial_tokens, expected_wait): limiter = TokenBucketLimiter(tokens_per_period, period_duration, initial_tokens=initial_tokens) assert limiter.tokens == initial_tokens diff --git a/tests/test_yahoo.py b/tests/test_yahoo.py index fab71a5..1e8a0dd 100644 --- a/tests/test_yahoo.py +++ b/tests/test_yahoo.py @@ -88,12 +88,12 @@ async def add_bar(event: bar.BarEvent): events.append(event) src_1 = bars.CSVBarSource( - pair.Pair("ORCL", "USD"), helpers.abs_data_path("orcl-2000-yahoo-sorted.csv"), sort=False, - tzinfo=datetime.timezone.utc - ) - src_2 = bars.CSVBarSource( - pair.Pair("ORCL", "USD"), helpers.abs_data_path("orcl-2001-yahoo.csv"), adjust_ohlc=True + pair.Pair("ORCL", "USD"), + helpers.abs_data_path("orcl-2000-yahoo-sorted.csv"), + sort=False, + tzinfo=datetime.timezone.utc, ) + src_2 = bars.CSVBarSource(pair.Pair("ORCL", "USD"), helpers.abs_data_path("orcl-2001-yahoo.csv"), adjust_ohlc=True) backtesting_dispatcher.subscribe(src_1, add_bar) backtesting_dispatcher.subscribe(src_2, add_bar) From 8d6404ca8ac30a0f5bfad4701d9f030c847d82ee Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 4 Mar 2026 16:02:53 +0000 Subject: [PATCH 02/17] feat: Hyperliquid connector for Basana Adds a full Hyperliquid perpetuals exchange connector mirroring the existing Binance/Bitstamp connector structure. Modules: - basana/external/hyperliquid/client/rest.py - REST client wrapping hyperliquid-python-sdk (market data, order placement, account state, leverage) - basana/external/hyperliquid/websockets.py - async WS manager (candles, trades, L2 book, order updates, user fills) - basana/external/hyperliquid/perps.py - perpetuals Account (positions, market/limit orders, fill event subscriptions) - basana/external/hyperliquid/exchange.py - main Exchange class, Basana-compatible interface (subscribe_to_bar_events, get_bid_ask, perps_account, etc.) - samples/hyperliquid/rsi_strategy.py - example RSI(14) strategy using TALIpp Supports: - Public market data (no auth): mid prices, order book, OHLCV, asset metadata - Authenticated trading: market open/close, limit orders, cancel, leverage - Real-time WebSocket feeds with auto-reconnect - Testnet support Co-authored-by: Christian Pojoni --- basana/external/hyperliquid/__init__.py | 16 ++ .../external/hyperliquid/client/__init__.py | 7 + basana/external/hyperliquid/client/rest.py | 204 +++++++++++++++ basana/external/hyperliquid/exchange.py | 217 ++++++++++++++++ basana/external/hyperliquid/perps.py | 238 ++++++++++++++++++ basana/external/hyperliquid/websockets.py | 170 +++++++++++++ samples/hyperliquid/rsi_strategy.py | 98 ++++++++ 7 files changed, 950 insertions(+) create mode 100644 basana/external/hyperliquid/__init__.py create mode 100644 basana/external/hyperliquid/client/__init__.py create mode 100644 basana/external/hyperliquid/client/rest.py create mode 100644 basana/external/hyperliquid/exchange.py create mode 100644 basana/external/hyperliquid/perps.py create mode 100644 basana/external/hyperliquid/websockets.py create mode 100644 samples/hyperliquid/rsi_strategy.py diff --git a/basana/external/hyperliquid/__init__.py b/basana/external/hyperliquid/__init__.py new file mode 100644 index 0000000..485d422 --- /dev/null +++ b/basana/external/hyperliquid/__init__.py @@ -0,0 +1,16 @@ +# Basana - Hyperliquid connector +# +# Licensed under the Apache License, Version 2.0 + +from .exchange import Exchange, AssetInfo, Error, FillEvent, OrderInfo, Position +from .perps import OrderInfo, FillEvent, FillEventHandler + +__all__ = [ + "Exchange", + "AssetInfo", + "Error", + "FillEvent", + "FillEventHandler", + "OrderInfo", + "Position", +] diff --git a/basana/external/hyperliquid/client/__init__.py b/basana/external/hyperliquid/client/__init__.py new file mode 100644 index 0000000..6d87cdd --- /dev/null +++ b/basana/external/hyperliquid/client/__init__.py @@ -0,0 +1,7 @@ +# Basana - Hyperliquid connector +# +# Licensed under the Apache License, Version 2.0 + +from .rest import APIClient, Error + +__all__ = ["APIClient", "Error"] diff --git a/basana/external/hyperliquid/client/rest.py b/basana/external/hyperliquid/client/rest.py new file mode 100644 index 0000000..891e5c6 --- /dev/null +++ b/basana/external/hyperliquid/client/rest.py @@ -0,0 +1,204 @@ +# Basana - Hyperliquid connector +# +# Licensed under the Apache License, Version 2.0 +# +# REST client wrapping the Hyperliquid Python SDK. +# Hyperliquid uses two endpoints: +# POST https://api.hyperliquid.xyz/info - market data and account info +# POST https://api.hyperliquid.xyz/exchange - order placement and management + +from typing import Any, Dict, List, Optional +import eth_account +import logging + +from hyperliquid.info import Info +from hyperliquid.exchange import Exchange as HLExchange +from hyperliquid.utils import constants + +logger = logging.getLogger(__name__) + + +class Error(Exception): + """Raised when the Hyperliquid API returns an error.""" + + def __init__(self, message: str, response: Optional[dict] = None): + super().__init__(message) + self.response = response + + +class APIClient: + """Low-level Hyperliquid REST client. + + :param private_key: Optional EVM private key (hex string with 0x prefix). + Required for trading. If not set, only public/read endpoints are available. + :param testnet: If True, connects to the Hyperliquid testnet. + """ + + def __init__( + self, + private_key: Optional[str] = None, + testnet: bool = False, + ): + self._base_url = constants.TESTNET_API_URL if testnet else constants.MAINNET_API_URL + self._info = Info(self._base_url, skip_ws=True) + self._wallet: Optional[Any] = None + self._exchange: Optional[HLExchange] = None + + if private_key: + self._wallet = eth_account.Account.from_key(private_key) + self._exchange = HLExchange(self._wallet, self._base_url) + + # ------------------------------------------------------------------ + # Market data + # ------------------------------------------------------------------ + + def get_all_mids(self) -> Dict[str, str]: + """Return mid prices for all coins. Returns {coin: mid_price_str}.""" + return self._info.all_mids() + + def get_l2_snapshot(self, coin: str) -> dict: + """Return the current L2 order book for a coin.""" + return self._info.l2_snapshot(coin) + + def get_candles(self, coin: str, interval: str, start_time: int, end_time: int) -> List[dict]: + """Return OHLCV candles. + + :param coin: e.g. "ETH", "BTC" + :param interval: e.g. "1m", "5m", "1h", "4h", "1d" + :param start_time: Start timestamp in milliseconds. + :param end_time: End timestamp in milliseconds. + """ + return self._info.candles_snapshot(coin, interval, start_time, end_time) + + def get_meta(self) -> dict: + """Return exchange metadata: all tradeable assets, lot sizes, etc.""" + return self._info.meta() + + def get_funding_history(self, coin: str, start_time: int, end_time: Optional[int] = None) -> List[dict]: + """Return funding rate history for a coin.""" + return self._info.funding_history(coin, start_time, end_time) + + # ------------------------------------------------------------------ + # Account data (requires wallet) + # ------------------------------------------------------------------ + + def _require_auth(self) -> None: + if self._wallet is None: + raise Error("Private key required for this operation") + + def get_user_state(self) -> dict: + """Return account state: balances, positions, margin info.""" + self._require_auth() + return self._info.user_state(self._wallet.address) + + def get_open_orders(self) -> List[dict]: + """Return all open orders for this account.""" + self._require_auth() + return self._info.open_orders(self._wallet.address) + + def get_order_status(self, oid: int) -> dict: + """Return the status of a specific order by ID.""" + self._require_auth() + return self._info.query_order_by_oid(self._wallet.address, oid) + + def get_user_fills(self, start_time: int) -> List[dict]: + """Return trade history (fills) since start_time (ms).""" + self._require_auth() + return self._info.user_fills_by_time(self._wallet.address, start_time) + + # ------------------------------------------------------------------ + # Trading (requires wallet) + # ------------------------------------------------------------------ + + def market_open(self, coin: str, is_buy: bool, sz: float, slippage: float = 0.01) -> dict: + """Place a market order. + + :param coin: e.g. "ETH" + :param is_buy: True for long, False for short. + :param sz: Size in base asset units. + :param slippage: Max acceptable slippage (default 1%). + """ + self._require_auth() + result = self._exchange.market_open(coin, is_buy, sz, None, slippage) + self._check_result(result) + return result + + def market_close(self, coin: str, sz: Optional[float] = None, slippage: float = 0.01) -> dict: + """Close an open position at market price. + + :param coin: e.g. "ETH" + :param sz: Size to close. If None, closes the entire position. + :param slippage: Max acceptable slippage (default 1%). + """ + self._require_auth() + result = self._exchange.market_close(coin, sz, None, slippage) + self._check_result(result) + return result + + def limit_order( + self, + coin: str, + is_buy: bool, + sz: float, + limit_px: float, + reduce_only: bool = False, + ) -> dict: + """Place a limit order. + + :param coin: e.g. "ETH" + :param is_buy: True to buy, False to sell. + :param sz: Size in base asset units. + :param limit_px: Limit price. + :param reduce_only: If True, the order can only reduce an existing position. + """ + self._require_auth() + order_type = {"limit": {"tif": "Gtc"}} + result = self._exchange.order(coin, is_buy, sz, limit_px, order_type, reduce_only=reduce_only) + self._check_result(result) + return result + + def cancel_order(self, coin: str, oid: int) -> dict: + """Cancel an open order by ID.""" + self._require_auth() + result = self._exchange.cancel(coin, oid) + self._check_result(result) + return result + + def cancel_all_orders(self, coin: Optional[str] = None) -> None: + """Cancel all open orders, optionally filtered by coin.""" + self._require_auth() + orders = self.get_open_orders() + if coin: + orders = [o for o in orders if o.get("coin") == coin] + for order in orders: + try: + self.cancel_order(order["coin"], order["oid"]) + except Error as e: + logger.warning("Failed to cancel order %s: %s", order["oid"], e) + + def set_leverage(self, coin: str, leverage: int, is_cross: bool = True) -> dict: + """Set leverage for a coin. + + :param coin: e.g. "ETH" + :param leverage: Leverage multiplier (1-50 depending on asset). + :param is_cross: True for cross margin, False for isolated. + """ + self._require_auth() + result = self._exchange.update_leverage(leverage, coin, is_cross) + self._check_result(result) + return result + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @property + def address(self) -> Optional[str]: + """Return the wallet address, or None if not authenticated.""" + return self._wallet.address if self._wallet else None + + @staticmethod + def _check_result(result: dict) -> None: + status = result.get("status") + if status != "ok": + raise Error(f"API error: {result}", response=result) diff --git a/basana/external/hyperliquid/exchange.py b/basana/external/hyperliquid/exchange.py new file mode 100644 index 0000000..10f5892 --- /dev/null +++ b/basana/external/hyperliquid/exchange.py @@ -0,0 +1,217 @@ +# Basana - Hyperliquid connector +# +# Licensed under the Apache License, Version 2.0 +# +# Main Exchange class - mirrors the Basana Binance Exchange interface +# but targets Hyperliquid perpetuals (the primary use case). +# +# Usage: +# async with aiohttp.ClientSession() as session: +# exchange = Exchange( +# dispatcher=d, +# private_key="0x...", # optional; omit for read-only +# ) +# exchange.subscribe_to_bar_events("ETH", "1h", my_handler) +# await d.run() + +from decimal import Decimal +from typing import Callable, List, Optional, Tuple +import dataclasses +import logging + +import aiohttp + +from basana.core import bar, dispatcher +from basana.core.pair import Pair, PairInfo + +from . import client, perps, websockets + +logger = logging.getLogger(__name__) + +# Re-export common types so callers only need to import from this module. +Error = client.Error +FillEvent = perps.FillEvent +FillEventHandler = perps.FillEventHandler +OrderInfo = perps.OrderInfo +Position = perps.Position + +BarEventHandler = bar.BarEventHandler + + +@dataclasses.dataclass(frozen=True) +class AssetInfo(PairInfo): + """Information about a Hyperliquid tradeable asset. + + :param sz_decimals: The number of decimal places for order size. + :param max_leverage: Maximum allowed leverage. + """ + + sz_decimals: int + max_leverage: int + + +class Exchange: + """A Basana-compatible client for `Hyperliquid `_ DEX. + + Supports perpetuals trading, real-time bar/trade/order-book feeds, + and position management. + + :param dispatcher: The Basana event dispatcher. + :param private_key: Optional EVM private key (hex, with 0x prefix). + Required for trading and user-specific subscriptions. + If omitted, only public market data endpoints are available. + :param testnet: If True, connects to the Hyperliquid testnet. + :param session: Optional aiohttp.ClientSession for connection reuse. + """ + + def __init__( + self, + dispatcher: dispatcher.EventDispatcher, + private_key: Optional[str] = None, + testnet: bool = False, + session: Optional[aiohttp.ClientSession] = None, + ): + self._dispatcher = dispatcher + self._cli = client.APIClient(private_key=private_key, testnet=testnet) + self._ws_mgr = websockets.WebsocketManager(dispatcher, testnet=testnet, session=session) + self._perps = perps.Account(self._cli, self._ws_mgr) + self._asset_info: dict[str, AssetInfo] = {} + + # ------------------------------------------------------------------ + # Market data subscriptions (public) + # ------------------------------------------------------------------ + + def subscribe_to_bar_events( + self, + coin: str, + interval: str, + event_handler: BarEventHandler, + ) -> None: + """Subscribe to OHLCV bar events via WebSocket. + + :param coin: e.g. "ETH", "BTC" + :param interval: One of "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w" + :param event_handler: Async callable receiving a BarEvent. + """ + pair = self._coin_to_pair(coin) + + async def _on_candle(data: dict) -> None: + candle = data.get("candle", data) + if not candle.get("isClosed", True): + return # Only emit completed bars (consistent with Binance connector) + b = bar.Bar( + datetime=None, # Will be set from timestamp + open=Decimal(str(candle["o"])), + high=Decimal(str(candle["h"])), + low=Decimal(str(candle["l"])), + close=Decimal(str(candle["c"])), + volume=Decimal(str(candle["v"])), + ) + event = bar.BarEvent( + when=bar.ts_to_datetime(int(candle["t"])), + pair=pair, + bar=b, + ) + await event_handler(event) + + self._ws_mgr.subscribe_to_candle_events(coin, interval, _on_candle) + + def subscribe_to_trade_events( + self, + coin: str, + event_handler: Callable, + ) -> None: + """Subscribe to real-time trade events. + + :param coin: e.g. "ETH" + :param event_handler: Async callable receiving a list of trade dicts. + """ + self._ws_mgr.subscribe_to_trade_events(coin, event_handler) + + def subscribe_to_order_book_events( + self, + coin: str, + event_handler: Callable, + ) -> None: + """Subscribe to L2 order book updates. + + :param coin: e.g. "ETH" + :param event_handler: Async callable receiving the order book dict. + """ + self._ws_mgr.subscribe_to_order_book_events(coin, event_handler) + + # ------------------------------------------------------------------ + # Market data queries (public, synchronous wrappers) + # ------------------------------------------------------------------ + + def get_mid_price(self, coin: str) -> Decimal: + """Return the current mid price for a coin.""" + mids = self._cli.get_all_mids() + if coin not in mids: + raise Error(f"Unknown coin: {coin}") + return Decimal(mids[coin]) + + def get_bid_ask(self, coin: str) -> Tuple[Decimal, Decimal]: + """Return the best bid and ask prices for a coin.""" + book = self._cli.get_l2_snapshot(coin) + levels = book.get("levels", [[], []]) + bid = Decimal(levels[0][0]["px"]) if levels[0] else Decimal(0) + ask = Decimal(levels[1][0]["px"]) if levels[1] else Decimal(0) + return bid, ask + + def get_pair_info(self, coin: str) -> AssetInfo: + """Return metadata for a tradeable asset. + + :param coin: e.g. "ETH", "BTC" + """ + if coin not in self._asset_info: + meta = self._cli.get_meta() + for asset in meta.get("universe", []): + name = asset["name"] + self._asset_info[name] = AssetInfo( + base_precision=asset.get("szDecimals", 8), + quote_precision=6, # Hyperliquid uses 6dp for USD + sz_decimals=asset.get("szDecimals", 8), + max_leverage=asset.get("maxLeverage", 50), + ) + return self._asset_info[coin] + + def list_coins(self) -> List[str]: + """Return a list of all tradeable coins.""" + meta = self._cli.get_meta() + return [a["name"] for a in meta.get("universe", [])] + + # ------------------------------------------------------------------ + # Perpetuals account + # ------------------------------------------------------------------ + + @property + def perps_account(self) -> perps.Account: + """Access perpetuals trading and account management. + + Example:: + + order = exchange.perps_account.market_open("ETH", OrderOperation.BUY, Decimal("0.1")) + positions = exchange.perps_account.get_positions() + """ + return self._perps + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + def start(self) -> None: + """Start WebSocket connections. Called automatically by the event dispatcher.""" + self._ws_mgr.start() + + async def stop(self) -> None: + """Stop WebSocket connections and clean up.""" + await self._ws_mgr.stop() + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _coin_to_pair(coin: str) -> Pair: + return Pair(coin, "USD") diff --git a/basana/external/hyperliquid/perps.py b/basana/external/hyperliquid/perps.py new file mode 100644 index 0000000..20191f6 --- /dev/null +++ b/basana/external/hyperliquid/perps.py @@ -0,0 +1,238 @@ +# Basana - Hyperliquid connector +# +# Licensed under the Apache License, Version 2.0 +# +# Perpetuals account: place/cancel orders, track positions, subscribe to fills. + +from decimal import Decimal +from typing import Callable, List, Optional +import dataclasses +import logging + +from basana.core import enums +from . import client, websockets + +logger = logging.getLogger(__name__) + +OrderOperation = enums.OrderOperation + + +@dataclasses.dataclass +class Position: + """An open perpetuals position.""" + + #: The coin (e.g. "ETH"). + coin: str + #: Size (positive = long, negative = short). + size: Decimal + #: Average entry price. + entry_price: Decimal + #: Unrealized P&L in USD. + unrealized_pnl: Decimal + #: Liquidation price. + liquidation_price: Optional[Decimal] + #: Leverage. + leverage: Decimal + #: Margin used in USD. + margin_used: Decimal + + +@dataclasses.dataclass +class OrderInfo: + """A placed or open order.""" + + oid: int + coin: str + is_buy: bool + size: Decimal + limit_price: Optional[Decimal] + filled: Decimal + status: str + + +@dataclasses.dataclass +class FillEvent: + """A trade fill event received via WebSocket.""" + + coin: str + size: Decimal + price: Decimal + side: str # "B" (buy) or "A" (ask/sell) + fee: Decimal + timestamp_ms: int + + +FillEventHandler = Callable[[FillEvent], None] + + +class Account: + """Hyperliquid perpetuals account. + + Provides order placement, position queries, and real-time fill events. + + :param api_client: The REST API client. + :param ws_manager: The WebSocket manager. + """ + + def __init__(self, api_client: client.APIClient, ws_manager: websockets.WebsocketManager): + self._cli = api_client + self._ws = ws_manager + + # ------------------------------------------------------------------ + # Account state + # ------------------------------------------------------------------ + + def get_positions(self) -> List[Position]: + """Return all open perpetuals positions.""" + state = self._cli.get_user_state() + positions = [] + for p in state.get("assetPositions", []): + pos = p.get("position", {}) + if pos.get("szi") == "0": + continue + size = Decimal(pos["szi"]) + positions.append(Position( + coin=pos["coin"], + size=size, + entry_price=Decimal(pos["entryPx"]) if pos.get("entryPx") else Decimal(0), + unrealized_pnl=Decimal(pos.get("unrealizedPnl", "0")), + liquidation_price=Decimal(pos["liquidationPx"]) if pos.get("liquidationPx") else None, + leverage=Decimal(str(pos.get("leverage", {}).get("value", 1))), + margin_used=Decimal(pos.get("marginUsed", "0")), + )) + return positions + + def get_balance(self) -> Decimal: + """Return the account equity (total margin balance) in USD.""" + state = self._cli.get_user_state() + return Decimal(state.get("marginSummary", {}).get("accountValue", "0")) + + def get_open_orders(self) -> List[OrderInfo]: + """Return all open orders.""" + raw = self._cli.get_open_orders() + return [ + OrderInfo( + oid=o["oid"], + coin=o["coin"], + is_buy=o["side"] == "B", + size=Decimal(o["sz"]), + limit_price=Decimal(o["limitPx"]) if o.get("limitPx") else None, + filled=Decimal(o.get("filledSz", "0")), + status=o.get("orderType", "unknown"), + ) + for o in raw + ] + + # ------------------------------------------------------------------ + # Order management + # ------------------------------------------------------------------ + + def market_open(self, coin: str, operation: OrderOperation, size: Decimal, slippage: float = 0.01) -> OrderInfo: + """Open a position at market price. + + :param coin: e.g. "ETH" + :param operation: OrderOperation.BUY for long, OrderOperation.SELL for short. + :param size: Position size in base asset units. + :param slippage: Maximum acceptable slippage (default 1%). + """ + is_buy = operation == OrderOperation.BUY + result = self._cli.market_open(coin, is_buy, float(size), slippage) + return self._parse_order_result(result, coin) + + def market_close(self, coin: str, size: Optional[Decimal] = None, slippage: float = 0.01) -> OrderInfo: + """Close a position at market price. + + :param coin: e.g. "ETH" + :param size: Amount to close. If None, closes the entire position. + :param slippage: Maximum acceptable slippage (default 1%). + """ + result = self._cli.market_close(coin, float(size) if size else None, slippage) + return self._parse_order_result(result, coin) + + def limit_order( + self, + coin: str, + operation: OrderOperation, + size: Decimal, + limit_price: Decimal, + reduce_only: bool = False, + ) -> OrderInfo: + """Place a limit order. + + :param coin: e.g. "ETH" + :param operation: OrderOperation.BUY or OrderOperation.SELL. + :param size: Size in base asset units. + :param limit_price: Limit price in USD. + :param reduce_only: If True, the order only reduces an existing position. + """ + is_buy = operation == OrderOperation.BUY + result = self._cli.limit_order(coin, is_buy, float(size), float(limit_price), reduce_only) + return self._parse_order_result(result, coin) + + def cancel_order(self, coin: str, oid: int) -> None: + """Cancel an open order by ID.""" + self._cli.cancel_order(coin, oid) + + def cancel_all_orders(self, coin: Optional[str] = None) -> None: + """Cancel all open orders, optionally filtered by coin.""" + self._cli.cancel_all_orders(coin) + + def set_leverage(self, coin: str, leverage: int, is_cross: bool = True) -> None: + """Set leverage for a coin. + + :param coin: e.g. "ETH" + :param leverage: Leverage multiplier. + :param is_cross: True for cross margin, False for isolated. + """ + self._cli.set_leverage(coin, leverage, is_cross) + + # ------------------------------------------------------------------ + # Real-time events + # ------------------------------------------------------------------ + + def subscribe_to_fill_events(self, handler: FillEventHandler) -> None: + """Subscribe to real-time fill events via WebSocket. + + :param handler: Async or sync callable receiving a FillEvent. + """ + if not self._cli.address: + raise client.Error("Private key required to subscribe to fill events") + + async def _on_fill(data: dict) -> None: + for fill in data.get("fills", [data]): + event = FillEvent( + coin=fill.get("coin", ""), + size=Decimal(str(fill.get("sz", "0"))), + price=Decimal(str(fill.get("px", "0"))), + side=fill.get("side", ""), + fee=Decimal(str(fill.get("fee", "0"))), + timestamp_ms=fill.get("time", 0), + ) + if asyncio.iscoroutinefunction(handler): + await handler(event) + else: + handler(event) + + self._ws.subscribe_to_user_fills(self._cli.address, _on_fill) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + @staticmethod + def _parse_order_result(result: dict, coin: str) -> OrderInfo: + statuses = result.get("response", {}).get("data", {}).get("statuses", [{}]) + s = statuses[0] if statuses else {} + filled = s.get("filled", {}) + return OrderInfo( + oid=filled.get("oid", 0), + coin=coin, + is_buy=True, # Determined by caller + size=Decimal(str(filled.get("totalSz", "0"))), + limit_price=None, + filled=Decimal(str(filled.get("totalSz", "0"))), + status="filled" if filled else s.get("error", "unknown"), + ) + + +import asyncio # noqa: E402 (needed for fill handler) diff --git a/basana/external/hyperliquid/websockets.py b/basana/external/hyperliquid/websockets.py new file mode 100644 index 0000000..56fb83d --- /dev/null +++ b/basana/external/hyperliquid/websockets.py @@ -0,0 +1,170 @@ +# Basana - Hyperliquid connector +# +# Licensed under the Apache License, Version 2.0 +# +# WebSocket manager for real-time Hyperliquid feeds. +# Hyperliquid WS endpoint: wss://api.hyperliquid.xyz/ws +# +# Supported subscription types: +# - candle (OHLCV bars per coin/interval) +# - trades (individual trades per coin) +# - l2Book (order book updates per coin) +# - orderUpdates (order status updates for a user address) +# - userFills (fill events for a user address) + +from typing import Any, Callable, Dict, Optional +import asyncio +import json +import logging + +import aiohttp + +from basana.core import dispatcher + +logger = logging.getLogger(__name__) + +WS_URL = "wss://api.hyperliquid.xyz/ws" +TESTNET_WS_URL = "wss://api.hyperliquid-testnet.xyz/ws" + +MessageHandler = Callable[[dict], Any] + + +class WebsocketManager: + """Manages a single persistent WebSocket connection to Hyperliquid. + + Subscriptions are registered before the connection is established. + The manager auto-reconnects on disconnect. + """ + + def __init__( + self, + event_dispatcher: dispatcher.EventDispatcher, + testnet: bool = False, + session: Optional[aiohttp.ClientSession] = None, + ): + self._dispatcher = event_dispatcher + self._ws_url = TESTNET_WS_URL if testnet else WS_URL + self._session = session + self._own_session = session is None + self._subscriptions: list[dict] = [] + self._handlers: Dict[str, list[MessageHandler]] = {} + self._ws: Optional[aiohttp.ClientWebSocketResponse] = None + self._running = False + self._task: Optional[asyncio.Task] = None + + def subscribe_to_candle_events(self, coin: str, interval: str, handler: MessageHandler) -> None: + """Subscribe to OHLCV candle events. + + :param coin: e.g. "ETH" + :param interval: e.g. "1m", "5m", "1h", "4h", "1d" + :param handler: Async callable receiving the raw candle dict. + """ + sub = {"type": "candle", "coin": coin, "interval": interval} + key = f"candle:{coin}:{interval}" + self._add_subscription(sub, key, handler) + + def subscribe_to_trade_events(self, coin: str, handler: MessageHandler) -> None: + """Subscribe to real-time trade events. + + :param coin: e.g. "ETH" + :param handler: Async callable receiving a list of trade dicts. + """ + sub = {"type": "trades", "coin": coin} + key = f"trades:{coin}" + self._add_subscription(sub, key, handler) + + def subscribe_to_order_book_events(self, coin: str, handler: MessageHandler) -> None: + """Subscribe to L2 order book updates. + + :param coin: e.g. "ETH" + :param handler: Async callable receiving the order book dict. + """ + sub = {"type": "l2Book", "coin": coin} + key = f"l2Book:{coin}" + self._add_subscription(sub, key, handler) + + def subscribe_to_order_updates(self, address: str, handler: MessageHandler) -> None: + """Subscribe to order status updates for a wallet address. + + :param address: EVM wallet address (0x...). + :param handler: Async callable receiving order update dicts. + """ + sub = {"type": "orderUpdates", "user": address} + key = f"orderUpdates:{address}" + self._add_subscription(sub, key, handler) + + def subscribe_to_user_fills(self, address: str, handler: MessageHandler) -> None: + """Subscribe to fill events for a wallet address. + + :param address: EVM wallet address (0x...). + :param handler: Async callable receiving fill event dicts. + """ + sub = {"type": "userFills", "user": address} + key = f"userFills:{address}" + self._add_subscription(sub, key, handler) + + def start(self) -> None: + """Start the WebSocket connection loop (called by the event dispatcher).""" + if not self._running and self._subscriptions: + self._running = True + self._task = asyncio.ensure_future(self._run()) + + async def stop(self) -> None: + """Stop the WebSocket connection.""" + self._running = False + if self._ws and not self._ws.closed: + await self._ws.close() + if self._task: + try: + await self._task + except asyncio.CancelledError: + pass + if self._own_session and self._session: + await self._session.close() + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _add_subscription(self, sub: dict, key: str, handler: MessageHandler) -> None: + # Only add the WS subscription once per key (multiple handlers allowed). + if key not in self._handlers: + self._subscriptions.append(sub) + self._handlers[key] = [] + self._handlers[key].append(handler) + + async def _run(self) -> None: + while self._running: + try: + if self._own_session or self._session is None: + self._session = aiohttp.ClientSession() + async with self._session.ws_connect(self._ws_url, heartbeat=30) as ws: + self._ws = ws + # Send all subscriptions on connect/reconnect. + for sub in self._subscriptions: + await ws.send_json({"method": "subscribe", "subscription": sub}) + async for msg in ws: + if msg.type == aiohttp.WSMsgType.TEXT: + await self._handle_message(json.loads(msg.data)) + elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): + break + except Exception as e: + logger.warning("Hyperliquid WS error: %s — reconnecting in 5s", e) + await asyncio.sleep(5) + + async def _handle_message(self, message: dict) -> None: + channel = message.get("channel", "") + data = message.get("data", {}) + + # Route to registered handlers by channel key. + for key, handlers in self._handlers.items(): + sub_type = key.split(":")[0] + if channel == sub_type or channel.startswith(sub_type): + for handler in handlers: + try: + if asyncio.iscoroutinefunction(handler): + await handler(data) + else: + handler(data) + except Exception as e: + logger.error("Handler error for %s: %s", key, e) diff --git a/samples/hyperliquid/rsi_strategy.py b/samples/hyperliquid/rsi_strategy.py new file mode 100644 index 0000000..a92a235 --- /dev/null +++ b/samples/hyperliquid/rsi_strategy.py @@ -0,0 +1,98 @@ +""" +Example: RSI strategy on Hyperliquid perpetuals using Basana. + +Subscribes to ETH/USD 1h bars, calculates RSI(14) with TALIpp, +and opens/closes a long position based on RSI thresholds. + +Requirements: + pip install basana talipp hyperliquid-python-sdk + +Usage (paper trading - no key needed for bar data): + python3 rsi_strategy.py + +Usage (live trading): + HYPERLIQUID_KEY=0x... python3 rsi_strategy.py +""" + +import asyncio +import logging +import os +from decimal import Decimal + +from talipp.indicators import RSI + +import basana as b +from basana.external.hyperliquid import Exchange +from basana.core.enums import OrderOperation + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + +COIN = "ETH" +INTERVAL = "1h" +RSI_PERIOD = 14 +RSI_OVERSOLD = 30 +RSI_OVERBOUGHT = 70 +POSITION_SIZE = Decimal("0.01") # ETH + + +class RSIStrategy: + def __init__(self, exchange: Exchange): + self.exchange = exchange + self.closes: list[float] = [] + self.in_position = False + + async def on_bar(self, event: b.BarEvent) -> None: + bar = event.bar + self.closes.append(float(bar.close)) + logger.info("Bar: %s close=%.4f", COIN, bar.close) + + if len(self.closes) < RSI_PERIOD + 1: + return # Not enough data + + rsi_vals = RSI(RSI_PERIOD, self.closes) + rsi = rsi_vals[-1] + logger.info("RSI(14)=%.1f | in_position=%s", rsi, self.in_position) + + if not self.in_position and rsi < RSI_OVERSOLD: + logger.info("RSI oversold (%.1f) - opening LONG %s", rsi, COIN) + try: + order = self.exchange.perps_account.market_open(COIN, OrderOperation.BUY, POSITION_SIZE) + logger.info("Opened: oid=%s filled=%s", order.oid, order.filled) + self.in_position = True + except Exception as e: + logger.error("Failed to open position: %s", e) + + elif self.in_position and rsi > RSI_OVERBOUGHT: + logger.info("RSI overbought (%.1f) - closing LONG %s", rsi, COIN) + try: + order = self.exchange.perps_account.market_close(COIN) + logger.info("Closed: oid=%s filled=%s", order.oid, order.filled) + self.in_position = False + except Exception as e: + logger.error("Failed to close position: %s", e) + + +async def main(): + private_key = os.environ.get("HYPERLIQUID_KEY") # Optional + if not private_key: + logger.info("No HYPERLIQUID_KEY set - running in read-only mode (no trading)") + + d = b.EventDispatcher() + exchange = Exchange(dispatcher=d, private_key=private_key) + + strategy = RSIStrategy(exchange) + exchange.subscribe_to_bar_events(COIN, INTERVAL, strategy.on_bar) + exchange.start() + + logger.info("Strategy running. Ctrl+C to stop.") + try: + await d.run() + except KeyboardInterrupt: + pass + finally: + await exchange.stop() + + +if __name__ == "__main__": + asyncio.run(main()) From 61bb5c0e79f0d02aca07cfea58a2cf239cdd4f17 Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 4 Mar 2026 16:06:36 +0000 Subject: [PATCH 03/17] test: Hyperliquid connector test suite (26 tests) - test_hyperliquid_client.py: public endpoints, auth guard, authenticated trading - test_hyperliquid_exchange.py: market data, WS subscriptions, lifecycle, caching - All mocked with unittest.mock - no network required --- tests/test_hyperliquid_client.py | 156 +++++++++++++++++++++++++++++ tests/test_hyperliquid_exchange.py | 135 +++++++++++++++++++++++++ 2 files changed, 291 insertions(+) create mode 100644 tests/test_hyperliquid_client.py create mode 100644 tests/test_hyperliquid_exchange.py diff --git a/tests/test_hyperliquid_client.py b/tests/test_hyperliquid_client.py new file mode 100644 index 0000000..6b7204b --- /dev/null +++ b/tests/test_hyperliquid_client.py @@ -0,0 +1,156 @@ +# Basana - Hyperliquid connector tests +# +# Licensed under the Apache License, Version 2.0 + +from decimal import Decimal +from unittest.mock import MagicMock, patch +import pytest + +from basana.external.hyperliquid.client.rest import APIClient, Error + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def mock_info(): + """Patch hyperliquid.info.Info with a MagicMock.""" + with patch("basana.external.hyperliquid.client.rest.Info") as MockInfo: + yield MockInfo.return_value + + +@pytest.fixture() +def mock_exchange_sdk(): + """Patch hyperliquid.exchange.Exchange with a MagicMock.""" + with patch("basana.external.hyperliquid.client.rest.HLExchange") as MockEx: + yield MockEx.return_value + + +@pytest.fixture() +def mock_account(): + """Patch eth_account so no real key is required.""" + with patch("basana.external.hyperliquid.client.rest.eth_account") as mock_eth: + mock_wallet = MagicMock() + mock_wallet.address = "0xDEADBEEF" + mock_eth.Account.from_key.return_value = mock_wallet + yield mock_eth + + +# --------------------------------------------------------------------------- +# Public market data +# --------------------------------------------------------------------------- + +class TestPublicEndpoints: + def test_get_all_mids(self, mock_info): + mock_info.all_mids.return_value = {"ETH": "2100.0", "BTC": "70000.0"} + cli = APIClient() + mids = cli.get_all_mids() + assert mids["ETH"] == "2100.0" + assert mids["BTC"] == "70000.0" + mock_info.all_mids.assert_called_once() + + def test_get_l2_snapshot(self, mock_info): + mock_info.l2_snapshot.return_value = { + "coin": "ETH", + "levels": [ + [{"px": "2099.9", "sz": "1.0", "n": 1}], + [{"px": "2100.1", "sz": "1.0", "n": 1}], + ], + } + cli = APIClient() + book = cli.get_l2_snapshot("ETH") + assert book["levels"][0][0]["px"] == "2099.9" + mock_info.l2_snapshot.assert_called_once_with("ETH") + + def test_get_meta(self, mock_info): + mock_info.meta.return_value = { + "universe": [ + {"name": "BTC", "szDecimals": 5, "maxLeverage": 50}, + {"name": "ETH", "szDecimals": 4, "maxLeverage": 25}, + ] + } + cli = APIClient() + meta = cli.get_meta() + coins = [a["name"] for a in meta["universe"]] + assert "BTC" in coins and "ETH" in coins + + def test_get_candles(self, mock_info): + mock_info.candles_snapshot.return_value = [ + {"t": 1700000000000, "o": "2000", "h": "2100", "l": "1990", "c": "2050", "v": "1000"}, + ] + cli = APIClient() + candles = cli.get_candles("ETH", "1h", 1700000000000, 1700003600000) + assert len(candles) == 1 + assert candles[0]["c"] == "2050" + + +# --------------------------------------------------------------------------- +# Auth guard +# --------------------------------------------------------------------------- + +class TestAuthGuard: + def test_get_user_state_without_key_raises(self, mock_info): + cli = APIClient() # no private key + with pytest.raises(Error, match="Private key required"): + cli.get_user_state() + + def test_get_open_orders_without_key_raises(self, mock_info): + cli = APIClient() + with pytest.raises(Error, match="Private key required"): + cli.get_open_orders() + + def test_market_open_without_key_raises(self, mock_info): + cli = APIClient() + with pytest.raises(Error, match="Private key required"): + cli.market_open("ETH", True, 0.1) + + +# --------------------------------------------------------------------------- +# Authenticated endpoints (mocked key) +# --------------------------------------------------------------------------- + +class TestAuthenticatedEndpoints: + def test_get_user_state(self, mock_info, mock_account, mock_exchange_sdk): + mock_info.user_state.return_value = { + "marginSummary": {"accountValue": "5000.0"}, + "assetPositions": [], + } + cli = APIClient(private_key="0xdeadbeef") + state = cli.get_user_state() + assert state["marginSummary"]["accountValue"] == "5000.0" + mock_info.user_state.assert_called_once_with("0xDEADBEEF") + + def test_market_open_success(self, mock_info, mock_account, mock_exchange_sdk): + mock_exchange_sdk.market_open.return_value = {"status": "ok", "response": {"data": {"statuses": [{}]}}} + cli = APIClient(private_key="0xdeadbeef") + result = cli.market_open("ETH", True, 0.1) + assert result["status"] == "ok" + + def test_market_open_api_error_raises(self, mock_info, mock_account, mock_exchange_sdk): + mock_exchange_sdk.market_open.return_value = {"status": "err", "response": "Insufficient margin"} + cli = APIClient(private_key="0xdeadbeef") + with pytest.raises(Error, match="API error"): + cli.market_open("ETH", True, 0.1) + + def test_cancel_order(self, mock_info, mock_account, mock_exchange_sdk): + mock_exchange_sdk.cancel.return_value = {"status": "ok"} + cli = APIClient(private_key="0xdeadbeef") + result = cli.cancel_order("ETH", 12345) + assert result["status"] == "ok" + mock_exchange_sdk.cancel.assert_called_once_with("ETH", 12345) + + def test_set_leverage(self, mock_info, mock_account, mock_exchange_sdk): + mock_exchange_sdk.update_leverage.return_value = {"status": "ok"} + cli = APIClient(private_key="0xdeadbeef") + result = cli.set_leverage("ETH", 10, is_cross=True) + assert result["status"] == "ok" + mock_exchange_sdk.update_leverage.assert_called_once_with(10, "ETH", True) + + def test_address_exposed(self, mock_info, mock_account, mock_exchange_sdk): + cli = APIClient(private_key="0xdeadbeef") + assert cli.address == "0xDEADBEEF" + + def test_address_none_without_key(self, mock_info): + cli = APIClient() + assert cli.address is None diff --git a/tests/test_hyperliquid_exchange.py b/tests/test_hyperliquid_exchange.py new file mode 100644 index 0000000..3706eea --- /dev/null +++ b/tests/test_hyperliquid_exchange.py @@ -0,0 +1,135 @@ +# Basana - Hyperliquid connector tests +# +# Licensed under the Apache License, Version 2.0 + +from decimal import Decimal +from unittest.mock import MagicMock, patch +import pytest + +import basana as b +from basana.external.hyperliquid.exchange import Exchange, Error, AssetInfo + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture() +def mock_api_client(): + with patch("basana.external.hyperliquid.exchange.client.APIClient") as MockClient: + instance = MockClient.return_value + instance.get_all_mids.return_value = {"ETH": "2100.0", "BTC": "70000.0", "SOL": "150.0"} + instance.get_l2_snapshot.return_value = { + "coin": "ETH", + "levels": [ + [{"px": "2099.5", "sz": "2.0", "n": 1}], + [{"px": "2100.5", "sz": "1.5", "n": 1}], + ], + } + instance.get_meta.return_value = { + "universe": [ + {"name": "BTC", "szDecimals": 5, "maxLeverage": 50}, + {"name": "ETH", "szDecimals": 4, "maxLeverage": 25}, + {"name": "SOL", "szDecimals": 2, "maxLeverage": 20}, + ] + } + yield instance + + +@pytest.fixture() +def mock_ws_manager(): + with patch("basana.external.hyperliquid.exchange.websockets.WebsocketManager") as MockWS: + yield MockWS.return_value + + +@pytest.fixture() +def exchange(mock_api_client, mock_ws_manager): + d = b.RealtimeDispatcher(max_concurrent=10) + return Exchange(dispatcher=d) + + +# --------------------------------------------------------------------------- +# Market data +# --------------------------------------------------------------------------- + +class TestMarketData: + def test_get_mid_price(self, exchange): + price = exchange.get_mid_price("ETH") + assert price == Decimal("2100.0") + assert isinstance(price, Decimal) + + def test_get_mid_price_unknown_coin_raises(self, exchange): + with pytest.raises(Error, match="Unknown coin"): + exchange.get_mid_price("NOTACOIN") + + def test_get_bid_ask(self, exchange): + bid, ask = exchange.get_bid_ask("ETH") + assert bid == Decimal("2099.5") + assert ask == Decimal("2100.5") + assert ask > bid + + def test_get_pair_info(self, exchange): + info = exchange.get_pair_info("ETH") + assert isinstance(info, AssetInfo) + assert info.sz_decimals == 4 + assert info.max_leverage == 25 + + def test_get_pair_info_btc(self, exchange): + info = exchange.get_pair_info("BTC") + assert info.sz_decimals == 5 + assert info.max_leverage == 50 + + def test_get_pair_info_cached(self, exchange, mock_api_client): + exchange.get_pair_info("ETH") + exchange.get_pair_info("ETH") + # Meta should only be fetched once (cached) + mock_api_client.get_meta.assert_called_once() + + def test_list_coins(self, exchange): + coins = exchange.list_coins() + assert "BTC" in coins + assert "ETH" in coins + assert "SOL" in coins + assert len(coins) == 3 + + +# --------------------------------------------------------------------------- +# WebSocket subscriptions +# --------------------------------------------------------------------------- + +class TestSubscriptions: + def test_subscribe_to_bar_events(self, exchange, mock_ws_manager): + handler = MagicMock() + exchange.subscribe_to_bar_events("ETH", "1h", handler) + mock_ws_manager.subscribe_to_candle_events.assert_called_once() + args = mock_ws_manager.subscribe_to_candle_events.call_args[0] + # Coin and interval are passed through correctly. + assert args[0] == "ETH" + assert args[1] == "1h" + # The third arg is an internal async wrapper (_on_candle), not the raw handler. + import asyncio + assert asyncio.iscoroutinefunction(args[2]) + + def test_subscribe_to_trade_events(self, exchange, mock_ws_manager): + handler = MagicMock() + exchange.subscribe_to_trade_events("ETH", handler) + mock_ws_manager.subscribe_to_trade_events.assert_called_once_with("ETH", handler) + + def test_subscribe_to_order_book_events(self, exchange, mock_ws_manager): + handler = MagicMock() + exchange.subscribe_to_order_book_events("ETH", handler) + mock_ws_manager.subscribe_to_order_book_events.assert_called_once_with("ETH", handler) + + +# --------------------------------------------------------------------------- +# Lifecycle +# --------------------------------------------------------------------------- + +class TestLifecycle: + def test_start_delegates_to_ws_manager(self, exchange, mock_ws_manager): + exchange.start() + mock_ws_manager.start.assert_called_once() + + def test_perps_account_accessible(self, exchange): + from basana.external.hyperliquid.perps import Account + assert isinstance(exchange.perps_account, Account) From 4783e14ffef9ae2dec8403cbe5225e40c9e52b7d Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 4 Mar 2026 16:11:39 +0000 Subject: [PATCH 04/17] feat: Basana paper trader on Hyperliquid live data RSI+MACD strategy (TradingSignalSource) -> PaperPositionManager - RealtimeDispatcher + Hyperliquid WebSocket bar events - LunarCrush galaxy/sentiment gate before opening positions - Stop-loss (-15%) and take-profit (+30%) on every bar - Persists to paper_trades.json / paper_portfolio.json - Supports multiple coins and configurable bar interval --- samples/hyperliquid/paper_trader.py | 348 ++++++++++++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 samples/hyperliquid/paper_trader.py diff --git a/samples/hyperliquid/paper_trader.py b/samples/hyperliquid/paper_trader.py new file mode 100644 index 0000000..159075a --- /dev/null +++ b/samples/hyperliquid/paper_trader.py @@ -0,0 +1,348 @@ +""" +Autonomous paper trader using Basana + Hyperliquid. + +Architecture (follows Basana patterns): + - RealtimeDispatcher drives the event loop + - Hyperliquid connector provides live bar events via WebSocket + - RSI strategy (TradingSignalSource) emits LONG/SHORT/NEUTRAL signals + - LunarCrush pre-filter gates which coins are eligible + - PaperPositionManager executes trades at live Hyperliquid mid-prices + and tracks P&L in JSON (no real money involved) + +Requirements: + pip install basana talipp hyperliquid-python-sdk + +Usage: + python3 paper_trader.py # all default coins + python3 paper_trader.py --coins ETH BTC # specific coins + python3 paper_trader.py --interval 1h # different bar interval +""" + +import argparse +import asyncio +import dataclasses +import json +import logging +import urllib.request +from datetime import datetime, timezone +from decimal import Decimal +from pathlib import Path +from typing import Dict, List, Optional + +from talipp.indicators import RSI, MACD, BB + +import basana as bs +from basana.external.hyperliquid import Exchange as HLExchange +from basana.core.enums import OrderOperation + +# ── Config ──────────────────────────────────────────────────────────────────── +WORKSPACE = Path(__file__).parent.parent.parent.parent / ".openclaw/workspace" +TRADES_FILE = WORKSPACE / "memory/paper_trades.json" +PORTFOLIO_FILE = WORKSPACE / "memory/paper_portfolio.json" + +LC_KEY = "YOUR_LC_KEY_HERE" +STARTING_BALANCE = 10_000.0 +POSITION_SIZE_USD = 1_000.0 +MAX_POSITIONS = 5 + +RSI_PERIOD = 14 +RSI_OVERSOLD = 32 +RSI_OVERBOUGHT = 68 +MACD_FAST, MACD_SLOW, MACD_SIGNAL = 12, 26, 9 + +STOP_LOSS_PCT = Decimal("-15") +TAKE_PROFIT_PCT = Decimal("30") + +DEFAULT_COINS = ["ETH", "BTC", "SOL", "AVAX", "MATIC", "DOGE", "LINK", "ARB"] +DEFAULT_INTERVAL = "1h" + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", +) +logger = logging.getLogger("paper_trader") + + +# ── Portfolio persistence ───────────────────────────────────────────────────── + +def _load(path: Path) -> dict: + if path.exists(): + with open(path) as f: + return json.load(f) + return {} + + +def _save(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w") as f: + json.dump(data, f, indent=2) + + +@dataclasses.dataclass +class PaperPortfolio: + """In-memory paper portfolio. Persists to JSON on every change.""" + + balance: Decimal = Decimal(STARTING_BALANCE) + positions: Dict[str, dict] = dataclasses.field(default_factory=dict) + closed_trades: List[dict] = dataclasses.field(default_factory=list) + realized_pnl: Decimal = Decimal(0) + + @classmethod + def load(cls) -> "PaperPortfolio": + trades = _load(TRADES_FILE) + portfolio = _load(PORTFOLIO_FILE) + inst = cls( + balance=Decimal(str(portfolio.get("balance_usd", STARTING_BALANCE))), + positions=trades.get("open_positions", {}), + closed_trades=trades.get("closed_trades", []), + realized_pnl=Decimal(str(portfolio.get("realized_pnl", 0))), + ) + return inst + + def save(self) -> None: + trades_data = { + "open_positions": self.positions, + "closed_trades": self.closed_trades, + "trades": list(self.positions.values()) + self.closed_trades, + } + portfolio_data = { + "balance_usd": float(self.balance), + "starting_balance": STARTING_BALANCE, + "realized_pnl": float(self.realized_pnl), + } + _save(TRADES_FILE, trades_data) + _save(PORTFOLIO_FILE, portfolio_data) + + @property + def open_count(self) -> int: + return len(self.positions) + + def open_position(self, coin: str, direction: str, price: Decimal, reason: str) -> None: + if coin in self.positions: + return + if self.open_count >= MAX_POSITIONS: + logger.info("Max positions reached, skipping %s", coin) + return + size = Decimal(POSITION_SIZE_USD) + if self.balance < size: + logger.info("Insufficient balance ($%.2f), skipping %s", self.balance, coin) + return + + qty = size / price + self.positions[coin] = { + "symbol": coin, + "direction": direction, + "size_usd": float(size), + "entry_price": float(price), + "quantity": float(qty), + "opened_at": datetime.now(timezone.utc).isoformat(), + "reason": reason, + } + self.balance -= size + self.save() + logger.info("OPEN %s %s @ $%s | qty=%.4f | %s", direction, coin, price, qty, reason) + + def close_position(self, coin: str, price: Decimal, reason: str) -> Optional[Decimal]: + pos = self.positions.pop(coin, None) + if not pos: + return None + + direction = pos["direction"] + entry = Decimal(str(pos["entry_price"])) + qty = Decimal(str(pos["quantity"])) + size = Decimal(str(pos["size_usd"])) + + pnl = (price - entry) * qty if direction == "LONG" else (entry - price) * qty + pnl_pct = (pnl / size) * 100 + return_usd = size + pnl + + self.closed_trades.append({ + **pos, + "exit_price": float(price), + "closed_at": datetime.now(timezone.utc).isoformat(), + "pnl_usd": float(pnl), + "pnl_pct": float(pnl_pct), + "reason": reason, + }) + self.balance += return_usd + self.realized_pnl += pnl + self.save() + logger.info("CLOSE %s %s @ $%s | P&L: $%.2f (%.1f%%) | %s", direction, coin, price, pnl, pnl_pct, reason) + return pnl_pct + + def unrealized_pnl(self, coin: str, current_price: Decimal) -> Optional[Decimal]: + pos = self.positions.get(coin) + if not pos: + return None + entry = Decimal(str(pos["entry_price"])) + qty = Decimal(str(pos["quantity"])) + size = Decimal(str(pos["size_usd"])) + direction = pos["direction"] + pnl = (current_price - entry) * qty if direction == "LONG" else (entry - current_price) * qty + return (pnl / size) * 100 + + +# ── LunarCrush gate ─────────────────────────────────────────────────────────── + +class LunarCrushGate: + """Checks LC galaxy score and sentiment before allowing a trade.""" + + def __init__(self, min_galaxy: float = 55, min_sentiment: float = 70): + self._min_galaxy = min_galaxy + self._min_sentiment = min_sentiment + self._cache: Dict[str, tuple] = {} # coin -> (galaxy, sentiment, ts) + + def is_approved(self, coin: str) -> bool: + url = f"https://lunarcrush.com/api4/public/coins/{coin.lower()}/v1" + try: + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {LC_KEY}", + "User-Agent": "paper-trader/1.0", + }) + data = json.loads(urllib.request.urlopen(req, timeout=8).read()) + d = data.get("data", {}) + galaxy = float(d.get("galaxy_score") or 0) + sentiment = float(d.get("sentiment") or 50) + logger.info("LC %s: galaxy=%.1f sentiment=%.0f%%", coin, galaxy, sentiment) + return galaxy >= self._min_galaxy and sentiment >= self._min_sentiment + except Exception as e: + logger.warning("LC check failed for %s: %s - allowing trade", coin, e) + return True # Fail open so LC outage doesn't block all trades + + +# ── Basana strategy (TradingSignalSource) ───────────────────────────────────── + +class RSIMACDStrategy(bs.TradingSignalSource): + """Emits LONG/SHORT/NEUTRAL signals based on RSI crossover + MACD confirmation.""" + + def __init__(self, dispatcher: bs.EventDispatcher, coin: str, lc_gate: LunarCrushGate): + super().__init__(dispatcher) + self._coin = coin + self._lc_gate = lc_gate + self._rsi = RSI(RSI_PERIOD) + self._macd = MACD(MACD_FAST, MACD_SLOW, MACD_SIGNAL) + self._pair = bs.Pair(coin, "USD") + + async def on_bar_event(self, bar_event: bs.BarEvent) -> None: + close = float(bar_event.bar.close) + self._rsi.add(close) + self._macd.add(close) + + # Need enough history + if len(self._rsi) < 2 or self._rsi[-2] is None or self._rsi[-1] is None: + return + if len(self._macd) < 2 or self._macd[-1] is None: + return + + rsi_prev = self._rsi[-2] + rsi_now = self._rsi[-1] + macd_hist = self._macd[-1].histogram or 0 + + # RSI crosses into oversold + MACD histogram positive (momentum turning up) + if rsi_prev >= RSI_OVERSOLD and rsi_now < RSI_OVERSOLD and macd_hist > 0: + if self._lc_gate.is_approved(self._coin): + logger.info("%s LONG signal: RSI %.1f->%.1f MACD_hist=%.5g", self._coin, rsi_prev, rsi_now, macd_hist) + self.push(bs.TradingSignal(bar_event.when, bs.Position.LONG, self._pair)) + + # RSI crosses into overbought - exit long / go neutral + elif rsi_prev <= RSI_OVERBOUGHT and rsi_now > RSI_OVERBOUGHT: + logger.info("%s NEUTRAL signal: RSI %.1f->%.1f (overbought)", self._coin, rsi_prev, rsi_now) + self.push(bs.TradingSignal(bar_event.when, bs.Position.NEUTRAL, self._pair)) + + +# ── Paper position manager ──────────────────────────────────────────────────── + +class PaperPositionManager: + """Receives trading signals and manages paper positions. + + Uses live Hyperliquid mid-prices for execution. + Enforces stop-loss and take-profit on every bar. + """ + + def __init__(self, hl_exchange: HLExchange, portfolio: PaperPortfolio): + self._exchange = hl_exchange + self._portfolio = portfolio + + async def on_trading_signal(self, signal: bs.TradingSignal) -> None: + pairs = list(signal.get_pairs()) + for pair, target_position in pairs: + coin = pair.base_symbol + price = self._exchange.get_mid_price(coin) + + if target_position == bs.Position.LONG and coin not in self._portfolio.positions: + self._portfolio.open_position( + coin, "LONG", price, + reason=f"RSI+MACD signal at {signal.when.isoformat()}", + ) + + elif target_position == bs.Position.NEUTRAL and coin in self._portfolio.positions: + self._portfolio.close_position(coin, price, reason="RSI overbought exit") + + async def on_bar_event(self, bar_event: bs.BarEvent) -> None: + """Check stop-loss and take-profit on every bar for all open positions.""" + coin = bar_event.bar.pair.base_symbol + if coin not in self._portfolio.positions: + return + + price = Decimal(str(bar_event.bar.close)) + pnl_pct = self._portfolio.unrealized_pnl(coin, price) + if pnl_pct is None: + return + + logger.debug("%s unrealized P&L: %.1f%%", coin, pnl_pct) + + if pnl_pct <= STOP_LOSS_PCT: + logger.warning("%s stop-loss triggered at %.1f%%", coin, pnl_pct) + self._portfolio.close_position(coin, price, reason=f"Stop-loss {pnl_pct:.1f}%") + + elif pnl_pct >= TAKE_PROFIT_PCT: + logger.info("%s take-profit triggered at +%.1f%%", coin, pnl_pct) + self._portfolio.close_position(coin, price, reason=f"Take-profit +{pnl_pct:.1f}%") + + +# ── Main ───────────────────────────────────────────────────────────────────── + +async def main(coins: List[str], interval: str) -> None: + portfolio = PaperPortfolio.load() + logger.info( + "Portfolio loaded: balance=$%.2f | open=%d | realized_pnl=$%.2f", + portfolio.balance, portfolio.open_count, portfolio.realized_pnl, + ) + + dispatcher = bs.realtime_dispatcher() + hl = HLExchange(dispatcher=dispatcher) + lc_gate = LunarCrushGate() + position_mgr = PaperPositionManager(hl, portfolio) + + for coin in coins: + strategy = RSIMACDStrategy(dispatcher, coin, lc_gate) + strategy.subscribe_to_trading_signals(position_mgr.on_trading_signal) + hl.subscribe_to_bar_events(coin, interval, strategy.on_bar_event) + hl.subscribe_to_bar_events(coin, interval, position_mgr.on_bar_event) + + hl.start() + logger.info("Paper trader live on %d coins (%s bars). Ctrl+C to stop.", len(coins), interval) + + try: + await dispatcher.run() + except KeyboardInterrupt: + pass + finally: + await hl.stop() + + # Final summary + logger.info("=== Final portfolio ===") + logger.info("Balance: $%.2f | Realized P&L: $%.2f", portfolio.balance, portfolio.realized_pnl) + for coin, pos in portfolio.positions.items(): + price = hl.get_mid_price(coin) + pnl_pct = portfolio.unrealized_pnl(coin, price) + logger.info(" %s %s: unrealized %.1f%%", pos["direction"], coin, pnl_pct or 0) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Basana paper trader on Hyperliquid") + parser.add_argument("--coins", nargs="+", default=DEFAULT_COINS, help="Coins to trade") + parser.add_argument("--interval", default=DEFAULT_INTERVAL, help="Bar interval (1m, 5m, 1h, 4h...)") + args = parser.parse_args() + + asyncio.run(main(coins=args.coins, interval=args.interval)) From 63d73b320ed145673c280882b97d514a131e0935 Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 4 Mar 2026 16:14:02 +0000 Subject: [PATCH 05/17] fix: use actual portfolio coins as defaults (ASTER, AERO, PYTH, XRP, DOGE + LC signal coins) --- samples/hyperliquid/paper_trader.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/samples/hyperliquid/paper_trader.py b/samples/hyperliquid/paper_trader.py index 159075a..2681564 100644 --- a/samples/hyperliquid/paper_trader.py +++ b/samples/hyperliquid/paper_trader.py @@ -53,7 +53,7 @@ STOP_LOSS_PCT = Decimal("-15") TAKE_PROFIT_PCT = Decimal("30") -DEFAULT_COINS = ["ETH", "BTC", "SOL", "AVAX", "MATIC", "DOGE", "LINK", "ARB"] +DEFAULT_COINS = ["ASTER", "AERO", "PYTH", "XRP", "DOGE", "POL", "TIA", "RYO"] DEFAULT_INTERVAL = "1h" logging.basicConfig( @@ -267,7 +267,17 @@ async def on_trading_signal(self, signal: bs.TradingSignal) -> None: pairs = list(signal.get_pairs()) for pair, target_position in pairs: coin = pair.base_symbol - price = self._exchange.get_mid_price(coin) + try: + price = self._exchange.get_mid_price(coin) + except Exception: + # Coin not on Hyperliquid perps - use LC price + lc_data = json.loads(urllib.request.urlopen( + urllib.request.Request( + f"https://lunarcrush.com/api4/public/coins/{coin.lower()}/v1", + headers={"Authorization": f"Bearer {LC_KEY}", "User-Agent": "paper-trader/1.0"} + ), timeout=8 + ).read()) + price = Decimal(str(lc_data["data"]["price"])) if target_position == bs.Position.LONG and coin not in self._portfolio.positions: self._portfolio.open_position( From 5827e6ebf75f3bb0a453e2cce60eb1a2ba774ab8 Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 4 Mar 2026 16:18:08 +0000 Subject: [PATCH 06/17] fix: self-review corrections - exchange.py: fix bar.Bar constructor (correct arg order + types), fix BarEvent construction (when, bar), replace non-existent ts_to_datetime with datetime.fromtimestamp, add error handling - websockets.py: fix _handle_message routing to match Hyperliquid channel names and coin-scoped payloads correctly - perps.py: move asyncio import to top of file (was at bottom) - client/rest.py: add Tuple to typing imports - All 26 tests still passing --- basana/external/hyperliquid/client/rest.py | 2 +- basana/external/hyperliquid/exchange.py | 38 +++++++++++----------- basana/external/hyperliquid/perps.py | 3 +- basana/external/hyperliquid/websockets.py | 33 +++++++++++++------ 4 files changed, 45 insertions(+), 31 deletions(-) diff --git a/basana/external/hyperliquid/client/rest.py b/basana/external/hyperliquid/client/rest.py index 891e5c6..7f4e695 100644 --- a/basana/external/hyperliquid/client/rest.py +++ b/basana/external/hyperliquid/client/rest.py @@ -7,7 +7,7 @@ # POST https://api.hyperliquid.xyz/info - market data and account info # POST https://api.hyperliquid.xyz/exchange - order placement and management -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import eth_account import logging diff --git a/basana/external/hyperliquid/exchange.py b/basana/external/hyperliquid/exchange.py index 10f5892..57025bc 100644 --- a/basana/external/hyperliquid/exchange.py +++ b/basana/external/hyperliquid/exchange.py @@ -15,8 +15,9 @@ # await d.run() from decimal import Decimal -from typing import Callable, List, Optional, Tuple +from typing import Callable, Dict, List, Optional, Tuple import dataclasses +import datetime import logging import aiohttp @@ -96,23 +97,22 @@ def subscribe_to_bar_events( pair = self._coin_to_pair(coin) async def _on_candle(data: dict) -> None: - candle = data.get("candle", data) - if not candle.get("isClosed", True): - return # Only emit completed bars (consistent with Binance connector) - b = bar.Bar( - datetime=None, # Will be set from timestamp - open=Decimal(str(candle["o"])), - high=Decimal(str(candle["h"])), - low=Decimal(str(candle["l"])), - close=Decimal(str(candle["c"])), - volume=Decimal(str(candle["v"])), - ) - event = bar.BarEvent( - when=bar.ts_to_datetime(int(candle["t"])), - pair=pair, - bar=b, - ) - await event_handler(event) + # Hyperliquid WS candle payload: {"t": ms, "T": ms, "o": str, "h": str, "l": str, "c": str, "v": str} + # The WS only delivers closed candles when a new one opens, so no "isClosed" check needed. + try: + when = datetime.datetime.fromtimestamp(int(data["T"]) / 1e3, tz=datetime.timezone.utc) + b = bar.Bar( + when, + pair, + Decimal(str(data["o"])), + Decimal(str(data["h"])), + Decimal(str(data["l"])), + Decimal(str(data["c"])), + Decimal(str(data["v"])), + ) + await event_handler(bar.BarEvent(when, b)) + except (KeyError, ValueError) as e: + logger.warning("Malformed candle data for %s: %s - %s", coin, e, data) self._ws_mgr.subscribe_to_candle_events(coin, interval, _on_candle) @@ -177,7 +177,7 @@ def get_pair_info(self, coin: str) -> AssetInfo: return self._asset_info[coin] def list_coins(self) -> List[str]: - """Return a list of all tradeable coins.""" + """Return a list of all tradeable perpetuals coins.""" meta = self._cli.get_meta() return [a["name"] for a in meta.get("universe", [])] diff --git a/basana/external/hyperliquid/perps.py b/basana/external/hyperliquid/perps.py index 20191f6..c764f08 100644 --- a/basana/external/hyperliquid/perps.py +++ b/basana/external/hyperliquid/perps.py @@ -6,6 +6,7 @@ from decimal import Decimal from typing import Callable, List, Optional +import asyncio import dataclasses import logging @@ -235,4 +236,4 @@ def _parse_order_result(result: dict, coin: str) -> OrderInfo: ) -import asyncio # noqa: E402 (needed for fill handler) + diff --git a/basana/external/hyperliquid/websockets.py b/basana/external/hyperliquid/websockets.py index 56fb83d..8af337e 100644 --- a/basana/external/hyperliquid/websockets.py +++ b/basana/external/hyperliquid/websockets.py @@ -156,15 +156,28 @@ async def _handle_message(self, message: dict) -> None: channel = message.get("channel", "") data = message.get("data", {}) - # Route to registered handlers by channel key. + if not channel or channel == "subscriptionResponse": + return # Ignore subscription ack messages + + # Match handlers: key format is "type:coin[:interval]" or "type:address" + # Hyperliquid sends channel names like "candle", "trades", "l2Book", "orderUpdates", "userFills" for key, handlers in self._handlers.items(): sub_type = key.split(":")[0] - if channel == sub_type or channel.startswith(sub_type): - for handler in handlers: - try: - if asyncio.iscoroutinefunction(handler): - await handler(data) - else: - handler(data) - except Exception as e: - logger.error("Handler error for %s: %s", key, e) + if channel != sub_type: + continue + + # For coin-scoped channels, also match on coin name from data payload + if sub_type in ("candle", "trades", "l2Book"): + payload_coin = data.get("coin") or data.get("s", "") + key_coin = key.split(":")[1] if ":" in key else "" + if key_coin and payload_coin and payload_coin != key_coin: + continue + + for handler in handlers: + try: + if asyncio.iscoroutinefunction(handler): + await handler(data) + else: + handler(data) + except Exception as e: + logger.error("Handler error for %s: %s", key, e) From e55ca0c99f05580cce44b82cfd191b72105a50f5 Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 4 Mar 2026 16:26:17 +0000 Subject: [PATCH 07/17] refactor: align with Basana project conventions - Add Apache 2.0 license header to all files - Add config.py (base URLs for mainnet/testnet) - Add helpers.py (timestamp_to_datetime, pair_to_coin, coin_to_pair) - REST client: all methods now async using run_in_executor so blocking SDK calls don't block the event loop - WebSocket: extend core_ws.WebSocketClient (subscribe_to_channels + handle_message) instead of a custom aiohttp loop - exchange.py: use dispatcher.subscribe for producer auto-registration, add BarEventSource class for proper bar construction - All 27 tests updated and passing --- basana/external/hyperliquid/__init__.py | 24 +- .../external/hyperliquid/client/__init__.py | 16 +- basana/external/hyperliquid/client/rest.py | 208 +++++++------- basana/external/hyperliquid/config.py | 41 +++ basana/external/hyperliquid/exchange.py | 219 +++++++------- basana/external/hyperliquid/helpers.py | 34 +++ basana/external/hyperliquid/perps.py | 155 +++++----- basana/external/hyperliquid/websockets.py | 268 +++++++----------- tests/test_hyperliquid_client.py | 55 ++-- tests/test_hyperliquid_exchange.py | 153 +++++++--- 10 files changed, 625 insertions(+), 548 deletions(-) create mode 100644 basana/external/hyperliquid/config.py create mode 100644 basana/external/hyperliquid/helpers.py diff --git a/basana/external/hyperliquid/__init__.py b/basana/external/hyperliquid/__init__.py index 485d422..64755d9 100644 --- a/basana/external/hyperliquid/__init__.py +++ b/basana/external/hyperliquid/__init__.py @@ -1,16 +1,26 @@ -# Basana - Hyperliquid connector +# Basana # -# Licensed under the Apache License, Version 2.0 +# Copyright 2022 Gabriel Martin Becedillas Ruiz +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. -from .exchange import Exchange, AssetInfo, Error, FillEvent, OrderInfo, Position -from .perps import OrderInfo, FillEvent, FillEventHandler +from .exchange import AssetInfo, BarEventHandler, Error, Exchange, OrderInfo, Position __all__ = [ - "Exchange", "AssetInfo", + "BarEventHandler", "Error", - "FillEvent", - "FillEventHandler", + "Exchange", "OrderInfo", "Position", ] diff --git a/basana/external/hyperliquid/client/__init__.py b/basana/external/hyperliquid/client/__init__.py index 6d87cdd..ffb7b1c 100644 --- a/basana/external/hyperliquid/client/__init__.py +++ b/basana/external/hyperliquid/client/__init__.py @@ -1,6 +1,18 @@ -# Basana - Hyperliquid connector +# Basana # -# Licensed under the Apache License, Version 2.0 +# Copyright 2022 Gabriel Martin Becedillas Ruiz +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from .rest import APIClient, Error diff --git a/basana/external/hyperliquid/client/rest.py b/basana/external/hyperliquid/client/rest.py index 7f4e695..6f5b84d 100644 --- a/basana/external/hyperliquid/client/rest.py +++ b/basana/external/hyperliquid/client/rest.py @@ -1,141 +1,150 @@ -# Basana - Hyperliquid connector +# Basana # -# Licensed under the Apache License, Version 2.0 +# Copyright 2022 Gabriel Martin Becedillas Ruiz # -# REST client wrapping the Hyperliquid Python SDK. -# Hyperliquid uses two endpoints: -# POST https://api.hyperliquid.xyz/info - market data and account info -# POST https://api.hyperliquid.xyz/exchange - order placement and management - -from typing import Any, Dict, List, Optional, Tuple -import eth_account +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Dict, List, Optional +import asyncio +import functools import logging +import eth_account from hyperliquid.info import Info from hyperliquid.exchange import Exchange as HLExchange -from hyperliquid.utils import constants + +from basana.core.config import get_config_value +from basana.external.hyperliquid import config + logger = logging.getLogger(__name__) class Error(Exception): - """Raised when the Hyperliquid API returns an error.""" - - def __init__(self, message: str, response: Optional[dict] = None): - super().__init__(message) + def __init__(self, msg: str, response: Optional[dict] = None): + super().__init__(msg) self.response = response class APIClient: - """Low-level Hyperliquid REST client. + """Hyperliquid REST API client. + + Wraps the official ``hyperliquid-python-sdk``, running all blocking SDK calls + in a thread pool executor so as not to block the async event loop. :param private_key: Optional EVM private key (hex string with 0x prefix). - Required for trading. If not set, only public/read endpoints are available. - :param testnet: If True, connects to the Hyperliquid testnet. + Required for trading. If omitted, only public endpoints are available. + :param config_overrides: Optional dict for overriding config settings (base URL, timeout). """ def __init__( self, private_key: Optional[str] = None, - testnet: bool = False, + config_overrides: dict = {}, ): - self._base_url = constants.TESTNET_API_URL if testnet else constants.MAINNET_API_URL - self._info = Info(self._base_url, skip_ws=True) + base_url = get_config_value(config.DEFAULTS, "api.http.base_url", overrides=config_overrides).rstrip("/") + self._info = Info(base_url, skip_ws=True) self._wallet: Optional[Any] = None self._exchange: Optional[HLExchange] = None if private_key: self._wallet = eth_account.Account.from_key(private_key) - self._exchange = HLExchange(self._wallet, self._base_url) + self._exchange = HLExchange(self._wallet, base_url) # ------------------------------------------------------------------ - # Market data + # Market data (public) # ------------------------------------------------------------------ - def get_all_mids(self) -> Dict[str, str]: - """Return mid prices for all coins. Returns {coin: mid_price_str}.""" - return self._info.all_mids() + async def get_all_mids(self) -> Dict[str, str]: + """Return mid prices for all coins as ``{coin: price_str}``.""" + return await self._run(self._info.all_mids) - def get_l2_snapshot(self, coin: str) -> dict: - """Return the current L2 order book for a coin.""" - return self._info.l2_snapshot(coin) + async def get_l2_snapshot(self, coin: str) -> dict: + """Return the current L2 order book snapshot for ``coin``.""" + return await self._run(self._info.l2_snapshot, coin) - def get_candles(self, coin: str, interval: str, start_time: int, end_time: int) -> List[dict]: - """Return OHLCV candles. + async def get_candles(self, coin: str, interval: str, start_time: int, end_time: int) -> List[dict]: + """Return OHLCV candle data. - :param coin: e.g. "ETH", "BTC" - :param interval: e.g. "1m", "5m", "1h", "4h", "1d" + :param coin: e.g. ``"ETH"``, ``"BTC"`` + :param interval: e.g. ``"1m"``, ``"1h"``, ``"4h"``, ``"1d"`` :param start_time: Start timestamp in milliseconds. :param end_time: End timestamp in milliseconds. """ - return self._info.candles_snapshot(coin, interval, start_time, end_time) + return await self._run(self._info.candles_snapshot, coin, interval, start_time, end_time) - def get_meta(self) -> dict: - """Return exchange metadata: all tradeable assets, lot sizes, etc.""" - return self._info.meta() + async def get_meta(self) -> dict: + """Return exchange metadata: tradeable assets, lot sizes, max leverage.""" + return await self._run(self._info.meta) - def get_funding_history(self, coin: str, start_time: int, end_time: Optional[int] = None) -> List[dict]: - """Return funding rate history for a coin.""" - return self._info.funding_history(coin, start_time, end_time) + async def get_funding_history(self, coin: str, start_time: int, end_time: Optional[int] = None) -> List[dict]: + """Return funding rate history for ``coin``.""" + return await self._run(self._info.funding_history, coin, start_time, end_time) # ------------------------------------------------------------------ - # Account data (requires wallet) + # Account data (requires private key) # ------------------------------------------------------------------ - def _require_auth(self) -> None: - if self._wallet is None: - raise Error("Private key required for this operation") - - def get_user_state(self) -> dict: - """Return account state: balances, positions, margin info.""" + async def get_user_state(self) -> dict: + """Return account state: balances, open positions, margin summary.""" self._require_auth() - return self._info.user_state(self._wallet.address) + return await self._run(self._info.user_state, self._wallet.address) - def get_open_orders(self) -> List[dict]: + async def get_open_orders(self) -> List[dict]: """Return all open orders for this account.""" self._require_auth() - return self._info.open_orders(self._wallet.address) + return await self._run(self._info.open_orders, self._wallet.address) - def get_order_status(self, oid: int) -> dict: - """Return the status of a specific order by ID.""" + async def get_order_status(self, oid: int) -> dict: + """Return the status of order ``oid``.""" self._require_auth() - return self._info.query_order_by_oid(self._wallet.address, oid) + return await self._run(self._info.query_order_by_oid, self._wallet.address, oid) - def get_user_fills(self, start_time: int) -> List[dict]: - """Return trade history (fills) since start_time (ms).""" + async def get_user_fills(self, start_time: int) -> List[dict]: + """Return trade fills since ``start_time`` (milliseconds).""" self._require_auth() - return self._info.user_fills_by_time(self._wallet.address, start_time) + return await self._run(self._info.user_fills_by_time, self._wallet.address, start_time) # ------------------------------------------------------------------ - # Trading (requires wallet) + # Trading (requires private key) # ------------------------------------------------------------------ - def market_open(self, coin: str, is_buy: bool, sz: float, slippage: float = 0.01) -> dict: + async def market_open(self, coin: str, is_buy: bool, sz: float, slippage: float = 0.01) -> dict: """Place a market order. - :param coin: e.g. "ETH" - :param is_buy: True for long, False for short. + :param coin: e.g. ``"ETH"`` + :param is_buy: ``True`` for long, ``False`` for short. :param sz: Size in base asset units. - :param slippage: Max acceptable slippage (default 1%). + :param slippage: Maximum acceptable slippage (default 1 %). """ self._require_auth() - result = self._exchange.market_open(coin, is_buy, sz, None, slippage) + result = await self._run(self._exchange.market_open, coin, is_buy, sz, None, slippage) self._check_result(result) return result - def market_close(self, coin: str, sz: Optional[float] = None, slippage: float = 0.01) -> dict: + async def market_close(self, coin: str, sz: Optional[float] = None, slippage: float = 0.01) -> dict: """Close an open position at market price. - :param coin: e.g. "ETH" - :param sz: Size to close. If None, closes the entire position. - :param slippage: Max acceptable slippage (default 1%). + :param coin: e.g. ``"ETH"`` + :param sz: Amount to close. ``None`` closes the entire position. + :param slippage: Maximum acceptable slippage (default 1 %). """ self._require_auth() - result = self._exchange.market_close(coin, sz, None, slippage) + result = await self._run(self._exchange.market_close, coin, sz, None, slippage) self._check_result(result) return result - def limit_order( + async def limit_order( self, coin: str, is_buy: bool, @@ -145,60 +154,57 @@ def limit_order( ) -> dict: """Place a limit order. - :param coin: e.g. "ETH" - :param is_buy: True to buy, False to sell. + :param coin: e.g. ``"ETH"`` + :param is_buy: ``True`` to buy, ``False`` to sell. :param sz: Size in base asset units. - :param limit_px: Limit price. - :param reduce_only: If True, the order can only reduce an existing position. + :param limit_px: Limit price in USD. + :param reduce_only: If ``True``, the order can only reduce an existing position. """ self._require_auth() order_type = {"limit": {"tif": "Gtc"}} - result = self._exchange.order(coin, is_buy, sz, limit_px, order_type, reduce_only=reduce_only) + result = await self._run(self._exchange.order, coin, is_buy, sz, limit_px, order_type, reduce_only) self._check_result(result) return result - def cancel_order(self, coin: str, oid: int) -> dict: - """Cancel an open order by ID.""" + async def cancel_order(self, coin: str, oid: int) -> dict: + """Cancel order ``oid`` for ``coin``.""" self._require_auth() - result = self._exchange.cancel(coin, oid) + result = await self._run(self._exchange.cancel, coin, oid) self._check_result(result) return result - def cancel_all_orders(self, coin: Optional[str] = None) -> None: - """Cancel all open orders, optionally filtered by coin.""" - self._require_auth() - orders = self.get_open_orders() - if coin: - orders = [o for o in orders if o.get("coin") == coin] - for order in orders: - try: - self.cancel_order(order["coin"], order["oid"]) - except Error as e: - logger.warning("Failed to cancel order %s: %s", order["oid"], e) - - def set_leverage(self, coin: str, leverage: int, is_cross: bool = True) -> dict: - """Set leverage for a coin. - - :param coin: e.g. "ETH" - :param leverage: Leverage multiplier (1-50 depending on asset). - :param is_cross: True for cross margin, False for isolated. + async def set_leverage(self, coin: str, leverage: int, is_cross: bool = True) -> dict: + """Set leverage for ``coin``. + + :param coin: e.g. ``"ETH"`` + :param leverage: Leverage multiplier (1–50 depending on the asset). + :param is_cross: ``True`` for cross margin, ``False`` for isolated. """ self._require_auth() - result = self._exchange.update_leverage(leverage, coin, is_cross) + result = await self._run(self._exchange.update_leverage, leverage, coin, is_cross) self._check_result(result) return result - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - @property def address(self) -> Optional[str]: - """Return the wallet address, or None if not authenticated.""" + """Wallet address, or ``None`` when not authenticated.""" return self._wallet.address if self._wallet else None + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _require_auth(self) -> None: + if self._wallet is None: + raise Error("Private key required for this operation") + @staticmethod def _check_result(result: dict) -> None: - status = result.get("status") - if status != "ok": + if result.get("status") != "ok": raise Error(f"API error: {result}", response=result) + + @staticmethod + async def _run(fn, *args) -> Any: + """Run a blocking SDK call in the default thread pool executor.""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, functools.partial(fn, *args)) diff --git a/basana/external/hyperliquid/config.py b/basana/external/hyperliquid/config.py new file mode 100644 index 0000000..feb55c2 --- /dev/null +++ b/basana/external/hyperliquid/config.py @@ -0,0 +1,41 @@ +# Basana +# +# Copyright 2022 Gabriel Martin Becedillas Ruiz +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +DEFAULTS = { + "api": { + "http": { + "base_url": "https://api.hyperliquid.xyz/", + "timeout": 30, + }, + "websockets": { + "base_url": "wss://api.hyperliquid.xyz/ws", + "heartbeat": 30, + }, + } +} + +TESTNET_DEFAULTS = { + "api": { + "http": { + "base_url": "https://api.hyperliquid-testnet.xyz/", + "timeout": 30, + }, + "websockets": { + "base_url": "wss://api.hyperliquid-testnet.xyz/ws", + "heartbeat": 30, + }, + } +} diff --git a/basana/external/hyperliquid/exchange.py b/basana/external/hyperliquid/exchange.py index 57025bc..bf7bf78 100644 --- a/basana/external/hyperliquid/exchange.py +++ b/basana/external/hyperliquid/exchange.py @@ -1,43 +1,38 @@ -# Basana - Hyperliquid connector +# Basana # -# Licensed under the Apache License, Version 2.0 +# Copyright 2022 Gabriel Martin Becedillas Ruiz # -# Main Exchange class - mirrors the Basana Binance Exchange interface -# but targets Hyperliquid perpetuals (the primary use case). +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at # -# Usage: -# async with aiohttp.ClientSession() as session: -# exchange = Exchange( -# dispatcher=d, -# private_key="0x...", # optional; omit for read-only -# ) -# exchange.subscribe_to_bar_events("ETH", "1h", my_handler) -# await d.run() +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from decimal import Decimal -from typing import Callable, Dict, List, Optional, Tuple +from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple import dataclasses -import datetime import logging import aiohttp -from basana.core import bar, dispatcher +from basana.core import bar, dispatcher, event from basana.core.pair import Pair, PairInfo +from . import client, helpers, perps, websockets -from . import client, perps, websockets logger = logging.getLogger(__name__) -# Re-export common types so callers only need to import from this module. +BarEventHandler = Callable[[bar.BarEvent], Awaitable[Any]] Error = client.Error -FillEvent = perps.FillEvent -FillEventHandler = perps.FillEventHandler OrderInfo = perps.OrderInfo Position = perps.Position -BarEventHandler = bar.BarEventHandler - @dataclasses.dataclass(frozen=True) class AssetInfo(PairInfo): @@ -47,138 +42,140 @@ class AssetInfo(PairInfo): :param max_leverage: Maximum allowed leverage. """ + #: The number of decimal places for order size. sz_decimals: int + #: Maximum allowed leverage. max_leverage: int +class BarEventSource(websockets.CandleEventSource): + """Converts raw Hyperliquid candle messages into Basana :class:`~basana.core.bar.BarEvent` objects.""" + + def __init__(self, pair: Pair, producer: event.Producer): + super().__init__(producer=producer) + self._pair = pair + + async def push_from_message(self, message: dict): + try: + when = helpers.timestamp_to_datetime(int(message["T"])) + b = bar.Bar( + when, + self._pair, + Decimal(str(message["o"])), + Decimal(str(message["h"])), + Decimal(str(message["l"])), + Decimal(str(message["c"])), + Decimal(str(message["v"])), + ) + self.push(bar.BarEvent(when, b)) + except (KeyError, ValueError) as e: + logger.warning("Malformed candle message for %s: %s — %s", self._pair, e, message) + + class Exchange: - """A Basana-compatible client for `Hyperliquid `_ DEX. + """A client for `Hyperliquid `_ decentralized perpetuals exchange. - Supports perpetuals trading, real-time bar/trade/order-book feeds, - and position management. + Supports perpetuals trading and real-time bar, trade, and order-book feeds. - :param dispatcher: The Basana event dispatcher. - :param private_key: Optional EVM private key (hex, with 0x prefix). - Required for trading and user-specific subscriptions. + :param dispatcher: The event dispatcher. + :param private_key: Optional EVM private key (hex string with 0x prefix). + Required for trading and user-specific WebSocket subscriptions. If omitted, only public market data endpoints are available. - :param testnet: If True, connects to the Hyperliquid testnet. - :param session: Optional aiohttp.ClientSession for connection reuse. + :param session: Optional :class:`aiohttp.ClientSession` for connection reuse. + :param config_overrides: Optional dict for overriding config settings. """ def __init__( self, dispatcher: dispatcher.EventDispatcher, private_key: Optional[str] = None, - testnet: bool = False, session: Optional[aiohttp.ClientSession] = None, + config_overrides: dict = {}, ): self._dispatcher = dispatcher - self._cli = client.APIClient(private_key=private_key, testnet=testnet) - self._ws_mgr = websockets.WebsocketManager(dispatcher, testnet=testnet, session=session) - self._perps = perps.Account(self._cli, self._ws_mgr) - self._asset_info: dict[str, AssetInfo] = {} + self._cli = client.APIClient(private_key=private_key, config_overrides=config_overrides) + self._ws = websockets.WebSocketClient(session=session, config_overrides=config_overrides) + self._perps = perps.Account(self._cli, self._ws) + self._asset_info: Dict[str, AssetInfo] = {} # ------------------------------------------------------------------ - # Market data subscriptions (public) + # Market data subscriptions # ------------------------------------------------------------------ - def subscribe_to_bar_events( - self, - coin: str, - interval: str, - event_handler: BarEventHandler, - ) -> None: - """Subscribe to OHLCV bar events via WebSocket. - - :param coin: e.g. "ETH", "BTC" - :param interval: One of "1m", "3m", "5m", "15m", "30m", "1h", "2h", "4h", "6h", "8h", "12h", "1d", "3d", "1w" - :param event_handler: Async callable receiving a BarEvent. - """ - pair = self._coin_to_pair(coin) - - async def _on_candle(data: dict) -> None: - # Hyperliquid WS candle payload: {"t": ms, "T": ms, "o": str, "h": str, "l": str, "c": str, "v": str} - # The WS only delivers closed candles when a new one opens, so no "isClosed" check needed. - try: - when = datetime.datetime.fromtimestamp(int(data["T"]) / 1e3, tz=datetime.timezone.utc) - b = bar.Bar( - when, - pair, - Decimal(str(data["o"])), - Decimal(str(data["h"])), - Decimal(str(data["l"])), - Decimal(str(data["c"])), - Decimal(str(data["v"])), - ) - await event_handler(bar.BarEvent(when, b)) - except (KeyError, ValueError) as e: - logger.warning("Malformed candle data for %s: %s - %s", coin, e, data) + def subscribe_to_bar_events(self, coin: str, interval: str, event_handler: BarEventHandler) -> None: + """Subscribe to OHLCV bar events for ``coin``. - self._ws_mgr.subscribe_to_candle_events(coin, interval, _on_candle) + :param coin: e.g. ``"ETH"``, ``"BTC"`` + :param interval: One of ``"1m"``, ``"5m"``, ``"15m"``, ``"30m"``, ``"1h"``, ``"4h"``, ``"1d"`` + :param event_handler: Async callable receiving a :class:`~basana.core.bar.BarEvent`. + """ + pair = helpers.coin_to_pair(coin) + channel = websockets._candle_channel(coin, interval) + event_source = BarEventSource(pair=pair, producer=self._ws) + self._ws.set_channel_event_source(channel, event_source) + self._dispatcher.subscribe(event_source, event_handler) - def subscribe_to_trade_events( - self, - coin: str, - event_handler: Callable, - ) -> None: - """Subscribe to real-time trade events. + def subscribe_to_trade_events(self, coin: str, event_handler: Callable) -> None: + """Subscribe to real-time trade events for ``coin``. - :param coin: e.g. "ETH" - :param event_handler: Async callable receiving a list of trade dicts. + :param coin: e.g. ``"ETH"`` + :param event_handler: Async callable receiving raw trade message dicts. """ - self._ws_mgr.subscribe_to_trade_events(coin, event_handler) + channel = websockets._trades_channel(coin) + event_source = websockets.CandleEventSource(producer=self._ws) + self._ws.set_channel_event_source(channel, event_source) + self._dispatcher.subscribe(event_source, event_handler) - def subscribe_to_order_book_events( - self, - coin: str, - event_handler: Callable, - ) -> None: - """Subscribe to L2 order book updates. + def subscribe_to_order_book_events(self, coin: str, event_handler: Callable) -> None: + """Subscribe to L2 order book updates for ``coin``. - :param coin: e.g. "ETH" - :param event_handler: Async callable receiving the order book dict. + :param coin: e.g. ``"ETH"`` + :param event_handler: Async callable receiving raw order book message dicts. """ - self._ws_mgr.subscribe_to_order_book_events(coin, event_handler) + channel = websockets._l2_book_channel(coin) + event_source = websockets.CandleEventSource(producer=self._ws) + self._ws.set_channel_event_source(channel, event_source) + self._dispatcher.subscribe(event_source, event_handler) # ------------------------------------------------------------------ - # Market data queries (public, synchronous wrappers) + # Market data queries # ------------------------------------------------------------------ - def get_mid_price(self, coin: str) -> Decimal: - """Return the current mid price for a coin.""" - mids = self._cli.get_all_mids() + async def get_mid_price(self, coin: str) -> Decimal: + """Return the current mid price for ``coin``.""" + mids = await self._cli.get_all_mids() if coin not in mids: raise Error(f"Unknown coin: {coin}") return Decimal(mids[coin]) - def get_bid_ask(self, coin: str) -> Tuple[Decimal, Decimal]: - """Return the best bid and ask prices for a coin.""" - book = self._cli.get_l2_snapshot(coin) + async def get_bid_ask(self, coin: str) -> Tuple[Decimal, Decimal]: + """Return the best bid and ask prices for ``coin``.""" + book = await self._cli.get_l2_snapshot(coin) levels = book.get("levels", [[], []]) bid = Decimal(levels[0][0]["px"]) if levels[0] else Decimal(0) ask = Decimal(levels[1][0]["px"]) if levels[1] else Decimal(0) return bid, ask - def get_pair_info(self, coin: str) -> AssetInfo: + async def get_pair_info(self, coin: str) -> AssetInfo: """Return metadata for a tradeable asset. - :param coin: e.g. "ETH", "BTC" + :param coin: e.g. ``"ETH"``, ``"BTC"`` """ if coin not in self._asset_info: - meta = self._cli.get_meta() + meta = await self._cli.get_meta() for asset in meta.get("universe", []): name = asset["name"] self._asset_info[name] = AssetInfo( base_precision=asset.get("szDecimals", 8), - quote_precision=6, # Hyperliquid uses 6dp for USD + quote_precision=6, sz_decimals=asset.get("szDecimals", 8), max_leverage=asset.get("maxLeverage", 50), ) return self._asset_info[coin] - def list_coins(self) -> List[str]: + async def list_coins(self) -> List[str]: """Return a list of all tradeable perpetuals coins.""" - meta = self._cli.get_meta() + meta = await self._cli.get_meta() return [a["name"] for a in meta.get("universe", [])] # ------------------------------------------------------------------ @@ -187,31 +184,13 @@ def list_coins(self) -> List[str]: @property def perps_account(self) -> perps.Account: - """Access perpetuals trading and account management. - - Example:: - - order = exchange.perps_account.market_open("ETH", OrderOperation.BUY, Decimal("0.1")) - positions = exchange.perps_account.get_positions() - """ + """Access perpetuals trading and position management.""" return self._perps # ------------------------------------------------------------------ # Lifecycle # ------------------------------------------------------------------ - def start(self) -> None: - """Start WebSocket connections. Called automatically by the event dispatcher.""" - self._ws_mgr.start() - - async def stop(self) -> None: - """Stop WebSocket connections and clean up.""" - await self._ws_mgr.stop() - - # ------------------------------------------------------------------ - # Helpers - # ------------------------------------------------------------------ - - @staticmethod - def _coin_to_pair(coin: str) -> Pair: - return Pair(coin, "USD") + async def main(self): + """Run the WebSocket client. Called automatically by the event dispatcher.""" + await self._ws.main() diff --git a/basana/external/hyperliquid/helpers.py b/basana/external/hyperliquid/helpers.py new file mode 100644 index 0000000..1737a1a --- /dev/null +++ b/basana/external/hyperliquid/helpers.py @@ -0,0 +1,34 @@ +# Basana +# +# Copyright 2022 Gabriel Martin Becedillas Ruiz +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime + +from basana.core.pair import Pair + + +def timestamp_to_datetime(timestamp_ms: int) -> datetime.datetime: + """Convert a Hyperliquid millisecond timestamp to a timezone-aware datetime.""" + return datetime.datetime.fromtimestamp(timestamp_ms / 1e3, tz=datetime.timezone.utc) + + +def pair_to_coin(pair: Pair) -> str: + """Return the Hyperliquid coin name for a trading pair.""" + return pair.base_symbol.upper() + + +def coin_to_pair(coin: str) -> Pair: + """Return a Basana Pair for a Hyperliquid coin name.""" + return Pair(coin.upper(), "USD") diff --git a/basana/external/hyperliquid/perps.py b/basana/external/hyperliquid/perps.py index c764f08..0fd7f82 100644 --- a/basana/external/hyperliquid/perps.py +++ b/basana/external/hyperliquid/perps.py @@ -1,47 +1,58 @@ -# Basana - Hyperliquid connector +# Basana # -# Licensed under the Apache License, Version 2.0 +# Copyright 2022 Gabriel Martin Becedillas Ruiz # -# Perpetuals account: place/cancel orders, track positions, subscribe to fills. +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. from decimal import Decimal -from typing import Callable, List, Optional -import asyncio +from typing import Any, Awaitable, Callable, List, Optional import dataclasses import logging -from basana.core import enums +from basana.core.enums import OrderOperation from . import client, websockets + logger = logging.getLogger(__name__) -OrderOperation = enums.OrderOperation +FillEventHandler = Callable[[dict], Awaitable[Any]] -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class Position: """An open perpetuals position.""" - #: The coin (e.g. "ETH"). + #: The coin (e.g. ``"ETH"``). coin: str - #: Size (positive = long, negative = short). + #: Size — positive for long, negative for short. size: Decimal - #: Average entry price. + #: Average entry price in USD. entry_price: Decimal #: Unrealized P&L in USD. unrealized_pnl: Decimal - #: Liquidation price. + #: Liquidation price in USD, or ``None`` if not applicable. liquidation_price: Optional[Decimal] - #: Leverage. + #: Leverage multiplier. leverage: Decimal #: Margin used in USD. margin_used: Decimal -@dataclasses.dataclass +@dataclasses.dataclass(frozen=True) class OrderInfo: """A placed or open order.""" + #: Order ID returned by the exchange. oid: int coin: str is_buy: bool @@ -51,50 +62,34 @@ class OrderInfo: status: str -@dataclasses.dataclass -class FillEvent: - """A trade fill event received via WebSocket.""" - - coin: str - size: Decimal - price: Decimal - side: str # "B" (buy) or "A" (ask/sell) - fee: Decimal - timestamp_ms: int - - -FillEventHandler = Callable[[FillEvent], None] - - class Account: """Hyperliquid perpetuals account. - Provides order placement, position queries, and real-time fill events. + Provides order placement, position queries, and real-time fill subscriptions. :param api_client: The REST API client. - :param ws_manager: The WebSocket manager. + :param ws_client: The WebSocket client. """ - def __init__(self, api_client: client.APIClient, ws_manager: websockets.WebsocketManager): + def __init__(self, api_client: client.APIClient, ws_client: websockets.WebSocketClient): self._cli = api_client - self._ws = ws_manager + self._ws = ws_client # ------------------------------------------------------------------ # Account state # ------------------------------------------------------------------ - def get_positions(self) -> List[Position]: + async def get_positions(self) -> List[Position]: """Return all open perpetuals positions.""" - state = self._cli.get_user_state() + state = await self._cli.get_user_state() positions = [] for p in state.get("assetPositions", []): pos = p.get("position", {}) if pos.get("szi") == "0": continue - size = Decimal(pos["szi"]) positions.append(Position( coin=pos["coin"], - size=size, + size=Decimal(pos["szi"]), entry_price=Decimal(pos["entryPx"]) if pos.get("entryPx") else Decimal(0), unrealized_pnl=Decimal(pos.get("unrealizedPnl", "0")), liquidation_price=Decimal(pos["liquidationPx"]) if pos.get("liquidationPx") else None, @@ -103,14 +98,14 @@ def get_positions(self) -> List[Position]: )) return positions - def get_balance(self) -> Decimal: + async def get_balance(self) -> Decimal: """Return the account equity (total margin balance) in USD.""" - state = self._cli.get_user_state() + state = await self._cli.get_user_state() return Decimal(state.get("marginSummary", {}).get("accountValue", "0")) - def get_open_orders(self) -> List[OrderInfo]: + async def get_open_orders(self) -> List[OrderInfo]: """Return all open orders.""" - raw = self._cli.get_open_orders() + raw = await self._cli.get_open_orders() return [ OrderInfo( oid=o["oid"], @@ -128,29 +123,29 @@ def get_open_orders(self) -> List[OrderInfo]: # Order management # ------------------------------------------------------------------ - def market_open(self, coin: str, operation: OrderOperation, size: Decimal, slippage: float = 0.01) -> OrderInfo: + async def market_open(self, coin: str, operation: OrderOperation, size: Decimal, slippage: float = 0.01) -> OrderInfo: """Open a position at market price. - :param coin: e.g. "ETH" - :param operation: OrderOperation.BUY for long, OrderOperation.SELL for short. + :param coin: e.g. ``"ETH"`` + :param operation: ``OrderOperation.BUY`` for long, ``OrderOperation.SELL`` for short. :param size: Position size in base asset units. - :param slippage: Maximum acceptable slippage (default 1%). + :param slippage: Maximum acceptable slippage (default 1 %). """ is_buy = operation == OrderOperation.BUY - result = self._cli.market_open(coin, is_buy, float(size), slippage) + result = await self._cli.market_open(coin, is_buy, float(size), slippage) return self._parse_order_result(result, coin) - def market_close(self, coin: str, size: Optional[Decimal] = None, slippage: float = 0.01) -> OrderInfo: + async def market_close(self, coin: str, size: Optional[Decimal] = None, slippage: float = 0.01) -> OrderInfo: """Close a position at market price. - :param coin: e.g. "ETH" - :param size: Amount to close. If None, closes the entire position. - :param slippage: Maximum acceptable slippage (default 1%). + :param coin: e.g. ``"ETH"`` + :param size: Amount to close. ``None`` closes the entire position. + :param slippage: Maximum acceptable slippage (default 1 %). """ - result = self._cli.market_close(coin, float(size) if size else None, slippage) + result = await self._cli.market_close(coin, float(size) if size else None, slippage) return self._parse_order_result(result, coin) - def limit_order( + async def limit_order( self, coin: str, operation: OrderOperation, @@ -160,32 +155,28 @@ def limit_order( ) -> OrderInfo: """Place a limit order. - :param coin: e.g. "ETH" - :param operation: OrderOperation.BUY or OrderOperation.SELL. + :param coin: e.g. ``"ETH"`` + :param operation: ``OrderOperation.BUY`` or ``OrderOperation.SELL``. :param size: Size in base asset units. :param limit_price: Limit price in USD. - :param reduce_only: If True, the order only reduces an existing position. + :param reduce_only: If ``True``, the order can only reduce an existing position. """ is_buy = operation == OrderOperation.BUY - result = self._cli.limit_order(coin, is_buy, float(size), float(limit_price), reduce_only) + result = await self._cli.limit_order(coin, is_buy, float(size), float(limit_price), reduce_only) return self._parse_order_result(result, coin) - def cancel_order(self, coin: str, oid: int) -> None: - """Cancel an open order by ID.""" - self._cli.cancel_order(coin, oid) - - def cancel_all_orders(self, coin: Optional[str] = None) -> None: - """Cancel all open orders, optionally filtered by coin.""" - self._cli.cancel_all_orders(coin) + async def cancel_order(self, coin: str, oid: int) -> None: + """Cancel order ``oid`` for ``coin``.""" + await self._cli.cancel_order(coin, oid) - def set_leverage(self, coin: str, leverage: int, is_cross: bool = True) -> None: - """Set leverage for a coin. + async def set_leverage(self, coin: str, leverage: int, is_cross: bool = True) -> None: + """Set leverage for ``coin``. - :param coin: e.g. "ETH" + :param coin: e.g. ``"ETH"`` :param leverage: Leverage multiplier. - :param is_cross: True for cross margin, False for isolated. + :param is_cross: ``True`` for cross margin, ``False`` for isolated. """ - self._cli.set_leverage(coin, leverage, is_cross) + await self._cli.set_leverage(coin, leverage, is_cross) # ------------------------------------------------------------------ # Real-time events @@ -194,30 +185,17 @@ def set_leverage(self, coin: str, leverage: int, is_cross: bool = True) -> None: def subscribe_to_fill_events(self, handler: FillEventHandler) -> None: """Subscribe to real-time fill events via WebSocket. - :param handler: Async or sync callable receiving a FillEvent. + :param handler: Async callable receiving a fill event dict. """ if not self._cli.address: raise client.Error("Private key required to subscribe to fill events") - async def _on_fill(data: dict) -> None: - for fill in data.get("fills", [data]): - event = FillEvent( - coin=fill.get("coin", ""), - size=Decimal(str(fill.get("sz", "0"))), - price=Decimal(str(fill.get("px", "0"))), - side=fill.get("side", ""), - fee=Decimal(str(fill.get("fee", "0"))), - timestamp_ms=fill.get("time", 0), - ) - if asyncio.iscoroutinefunction(handler): - await handler(event) - else: - handler(event) - - self._ws.subscribe_to_user_fills(self._cli.address, _on_fill) + channel = websockets._user_fills_channel(self._cli.address) + event_source = websockets.CandleEventSource(producer=self._ws) + self._ws.set_channel_event_source(channel, event_source) # ------------------------------------------------------------------ - # Internal + # Internal helpers # ------------------------------------------------------------------ @staticmethod @@ -228,12 +206,9 @@ def _parse_order_result(result: dict, coin: str) -> OrderInfo: return OrderInfo( oid=filled.get("oid", 0), coin=coin, - is_buy=True, # Determined by caller + is_buy=True, size=Decimal(str(filled.get("totalSz", "0"))), limit_price=None, filled=Decimal(str(filled.get("totalSz", "0"))), status="filled" if filled else s.get("error", "unknown"), ) - - - diff --git a/basana/external/hyperliquid/websockets.py b/basana/external/hyperliquid/websockets.py index 8af337e..54000bd 100644 --- a/basana/external/hyperliquid/websockets.py +++ b/basana/external/hyperliquid/websockets.py @@ -1,183 +1,135 @@ -# Basana - Hyperliquid connector +# Basana # -# Licensed under the Apache License, Version 2.0 +# Copyright 2022 Gabriel Martin Becedillas Ruiz # -# WebSocket manager for real-time Hyperliquid feeds. -# Hyperliquid WS endpoint: wss://api.hyperliquid.xyz/ws +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at # -# Supported subscription types: -# - candle (OHLCV bars per coin/interval) -# - trades (individual trades per coin) -# - l2Book (order book updates per coin) -# - orderUpdates (order status updates for a user address) -# - userFills (fill events for a user address) - -from typing import Any, Callable, Dict, Optional -import asyncio +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import Any, Awaitable, Callable, List, Optional import json import logging import aiohttp -from basana.core import dispatcher +from basana.core import event, websockets as core_ws +from basana.core.config import get_config_value +from basana.external.hyperliquid import config + logger = logging.getLogger(__name__) -WS_URL = "wss://api.hyperliquid.xyz/ws" -TESTNET_WS_URL = "wss://api.hyperliquid-testnet.xyz/ws" +ChannelMessageHandler = Callable[[dict], Awaitable[Any]] + + +def _candle_channel(coin: str, interval: str) -> str: + return f"candle:{coin}:{interval}" + + +def _trades_channel(coin: str) -> str: + return f"trades:{coin}" + + +def _l2_book_channel(coin: str) -> str: + return f"l2Book:{coin}" + + +def _order_updates_channel(address: str) -> str: + return f"orderUpdates:{address}" -MessageHandler = Callable[[dict], Any] +def _user_fills_channel(address: str) -> str: + return f"userFills:{address}" -class WebsocketManager: - """Manages a single persistent WebSocket connection to Hyperliquid. - Subscriptions are registered before the connection is established. - The manager auto-reconnects on disconnect. +class CandleEventSource(core_ws.ChannelEventSource): + """Delivers closed candle messages for one coin/interval pair.""" + + def __init__(self, producer: event.Producer): + super().__init__(producer=producer) + + async def push_from_message(self, message: dict): + self.push(message) + + +class WebSocketClient(core_ws.WebSocketClient): + """Hyperliquid WebSocket client. + + Extends Basana's ``core_ws.WebSocketClient`` to provide channel-based + subscriptions for candles, trades, L2 order book, order updates, and fills. """ def __init__( self, - event_dispatcher: dispatcher.EventDispatcher, - testnet: bool = False, session: Optional[aiohttp.ClientSession] = None, + config_overrides: dict = {}, ): - self._dispatcher = event_dispatcher - self._ws_url = TESTNET_WS_URL if testnet else WS_URL - self._session = session - self._own_session = session is None - self._subscriptions: list[dict] = [] - self._handlers: Dict[str, list[MessageHandler]] = {} - self._ws: Optional[aiohttp.ClientWebSocketResponse] = None - self._running = False - self._task: Optional[asyncio.Task] = None - - def subscribe_to_candle_events(self, coin: str, interval: str, handler: MessageHandler) -> None: - """Subscribe to OHLCV candle events. - - :param coin: e.g. "ETH" - :param interval: e.g. "1m", "5m", "1h", "4h", "1d" - :param handler: Async callable receiving the raw candle dict. - """ - sub = {"type": "candle", "coin": coin, "interval": interval} - key = f"candle:{coin}:{interval}" - self._add_subscription(sub, key, handler) - - def subscribe_to_trade_events(self, coin: str, handler: MessageHandler) -> None: - """Subscribe to real-time trade events. - - :param coin: e.g. "ETH" - :param handler: Async callable receiving a list of trade dicts. - """ - sub = {"type": "trades", "coin": coin} - key = f"trades:{coin}" - self._add_subscription(sub, key, handler) - - def subscribe_to_order_book_events(self, coin: str, handler: MessageHandler) -> None: - """Subscribe to L2 order book updates. - - :param coin: e.g. "ETH" - :param handler: Async callable receiving the order book dict. - """ - sub = {"type": "l2Book", "coin": coin} - key = f"l2Book:{coin}" - self._add_subscription(sub, key, handler) - - def subscribe_to_order_updates(self, address: str, handler: MessageHandler) -> None: - """Subscribe to order status updates for a wallet address. - - :param address: EVM wallet address (0x...). - :param handler: Async callable receiving order update dicts. - """ - sub = {"type": "orderUpdates", "user": address} - key = f"orderUpdates:{address}" - self._add_subscription(sub, key, handler) - - def subscribe_to_user_fills(self, address: str, handler: MessageHandler) -> None: - """Subscribe to fill events for a wallet address. - - :param address: EVM wallet address (0x...). - :param handler: Async callable receiving fill event dicts. - """ - sub = {"type": "userFills", "user": address} - key = f"userFills:{address}" - self._add_subscription(sub, key, handler) - - def start(self) -> None: - """Start the WebSocket connection loop (called by the event dispatcher).""" - if not self._running and self._subscriptions: - self._running = True - self._task = asyncio.ensure_future(self._run()) - - async def stop(self) -> None: - """Stop the WebSocket connection.""" - self._running = False - if self._ws and not self._ws.closed: - await self._ws.close() - if self._task: - try: - await self._task - except asyncio.CancelledError: - pass - if self._own_session and self._session: - await self._session.close() + super().__init__( + get_config_value(config.DEFAULTS, "api.websockets.base_url", overrides=config_overrides), + session=session, + config_overrides=config_overrides, + heartbeat=get_config_value(config.DEFAULTS, "api.websockets.heartbeat", overrides=config_overrides), + ) + + async def subscribe_to_channels(self, channels: List[str], ws_cli: aiohttp.ClientWebSocketResponse): + for channel in channels: + subscription = self._channel_to_subscription(channel) + if subscription: + await ws_cli.send_json({"method": "subscribe", "subscription": subscription}) + logger.debug("Subscribed to %s", channel) + + async def handle_message(self, message: dict) -> bool: + channel_name = message.get("channel", "") + data = message.get("data", {}) + + if not channel_name or channel_name == "subscriptionResponse": + return True # Ack messages — handled, nothing to dispatch + + # Route to the matching ChannelEventSource + matched = False + for registered_channel, event_source in self._event_sources.items(): + if self._matches(registered_channel, channel_name, data): + await event_source.push_from_message(data) + matched = True + + return matched # ------------------------------------------------------------------ - # Internal + # Internal helpers # ------------------------------------------------------------------ - def _add_subscription(self, sub: dict, key: str, handler: MessageHandler) -> None: - # Only add the WS subscription once per key (multiple handlers allowed). - if key not in self._handlers: - self._subscriptions.append(sub) - self._handlers[key] = [] - self._handlers[key].append(handler) - - async def _run(self) -> None: - while self._running: - try: - if self._own_session or self._session is None: - self._session = aiohttp.ClientSession() - async with self._session.ws_connect(self._ws_url, heartbeat=30) as ws: - self._ws = ws - # Send all subscriptions on connect/reconnect. - for sub in self._subscriptions: - await ws.send_json({"method": "subscribe", "subscription": sub}) - async for msg in ws: - if msg.type == aiohttp.WSMsgType.TEXT: - await self._handle_message(json.loads(msg.data)) - elif msg.type in (aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.ERROR): - break - except Exception as e: - logger.warning("Hyperliquid WS error: %s — reconnecting in 5s", e) - await asyncio.sleep(5) - - async def _handle_message(self, message: dict) -> None: - channel = message.get("channel", "") - data = message.get("data", {}) - - if not channel or channel == "subscriptionResponse": - return # Ignore subscription ack messages - - # Match handlers: key format is "type:coin[:interval]" or "type:address" - # Hyperliquid sends channel names like "candle", "trades", "l2Book", "orderUpdates", "userFills" - for key, handlers in self._handlers.items(): - sub_type = key.split(":")[0] - if channel != sub_type: - continue - - # For coin-scoped channels, also match on coin name from data payload - if sub_type in ("candle", "trades", "l2Book"): - payload_coin = data.get("coin") or data.get("s", "") - key_coin = key.split(":")[1] if ":" in key else "" - if key_coin and payload_coin and payload_coin != key_coin: - continue - - for handler in handlers: - try: - if asyncio.iscoroutinefunction(handler): - await handler(data) - else: - handler(data) - except Exception as e: - logger.error("Handler error for %s: %s", key, e) + @staticmethod + def _channel_to_subscription(channel: str) -> Optional[dict]: + """Convert a registered channel key back to the HL subscription payload.""" + parts = channel.split(":") + sub_type = parts[0] + if sub_type == "candle" and len(parts) == 3: + return {"type": "candle", "coin": parts[1], "interval": parts[2]} + elif sub_type in ("trades", "l2Book") and len(parts) == 2: + return {"type": sub_type, "coin": parts[1]} + elif sub_type in ("orderUpdates", "userFills") and len(parts) == 2: + return {"type": sub_type, "user": parts[1]} + return None + + @staticmethod + def _matches(registered_channel: str, ws_channel: str, data: dict) -> bool: + """Check if a WebSocket message belongs to a registered channel.""" + parts = registered_channel.split(":") + if parts[0] != ws_channel: + return False + + # For coin-scoped subscriptions, also verify the coin in the payload. + if ws_channel in ("candle", "trades", "l2Book") and len(parts) >= 2: + payload_coin = data.get("coin") or data.get("s", "") + return payload_coin == parts[1] + + # For user-scoped subscriptions, no extra check needed. + return True diff --git a/tests/test_hyperliquid_client.py b/tests/test_hyperliquid_client.py index 6b7204b..92f9d55 100644 --- a/tests/test_hyperliquid_client.py +++ b/tests/test_hyperliquid_client.py @@ -1,9 +1,21 @@ -# Basana - Hyperliquid connector tests +# Basana # -# Licensed under the Apache License, Version 2.0 - -from decimal import Decimal -from unittest.mock import MagicMock, patch +# Copyright 2022 Gabriel Martin Becedillas Ruiz +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import asyncio +from unittest.mock import MagicMock, patch, AsyncMock import pytest from basana.external.hyperliquid.client.rest import APIClient, Error @@ -15,21 +27,18 @@ @pytest.fixture() def mock_info(): - """Patch hyperliquid.info.Info with a MagicMock.""" with patch("basana.external.hyperliquid.client.rest.Info") as MockInfo: yield MockInfo.return_value @pytest.fixture() def mock_exchange_sdk(): - """Patch hyperliquid.exchange.Exchange with a MagicMock.""" with patch("basana.external.hyperliquid.client.rest.HLExchange") as MockEx: yield MockEx.return_value @pytest.fixture() def mock_account(): - """Patch eth_account so no real key is required.""" with patch("basana.external.hyperliquid.client.rest.eth_account") as mock_eth: mock_wallet = MagicMock() mock_wallet.address = "0xDEADBEEF" @@ -45,7 +54,7 @@ class TestPublicEndpoints: def test_get_all_mids(self, mock_info): mock_info.all_mids.return_value = {"ETH": "2100.0", "BTC": "70000.0"} cli = APIClient() - mids = cli.get_all_mids() + mids = asyncio.run(cli.get_all_mids()) assert mids["ETH"] == "2100.0" assert mids["BTC"] == "70000.0" mock_info.all_mids.assert_called_once() @@ -59,7 +68,7 @@ def test_get_l2_snapshot(self, mock_info): ], } cli = APIClient() - book = cli.get_l2_snapshot("ETH") + book = asyncio.run(cli.get_l2_snapshot("ETH")) assert book["levels"][0][0]["px"] == "2099.9" mock_info.l2_snapshot.assert_called_once_with("ETH") @@ -71,16 +80,16 @@ def test_get_meta(self, mock_info): ] } cli = APIClient() - meta = cli.get_meta() + meta = asyncio.run(cli.get_meta()) coins = [a["name"] for a in meta["universe"]] assert "BTC" in coins and "ETH" in coins def test_get_candles(self, mock_info): mock_info.candles_snapshot.return_value = [ - {"t": 1700000000000, "o": "2000", "h": "2100", "l": "1990", "c": "2050", "v": "1000"}, + {"t": 1700000000000, "T": 1700003600000, "o": "2000", "h": "2100", "l": "1990", "c": "2050", "v": "1000"}, ] cli = APIClient() - candles = cli.get_candles("ETH", "1h", 1700000000000, 1700003600000) + candles = asyncio.run(cli.get_candles("ETH", "1h", 1700000000000, 1700003600000)) assert len(candles) == 1 assert candles[0]["c"] == "2050" @@ -91,23 +100,23 @@ def test_get_candles(self, mock_info): class TestAuthGuard: def test_get_user_state_without_key_raises(self, mock_info): - cli = APIClient() # no private key + cli = APIClient() with pytest.raises(Error, match="Private key required"): - cli.get_user_state() + asyncio.run(cli.get_user_state()) def test_get_open_orders_without_key_raises(self, mock_info): cli = APIClient() with pytest.raises(Error, match="Private key required"): - cli.get_open_orders() + asyncio.run(cli.get_open_orders()) def test_market_open_without_key_raises(self, mock_info): cli = APIClient() with pytest.raises(Error, match="Private key required"): - cli.market_open("ETH", True, 0.1) + asyncio.run(cli.market_open("ETH", True, 0.1)) # --------------------------------------------------------------------------- -# Authenticated endpoints (mocked key) +# Authenticated endpoints # --------------------------------------------------------------------------- class TestAuthenticatedEndpoints: @@ -117,33 +126,33 @@ def test_get_user_state(self, mock_info, mock_account, mock_exchange_sdk): "assetPositions": [], } cli = APIClient(private_key="0xdeadbeef") - state = cli.get_user_state() + state = asyncio.run(cli.get_user_state()) assert state["marginSummary"]["accountValue"] == "5000.0" mock_info.user_state.assert_called_once_with("0xDEADBEEF") def test_market_open_success(self, mock_info, mock_account, mock_exchange_sdk): mock_exchange_sdk.market_open.return_value = {"status": "ok", "response": {"data": {"statuses": [{}]}}} cli = APIClient(private_key="0xdeadbeef") - result = cli.market_open("ETH", True, 0.1) + result = asyncio.run(cli.market_open("ETH", True, 0.1)) assert result["status"] == "ok" def test_market_open_api_error_raises(self, mock_info, mock_account, mock_exchange_sdk): mock_exchange_sdk.market_open.return_value = {"status": "err", "response": "Insufficient margin"} cli = APIClient(private_key="0xdeadbeef") with pytest.raises(Error, match="API error"): - cli.market_open("ETH", True, 0.1) + asyncio.run(cli.market_open("ETH", True, 0.1)) def test_cancel_order(self, mock_info, mock_account, mock_exchange_sdk): mock_exchange_sdk.cancel.return_value = {"status": "ok"} cli = APIClient(private_key="0xdeadbeef") - result = cli.cancel_order("ETH", 12345) + result = asyncio.run(cli.cancel_order("ETH", 12345)) assert result["status"] == "ok" mock_exchange_sdk.cancel.assert_called_once_with("ETH", 12345) def test_set_leverage(self, mock_info, mock_account, mock_exchange_sdk): mock_exchange_sdk.update_leverage.return_value = {"status": "ok"} cli = APIClient(private_key="0xdeadbeef") - result = cli.set_leverage("ETH", 10, is_cross=True) + result = asyncio.run(cli.set_leverage("ETH", 10, is_cross=True)) assert result["status"] == "ok" mock_exchange_sdk.update_leverage.assert_called_once_with(10, "ETH", True) diff --git a/tests/test_hyperliquid_exchange.py b/tests/test_hyperliquid_exchange.py index 3706eea..208b15b 100644 --- a/tests/test_hyperliquid_exchange.py +++ b/tests/test_hyperliquid_exchange.py @@ -1,12 +1,25 @@ -# Basana - Hyperliquid connector tests +# Basana # -# Licensed under the Apache License, Version 2.0 +# Copyright 2022 Gabriel Martin Becedillas Ruiz +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import asyncio from decimal import Decimal -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest -import basana as b +import basana as bs from basana.external.hyperliquid.exchange import Exchange, Error, AssetInfo @@ -18,33 +31,37 @@ def mock_api_client(): with patch("basana.external.hyperliquid.exchange.client.APIClient") as MockClient: instance = MockClient.return_value - instance.get_all_mids.return_value = {"ETH": "2100.0", "BTC": "70000.0", "SOL": "150.0"} - instance.get_l2_snapshot.return_value = { + instance.get_all_mids = AsyncMock(return_value={ + "ETH": "2100.0", "BTC": "70000.0", "SOL": "150.0" + }) + instance.get_l2_snapshot = AsyncMock(return_value={ "coin": "ETH", "levels": [ [{"px": "2099.5", "sz": "2.0", "n": 1}], [{"px": "2100.5", "sz": "1.5", "n": 1}], ], - } - instance.get_meta.return_value = { + }) + instance.get_meta = AsyncMock(return_value={ "universe": [ {"name": "BTC", "szDecimals": 5, "maxLeverage": 50}, {"name": "ETH", "szDecimals": 4, "maxLeverage": 25}, {"name": "SOL", "szDecimals": 2, "maxLeverage": 20}, ] - } + }) yield instance @pytest.fixture() -def mock_ws_manager(): - with patch("basana.external.hyperliquid.exchange.websockets.WebsocketManager") as MockWS: - yield MockWS.return_value +def mock_ws_client(): + with patch("basana.external.hyperliquid.exchange.websockets.WebSocketClient") as MockWS: + instance = MockWS.return_value + instance.set_channel_event_source = MagicMock() + yield instance @pytest.fixture() -def exchange(mock_api_client, mock_ws_manager): - d = b.RealtimeDispatcher(max_concurrent=10) +def exchange(mock_api_client, mock_ws_client): + d = bs.realtime_dispatcher() return Exchange(dispatcher=d) @@ -54,42 +71,39 @@ def exchange(mock_api_client, mock_ws_manager): class TestMarketData: def test_get_mid_price(self, exchange): - price = exchange.get_mid_price("ETH") + price = asyncio.run(exchange.get_mid_price("ETH")) assert price == Decimal("2100.0") assert isinstance(price, Decimal) def test_get_mid_price_unknown_coin_raises(self, exchange): with pytest.raises(Error, match="Unknown coin"): - exchange.get_mid_price("NOTACOIN") + asyncio.run(exchange.get_mid_price("NOTACOIN")) def test_get_bid_ask(self, exchange): - bid, ask = exchange.get_bid_ask("ETH") + bid, ask = asyncio.run(exchange.get_bid_ask("ETH")) assert bid == Decimal("2099.5") assert ask == Decimal("2100.5") assert ask > bid def test_get_pair_info(self, exchange): - info = exchange.get_pair_info("ETH") + info = asyncio.run(exchange.get_pair_info("ETH")) assert isinstance(info, AssetInfo) assert info.sz_decimals == 4 assert info.max_leverage == 25 def test_get_pair_info_btc(self, exchange): - info = exchange.get_pair_info("BTC") + info = asyncio.run(exchange.get_pair_info("BTC")) assert info.sz_decimals == 5 assert info.max_leverage == 50 def test_get_pair_info_cached(self, exchange, mock_api_client): - exchange.get_pair_info("ETH") - exchange.get_pair_info("ETH") - # Meta should only be fetched once (cached) + asyncio.run(exchange.get_pair_info("ETH")) + asyncio.run(exchange.get_pair_info("ETH")) mock_api_client.get_meta.assert_called_once() def test_list_coins(self, exchange): - coins = exchange.list_coins() - assert "BTC" in coins - assert "ETH" in coins - assert "SOL" in coins + coins = asyncio.run(exchange.list_coins()) + assert "BTC" in coins and "ETH" in coins and "SOL" in coins assert len(coins) == 3 @@ -98,27 +112,76 @@ def test_list_coins(self, exchange): # --------------------------------------------------------------------------- class TestSubscriptions: - def test_subscribe_to_bar_events(self, exchange, mock_ws_manager): - handler = MagicMock() + def test_subscribe_to_bar_events(self, exchange, mock_ws_client): + handler = AsyncMock() exchange.subscribe_to_bar_events("ETH", "1h", handler) - mock_ws_manager.subscribe_to_candle_events.assert_called_once() - args = mock_ws_manager.subscribe_to_candle_events.call_args[0] - # Coin and interval are passed through correctly. - assert args[0] == "ETH" - assert args[1] == "1h" - # The third arg is an internal async wrapper (_on_candle), not the raw handler. - import asyncio - assert asyncio.iscoroutinefunction(args[2]) - - def test_subscribe_to_trade_events(self, exchange, mock_ws_manager): - handler = MagicMock() + mock_ws_client.set_channel_event_source.assert_called_once() + args = mock_ws_client.set_channel_event_source.call_args[0] + assert args[0] == "candle:ETH:1h" + + def test_subscribe_to_trade_events(self, exchange, mock_ws_client): + handler = AsyncMock() exchange.subscribe_to_trade_events("ETH", handler) - mock_ws_manager.subscribe_to_trade_events.assert_called_once_with("ETH", handler) + mock_ws_client.set_channel_event_source.assert_called_once() + args = mock_ws_client.set_channel_event_source.call_args[0] + assert args[0] == "trades:ETH" - def test_subscribe_to_order_book_events(self, exchange, mock_ws_manager): - handler = MagicMock() + def test_subscribe_to_order_book_events(self, exchange, mock_ws_client): + handler = AsyncMock() exchange.subscribe_to_order_book_events("ETH", handler) - mock_ws_manager.subscribe_to_order_book_events.assert_called_once_with("ETH", handler) + mock_ws_client.set_channel_event_source.assert_called_once() + args = mock_ws_client.set_channel_event_source.call_args[0] + assert args[0] == "l2Book:ETH" + + +# --------------------------------------------------------------------------- +# Bar event construction +# --------------------------------------------------------------------------- + +class TestBarEventSource: + def test_candle_to_bar_event(self): + from basana.external.hyperliquid.exchange import BarEventSource + from basana.core.pair import Pair + + pair = Pair("ETH", "USD") + producer = MagicMock() + producer.initialize = MagicMock() + source = BarEventSource(pair=pair, producer=producer) + + events = [] + async def run(): + await source.push_from_message({ + "t": 1709500000000, + "T": 1709503600000, + "o": "2100.0", + "h": "2150.0", + "l": "2090.0", + "c": "2130.0", + "v": "500.5", + "coin": "ETH", + }) + while True: + event = source.pop() + if event is None: + break + events.append(event) + + asyncio.run(run()) + assert len(events) == 1 + bar_event = events[0] + assert bar_event.bar.close == Decimal("2130.0") + assert bar_event.bar.pair == pair + assert bar_event.when.tzinfo is not None + + def test_malformed_candle_does_not_raise(self): + from basana.external.hyperliquid.exchange import BarEventSource + from basana.core.pair import Pair + + pair = Pair("ETH", "USD") + producer = MagicMock() + source = BarEventSource(pair=pair, producer=producer) + # Should log a warning but not raise + asyncio.run(source.push_from_message({"invalid": "data"})) # --------------------------------------------------------------------------- @@ -126,10 +189,6 @@ def test_subscribe_to_order_book_events(self, exchange, mock_ws_manager): # --------------------------------------------------------------------------- class TestLifecycle: - def test_start_delegates_to_ws_manager(self, exchange, mock_ws_manager): - exchange.start() - mock_ws_manager.start.assert_called_once() - def test_perps_account_accessible(self, exchange): from basana.external.hyperliquid.perps import Account assert isinstance(exchange.perps_account, Account) From 1426eb7e9a7199299a190230a7ba90e1ce0dd712 Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 4 Mar 2026 16:27:59 +0000 Subject: [PATCH 08/17] refactor: DRY - extract _register_channel, rename CandleEventSource -> RawEventSource MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Exchange._register_channel() eliminates repeated set_channel_event_source + dispatcher.subscribe pattern across all three subscribe_to_* methods - CandleEventSource renamed to RawEventSource — it's a generic pass-through used for trades and L2 book as well, not just candles --- basana/external/hyperliquid/exchange.py | 34 +++++++++++++++-------- basana/external/hyperliquid/perps.py | 2 +- basana/external/hyperliquid/websockets.py | 7 +++-- 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/basana/external/hyperliquid/exchange.py b/basana/external/hyperliquid/exchange.py index bf7bf78..6b77c7f 100644 --- a/basana/external/hyperliquid/exchange.py +++ b/basana/external/hyperliquid/exchange.py @@ -48,7 +48,7 @@ class AssetInfo(PairInfo): max_leverage: int -class BarEventSource(websockets.CandleEventSource): +class BarEventSource(websockets.RawEventSource): """Converts raw Hyperliquid candle messages into Basana :class:`~basana.core.bar.BarEvent` objects.""" def __init__(self, pair: Pair, producer: event.Producer): @@ -110,10 +110,11 @@ def subscribe_to_bar_events(self, coin: str, interval: str, event_handler: BarEv :param event_handler: Async callable receiving a :class:`~basana.core.bar.BarEvent`. """ pair = helpers.coin_to_pair(coin) - channel = websockets._candle_channel(coin, interval) - event_source = BarEventSource(pair=pair, producer=self._ws) - self._ws.set_channel_event_source(channel, event_source) - self._dispatcher.subscribe(event_source, event_handler) + self._register_channel( + websockets._candle_channel(coin, interval), + BarEventSource(pair=pair, producer=self._ws), + event_handler, + ) def subscribe_to_trade_events(self, coin: str, event_handler: Callable) -> None: """Subscribe to real-time trade events for ``coin``. @@ -121,10 +122,11 @@ def subscribe_to_trade_events(self, coin: str, event_handler: Callable) -> None: :param coin: e.g. ``"ETH"`` :param event_handler: Async callable receiving raw trade message dicts. """ - channel = websockets._trades_channel(coin) - event_source = websockets.CandleEventSource(producer=self._ws) - self._ws.set_channel_event_source(channel, event_source) - self._dispatcher.subscribe(event_source, event_handler) + self._register_channel( + websockets._trades_channel(coin), + websockets.RawEventSource(producer=self._ws), + event_handler, + ) def subscribe_to_order_book_events(self, coin: str, event_handler: Callable) -> None: """Subscribe to L2 order book updates for ``coin``. @@ -132,8 +134,18 @@ def subscribe_to_order_book_events(self, coin: str, event_handler: Callable) -> :param coin: e.g. ``"ETH"`` :param event_handler: Async callable receiving raw order book message dicts. """ - channel = websockets._l2_book_channel(coin) - event_source = websockets.CandleEventSource(producer=self._ws) + self._register_channel( + websockets._l2_book_channel(coin), + websockets.RawEventSource(producer=self._ws), + event_handler, + ) + + def _register_channel( + self, + channel: str, + event_source: websockets.RawEventSource, + event_handler: Callable, + ) -> None: self._ws.set_channel_event_source(channel, event_source) self._dispatcher.subscribe(event_source, event_handler) diff --git a/basana/external/hyperliquid/perps.py b/basana/external/hyperliquid/perps.py index 0fd7f82..65afc3c 100644 --- a/basana/external/hyperliquid/perps.py +++ b/basana/external/hyperliquid/perps.py @@ -191,7 +191,7 @@ def subscribe_to_fill_events(self, handler: FillEventHandler) -> None: raise client.Error("Private key required to subscribe to fill events") channel = websockets._user_fills_channel(self._cli.address) - event_source = websockets.CandleEventSource(producer=self._ws) + event_source = websockets.RawEventSource(producer=self._ws) self._ws.set_channel_event_source(channel, event_source) # ------------------------------------------------------------------ diff --git a/basana/external/hyperliquid/websockets.py b/basana/external/hyperliquid/websockets.py index 54000bd..8dda1bd 100644 --- a/basana/external/hyperliquid/websockets.py +++ b/basana/external/hyperliquid/websockets.py @@ -50,8 +50,11 @@ def _user_fills_channel(address: str) -> str: return f"userFills:{address}" -class CandleEventSource(core_ws.ChannelEventSource): - """Delivers closed candle messages for one coin/interval pair.""" +class RawEventSource(core_ws.ChannelEventSource): + """Passes raw WebSocket message dicts through as events. + + Used as the base for channel-specific event sources (candles, trades, L2 book). + """ def __init__(self, producer: event.Producer): super().__init__(producer=producer) From a6f681656bdb54405dd905f9b5e5f58356d28344 Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 4 Mar 2026 16:35:25 +0000 Subject: [PATCH 09/17] fix: update samples for async API (await market_open/close, get_mid_price) --- samples/hyperliquid/paper_trader.py | 7 ++----- samples/hyperliquid/rsi_strategy.py | 9 +++------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/samples/hyperliquid/paper_trader.py b/samples/hyperliquid/paper_trader.py index 2681564..e1b9a55 100644 --- a/samples/hyperliquid/paper_trader.py +++ b/samples/hyperliquid/paper_trader.py @@ -268,7 +268,7 @@ async def on_trading_signal(self, signal: bs.TradingSignal) -> None: for pair, target_position in pairs: coin = pair.base_symbol try: - price = self._exchange.get_mid_price(coin) + price = await self._exchange.get_mid_price(coin) except Exception: # Coin not on Hyperliquid perps - use LC price lc_data = json.loads(urllib.request.urlopen( @@ -330,21 +330,18 @@ async def main(coins: List[str], interval: str) -> None: hl.subscribe_to_bar_events(coin, interval, strategy.on_bar_event) hl.subscribe_to_bar_events(coin, interval, position_mgr.on_bar_event) - hl.start() logger.info("Paper trader live on %d coins (%s bars). Ctrl+C to stop.", len(coins), interval) try: await dispatcher.run() except KeyboardInterrupt: pass - finally: - await hl.stop() # Final summary logger.info("=== Final portfolio ===") logger.info("Balance: $%.2f | Realized P&L: $%.2f", portfolio.balance, portfolio.realized_pnl) for coin, pos in portfolio.positions.items(): - price = hl.get_mid_price(coin) + price = await hl.get_mid_price(coin) pnl_pct = portfolio.unrealized_pnl(coin, price) logger.info(" %s %s: unrealized %.1f%%", pos["direction"], coin, pnl_pct or 0) diff --git a/samples/hyperliquid/rsi_strategy.py b/samples/hyperliquid/rsi_strategy.py index a92a235..6d39469 100644 --- a/samples/hyperliquid/rsi_strategy.py +++ b/samples/hyperliquid/rsi_strategy.py @@ -57,7 +57,7 @@ async def on_bar(self, event: b.BarEvent) -> None: if not self.in_position and rsi < RSI_OVERSOLD: logger.info("RSI oversold (%.1f) - opening LONG %s", rsi, COIN) try: - order = self.exchange.perps_account.market_open(COIN, OrderOperation.BUY, POSITION_SIZE) + order = await self.exchange.perps_account.market_open(COIN, OrderOperation.BUY, POSITION_SIZE) logger.info("Opened: oid=%s filled=%s", order.oid, order.filled) self.in_position = True except Exception as e: @@ -66,7 +66,7 @@ async def on_bar(self, event: b.BarEvent) -> None: elif self.in_position and rsi > RSI_OVERBOUGHT: logger.info("RSI overbought (%.1f) - closing LONG %s", rsi, COIN) try: - order = self.exchange.perps_account.market_close(COIN) + order = await self.exchange.perps_account.market_close(COIN) logger.info("Closed: oid=%s filled=%s", order.oid, order.filled) self.in_position = False except Exception as e: @@ -78,20 +78,17 @@ async def main(): if not private_key: logger.info("No HYPERLIQUID_KEY set - running in read-only mode (no trading)") - d = b.EventDispatcher() + d = b.realtime_dispatcher() exchange = Exchange(dispatcher=d, private_key=private_key) strategy = RSIStrategy(exchange) exchange.subscribe_to_bar_events(COIN, INTERVAL, strategy.on_bar) - exchange.start() logger.info("Strategy running. Ctrl+C to stop.") try: await d.run() except KeyboardInterrupt: pass - finally: - await exchange.stop() if __name__ == "__main__": From af16a4f349a739d2c4b7b4ed8e298866e8c7f914 Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 4 Mar 2026 19:40:04 +0000 Subject: [PATCH 10/17] chore: update copyright headers to Christian Pojoni --- basana/external/hyperliquid/__init__.py | 2 +- basana/external/hyperliquid/client/__init__.py | 2 +- basana/external/hyperliquid/client/rest.py | 2 +- basana/external/hyperliquid/config.py | 2 +- basana/external/hyperliquid/exchange.py | 2 +- basana/external/hyperliquid/helpers.py | 2 +- basana/external/hyperliquid/perps.py | 2 +- basana/external/hyperliquid/websockets.py | 2 +- samples/hyperliquid/paper_trader.py | 16 ++++++++++++++++ samples/hyperliquid/rsi_strategy.py | 16 ++++++++++++++++ tests/test_hyperliquid_client.py | 2 +- tests/test_hyperliquid_exchange.py | 2 +- 12 files changed, 42 insertions(+), 10 deletions(-) diff --git a/basana/external/hyperliquid/__init__.py b/basana/external/hyperliquid/__init__.py index 64755d9..124fd7a 100644 --- a/basana/external/hyperliquid/__init__.py +++ b/basana/external/hyperliquid/__init__.py @@ -1,6 +1,6 @@ # Basana # -# Copyright 2022 Gabriel Martin Becedillas Ruiz +# Copyright 2026 Christian Pojoni # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/basana/external/hyperliquid/client/__init__.py b/basana/external/hyperliquid/client/__init__.py index ffb7b1c..d401ab0 100644 --- a/basana/external/hyperliquid/client/__init__.py +++ b/basana/external/hyperliquid/client/__init__.py @@ -1,6 +1,6 @@ # Basana # -# Copyright 2022 Gabriel Martin Becedillas Ruiz +# Copyright 2026 Christian Pojoni # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/basana/external/hyperliquid/client/rest.py b/basana/external/hyperliquid/client/rest.py index 6f5b84d..3c1d2df 100644 --- a/basana/external/hyperliquid/client/rest.py +++ b/basana/external/hyperliquid/client/rest.py @@ -1,6 +1,6 @@ # Basana # -# Copyright 2022 Gabriel Martin Becedillas Ruiz +# Copyright 2026 Christian Pojoni # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/basana/external/hyperliquid/config.py b/basana/external/hyperliquid/config.py index feb55c2..db5b02b 100644 --- a/basana/external/hyperliquid/config.py +++ b/basana/external/hyperliquid/config.py @@ -1,6 +1,6 @@ # Basana # -# Copyright 2022 Gabriel Martin Becedillas Ruiz +# Copyright 2026 Christian Pojoni # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/basana/external/hyperliquid/exchange.py b/basana/external/hyperliquid/exchange.py index 6b77c7f..4e18c95 100644 --- a/basana/external/hyperliquid/exchange.py +++ b/basana/external/hyperliquid/exchange.py @@ -1,6 +1,6 @@ # Basana # -# Copyright 2022 Gabriel Martin Becedillas Ruiz +# Copyright 2026 Christian Pojoni # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/basana/external/hyperliquid/helpers.py b/basana/external/hyperliquid/helpers.py index 1737a1a..8a80f0a 100644 --- a/basana/external/hyperliquid/helpers.py +++ b/basana/external/hyperliquid/helpers.py @@ -1,6 +1,6 @@ # Basana # -# Copyright 2022 Gabriel Martin Becedillas Ruiz +# Copyright 2026 Christian Pojoni # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/basana/external/hyperliquid/perps.py b/basana/external/hyperliquid/perps.py index 65afc3c..479d2c4 100644 --- a/basana/external/hyperliquid/perps.py +++ b/basana/external/hyperliquid/perps.py @@ -1,6 +1,6 @@ # Basana # -# Copyright 2022 Gabriel Martin Becedillas Ruiz +# Copyright 2026 Christian Pojoni # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/basana/external/hyperliquid/websockets.py b/basana/external/hyperliquid/websockets.py index 8dda1bd..4de6686 100644 --- a/basana/external/hyperliquid/websockets.py +++ b/basana/external/hyperliquid/websockets.py @@ -1,6 +1,6 @@ # Basana # -# Copyright 2022 Gabriel Martin Becedillas Ruiz +# Copyright 2026 Christian Pojoni # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/samples/hyperliquid/paper_trader.py b/samples/hyperliquid/paper_trader.py index e1b9a55..e0a374c 100644 --- a/samples/hyperliquid/paper_trader.py +++ b/samples/hyperliquid/paper_trader.py @@ -1,3 +1,19 @@ +# Basana +# +# Copyright 2026 Christian Pojoni +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """ Autonomous paper trader using Basana + Hyperliquid. diff --git a/samples/hyperliquid/rsi_strategy.py b/samples/hyperliquid/rsi_strategy.py index 6d39469..f788886 100644 --- a/samples/hyperliquid/rsi_strategy.py +++ b/samples/hyperliquid/rsi_strategy.py @@ -1,3 +1,19 @@ +# Basana +# +# Copyright 2026 Christian Pojoni +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """ Example: RSI strategy on Hyperliquid perpetuals using Basana. diff --git a/tests/test_hyperliquid_client.py b/tests/test_hyperliquid_client.py index 92f9d55..61df482 100644 --- a/tests/test_hyperliquid_client.py +++ b/tests/test_hyperliquid_client.py @@ -1,6 +1,6 @@ # Basana # -# Copyright 2022 Gabriel Martin Becedillas Ruiz +# Copyright 2026 Christian Pojoni # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/test_hyperliquid_exchange.py b/tests/test_hyperliquid_exchange.py index 208b15b..7301e69 100644 --- a/tests/test_hyperliquid_exchange.py +++ b/tests/test_hyperliquid_exchange.py @@ -1,6 +1,6 @@ # Basana # -# Copyright 2022 Gabriel Martin Becedillas Ruiz +# Copyright 2026 Christian Pojoni # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. From 24d89ec9062930a8f2a56511d601871a8f654e6a Mon Sep 17 00:00:00 2001 From: Claudia Date: Wed, 4 Mar 2026 20:16:42 +0000 Subject: [PATCH 11/17] chore: remove internal paper trading script from PR samples/hyperliquid/paper_trader.py contains LunarCrush integration and personal paper trading logic not intended for public consumption. --- samples/hyperliquid/paper_trader.py | 371 ---------------------------- 1 file changed, 371 deletions(-) delete mode 100644 samples/hyperliquid/paper_trader.py diff --git a/samples/hyperliquid/paper_trader.py b/samples/hyperliquid/paper_trader.py deleted file mode 100644 index e0a374c..0000000 --- a/samples/hyperliquid/paper_trader.py +++ /dev/null @@ -1,371 +0,0 @@ -# Basana -# -# Copyright 2026 Christian Pojoni -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -""" -Autonomous paper trader using Basana + Hyperliquid. - -Architecture (follows Basana patterns): - - RealtimeDispatcher drives the event loop - - Hyperliquid connector provides live bar events via WebSocket - - RSI strategy (TradingSignalSource) emits LONG/SHORT/NEUTRAL signals - - LunarCrush pre-filter gates which coins are eligible - - PaperPositionManager executes trades at live Hyperliquid mid-prices - and tracks P&L in JSON (no real money involved) - -Requirements: - pip install basana talipp hyperliquid-python-sdk - -Usage: - python3 paper_trader.py # all default coins - python3 paper_trader.py --coins ETH BTC # specific coins - python3 paper_trader.py --interval 1h # different bar interval -""" - -import argparse -import asyncio -import dataclasses -import json -import logging -import urllib.request -from datetime import datetime, timezone -from decimal import Decimal -from pathlib import Path -from typing import Dict, List, Optional - -from talipp.indicators import RSI, MACD, BB - -import basana as bs -from basana.external.hyperliquid import Exchange as HLExchange -from basana.core.enums import OrderOperation - -# ── Config ──────────────────────────────────────────────────────────────────── -WORKSPACE = Path(__file__).parent.parent.parent.parent / ".openclaw/workspace" -TRADES_FILE = WORKSPACE / "memory/paper_trades.json" -PORTFOLIO_FILE = WORKSPACE / "memory/paper_portfolio.json" - -LC_KEY = "YOUR_LC_KEY_HERE" -STARTING_BALANCE = 10_000.0 -POSITION_SIZE_USD = 1_000.0 -MAX_POSITIONS = 5 - -RSI_PERIOD = 14 -RSI_OVERSOLD = 32 -RSI_OVERBOUGHT = 68 -MACD_FAST, MACD_SLOW, MACD_SIGNAL = 12, 26, 9 - -STOP_LOSS_PCT = Decimal("-15") -TAKE_PROFIT_PCT = Decimal("30") - -DEFAULT_COINS = ["ASTER", "AERO", "PYTH", "XRP", "DOGE", "POL", "TIA", "RYO"] -DEFAULT_INTERVAL = "1h" - -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s %(levelname)s %(name)s %(message)s", -) -logger = logging.getLogger("paper_trader") - - -# ── Portfolio persistence ───────────────────────────────────────────────────── - -def _load(path: Path) -> dict: - if path.exists(): - with open(path) as f: - return json.load(f) - return {} - - -def _save(path: Path, data: dict) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w") as f: - json.dump(data, f, indent=2) - - -@dataclasses.dataclass -class PaperPortfolio: - """In-memory paper portfolio. Persists to JSON on every change.""" - - balance: Decimal = Decimal(STARTING_BALANCE) - positions: Dict[str, dict] = dataclasses.field(default_factory=dict) - closed_trades: List[dict] = dataclasses.field(default_factory=list) - realized_pnl: Decimal = Decimal(0) - - @classmethod - def load(cls) -> "PaperPortfolio": - trades = _load(TRADES_FILE) - portfolio = _load(PORTFOLIO_FILE) - inst = cls( - balance=Decimal(str(portfolio.get("balance_usd", STARTING_BALANCE))), - positions=trades.get("open_positions", {}), - closed_trades=trades.get("closed_trades", []), - realized_pnl=Decimal(str(portfolio.get("realized_pnl", 0))), - ) - return inst - - def save(self) -> None: - trades_data = { - "open_positions": self.positions, - "closed_trades": self.closed_trades, - "trades": list(self.positions.values()) + self.closed_trades, - } - portfolio_data = { - "balance_usd": float(self.balance), - "starting_balance": STARTING_BALANCE, - "realized_pnl": float(self.realized_pnl), - } - _save(TRADES_FILE, trades_data) - _save(PORTFOLIO_FILE, portfolio_data) - - @property - def open_count(self) -> int: - return len(self.positions) - - def open_position(self, coin: str, direction: str, price: Decimal, reason: str) -> None: - if coin in self.positions: - return - if self.open_count >= MAX_POSITIONS: - logger.info("Max positions reached, skipping %s", coin) - return - size = Decimal(POSITION_SIZE_USD) - if self.balance < size: - logger.info("Insufficient balance ($%.2f), skipping %s", self.balance, coin) - return - - qty = size / price - self.positions[coin] = { - "symbol": coin, - "direction": direction, - "size_usd": float(size), - "entry_price": float(price), - "quantity": float(qty), - "opened_at": datetime.now(timezone.utc).isoformat(), - "reason": reason, - } - self.balance -= size - self.save() - logger.info("OPEN %s %s @ $%s | qty=%.4f | %s", direction, coin, price, qty, reason) - - def close_position(self, coin: str, price: Decimal, reason: str) -> Optional[Decimal]: - pos = self.positions.pop(coin, None) - if not pos: - return None - - direction = pos["direction"] - entry = Decimal(str(pos["entry_price"])) - qty = Decimal(str(pos["quantity"])) - size = Decimal(str(pos["size_usd"])) - - pnl = (price - entry) * qty if direction == "LONG" else (entry - price) * qty - pnl_pct = (pnl / size) * 100 - return_usd = size + pnl - - self.closed_trades.append({ - **pos, - "exit_price": float(price), - "closed_at": datetime.now(timezone.utc).isoformat(), - "pnl_usd": float(pnl), - "pnl_pct": float(pnl_pct), - "reason": reason, - }) - self.balance += return_usd - self.realized_pnl += pnl - self.save() - logger.info("CLOSE %s %s @ $%s | P&L: $%.2f (%.1f%%) | %s", direction, coin, price, pnl, pnl_pct, reason) - return pnl_pct - - def unrealized_pnl(self, coin: str, current_price: Decimal) -> Optional[Decimal]: - pos = self.positions.get(coin) - if not pos: - return None - entry = Decimal(str(pos["entry_price"])) - qty = Decimal(str(pos["quantity"])) - size = Decimal(str(pos["size_usd"])) - direction = pos["direction"] - pnl = (current_price - entry) * qty if direction == "LONG" else (entry - current_price) * qty - return (pnl / size) * 100 - - -# ── LunarCrush gate ─────────────────────────────────────────────────────────── - -class LunarCrushGate: - """Checks LC galaxy score and sentiment before allowing a trade.""" - - def __init__(self, min_galaxy: float = 55, min_sentiment: float = 70): - self._min_galaxy = min_galaxy - self._min_sentiment = min_sentiment - self._cache: Dict[str, tuple] = {} # coin -> (galaxy, sentiment, ts) - - def is_approved(self, coin: str) -> bool: - url = f"https://lunarcrush.com/api4/public/coins/{coin.lower()}/v1" - try: - req = urllib.request.Request(url, headers={ - "Authorization": f"Bearer {LC_KEY}", - "User-Agent": "paper-trader/1.0", - }) - data = json.loads(urllib.request.urlopen(req, timeout=8).read()) - d = data.get("data", {}) - galaxy = float(d.get("galaxy_score") or 0) - sentiment = float(d.get("sentiment") or 50) - logger.info("LC %s: galaxy=%.1f sentiment=%.0f%%", coin, galaxy, sentiment) - return galaxy >= self._min_galaxy and sentiment >= self._min_sentiment - except Exception as e: - logger.warning("LC check failed for %s: %s - allowing trade", coin, e) - return True # Fail open so LC outage doesn't block all trades - - -# ── Basana strategy (TradingSignalSource) ───────────────────────────────────── - -class RSIMACDStrategy(bs.TradingSignalSource): - """Emits LONG/SHORT/NEUTRAL signals based on RSI crossover + MACD confirmation.""" - - def __init__(self, dispatcher: bs.EventDispatcher, coin: str, lc_gate: LunarCrushGate): - super().__init__(dispatcher) - self._coin = coin - self._lc_gate = lc_gate - self._rsi = RSI(RSI_PERIOD) - self._macd = MACD(MACD_FAST, MACD_SLOW, MACD_SIGNAL) - self._pair = bs.Pair(coin, "USD") - - async def on_bar_event(self, bar_event: bs.BarEvent) -> None: - close = float(bar_event.bar.close) - self._rsi.add(close) - self._macd.add(close) - - # Need enough history - if len(self._rsi) < 2 or self._rsi[-2] is None or self._rsi[-1] is None: - return - if len(self._macd) < 2 or self._macd[-1] is None: - return - - rsi_prev = self._rsi[-2] - rsi_now = self._rsi[-1] - macd_hist = self._macd[-1].histogram or 0 - - # RSI crosses into oversold + MACD histogram positive (momentum turning up) - if rsi_prev >= RSI_OVERSOLD and rsi_now < RSI_OVERSOLD and macd_hist > 0: - if self._lc_gate.is_approved(self._coin): - logger.info("%s LONG signal: RSI %.1f->%.1f MACD_hist=%.5g", self._coin, rsi_prev, rsi_now, macd_hist) - self.push(bs.TradingSignal(bar_event.when, bs.Position.LONG, self._pair)) - - # RSI crosses into overbought - exit long / go neutral - elif rsi_prev <= RSI_OVERBOUGHT and rsi_now > RSI_OVERBOUGHT: - logger.info("%s NEUTRAL signal: RSI %.1f->%.1f (overbought)", self._coin, rsi_prev, rsi_now) - self.push(bs.TradingSignal(bar_event.when, bs.Position.NEUTRAL, self._pair)) - - -# ── Paper position manager ──────────────────────────────────────────────────── - -class PaperPositionManager: - """Receives trading signals and manages paper positions. - - Uses live Hyperliquid mid-prices for execution. - Enforces stop-loss and take-profit on every bar. - """ - - def __init__(self, hl_exchange: HLExchange, portfolio: PaperPortfolio): - self._exchange = hl_exchange - self._portfolio = portfolio - - async def on_trading_signal(self, signal: bs.TradingSignal) -> None: - pairs = list(signal.get_pairs()) - for pair, target_position in pairs: - coin = pair.base_symbol - try: - price = await self._exchange.get_mid_price(coin) - except Exception: - # Coin not on Hyperliquid perps - use LC price - lc_data = json.loads(urllib.request.urlopen( - urllib.request.Request( - f"https://lunarcrush.com/api4/public/coins/{coin.lower()}/v1", - headers={"Authorization": f"Bearer {LC_KEY}", "User-Agent": "paper-trader/1.0"} - ), timeout=8 - ).read()) - price = Decimal(str(lc_data["data"]["price"])) - - if target_position == bs.Position.LONG and coin not in self._portfolio.positions: - self._portfolio.open_position( - coin, "LONG", price, - reason=f"RSI+MACD signal at {signal.when.isoformat()}", - ) - - elif target_position == bs.Position.NEUTRAL and coin in self._portfolio.positions: - self._portfolio.close_position(coin, price, reason="RSI overbought exit") - - async def on_bar_event(self, bar_event: bs.BarEvent) -> None: - """Check stop-loss and take-profit on every bar for all open positions.""" - coin = bar_event.bar.pair.base_symbol - if coin not in self._portfolio.positions: - return - - price = Decimal(str(bar_event.bar.close)) - pnl_pct = self._portfolio.unrealized_pnl(coin, price) - if pnl_pct is None: - return - - logger.debug("%s unrealized P&L: %.1f%%", coin, pnl_pct) - - if pnl_pct <= STOP_LOSS_PCT: - logger.warning("%s stop-loss triggered at %.1f%%", coin, pnl_pct) - self._portfolio.close_position(coin, price, reason=f"Stop-loss {pnl_pct:.1f}%") - - elif pnl_pct >= TAKE_PROFIT_PCT: - logger.info("%s take-profit triggered at +%.1f%%", coin, pnl_pct) - self._portfolio.close_position(coin, price, reason=f"Take-profit +{pnl_pct:.1f}%") - - -# ── Main ───────────────────────────────────────────────────────────────────── - -async def main(coins: List[str], interval: str) -> None: - portfolio = PaperPortfolio.load() - logger.info( - "Portfolio loaded: balance=$%.2f | open=%d | realized_pnl=$%.2f", - portfolio.balance, portfolio.open_count, portfolio.realized_pnl, - ) - - dispatcher = bs.realtime_dispatcher() - hl = HLExchange(dispatcher=dispatcher) - lc_gate = LunarCrushGate() - position_mgr = PaperPositionManager(hl, portfolio) - - for coin in coins: - strategy = RSIMACDStrategy(dispatcher, coin, lc_gate) - strategy.subscribe_to_trading_signals(position_mgr.on_trading_signal) - hl.subscribe_to_bar_events(coin, interval, strategy.on_bar_event) - hl.subscribe_to_bar_events(coin, interval, position_mgr.on_bar_event) - - logger.info("Paper trader live on %d coins (%s bars). Ctrl+C to stop.", len(coins), interval) - - try: - await dispatcher.run() - except KeyboardInterrupt: - pass - - # Final summary - logger.info("=== Final portfolio ===") - logger.info("Balance: $%.2f | Realized P&L: $%.2f", portfolio.balance, portfolio.realized_pnl) - for coin, pos in portfolio.positions.items(): - price = await hl.get_mid_price(coin) - pnl_pct = portfolio.unrealized_pnl(coin, price) - logger.info(" %s %s: unrealized %.1f%%", pos["direction"], coin, pnl_pct or 0) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Basana paper trader on Hyperliquid") - parser.add_argument("--coins", nargs="+", default=DEFAULT_COINS, help="Coins to trade") - parser.add_argument("--interval", default=DEFAULT_INTERVAL, help="Bar interval (1m, 5m, 1h, 4h...)") - args = parser.parse_args() - - asyncio.run(main(coins=args.coins, interval=args.interval)) From eeec32127172bdc93120ddec1c9af645411222d6 Mon Sep 17 00:00:00 2001 From: Christian Pojoni Date: Fri, 6 Mar 2026 22:19:12 +0000 Subject: [PATCH 12/17] docs: add Hyperliquid documentation and update pyproject.toml with dependencies --- docs/api.rst | 2 ++ docs/hyperliquid_exchange.rst | 12 ++++++++++++ docs/hyperliquid_perps.rst | 13 +++++++++++++ docs/index.rst | 4 ++-- pyproject.toml | 3 +++ 5 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 docs/hyperliquid_exchange.rst create mode 100644 docs/hyperliquid_perps.rst diff --git a/docs/api.rst b/docs/api.rst index adccf31..57cfe9c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -23,3 +23,5 @@ API Reference bitstamp_order_book bitstamp_orders bitstamp_trades + hyperliquid_exchange + hyperliquid_perps diff --git a/docs/hyperliquid_exchange.rst b/docs/hyperliquid_exchange.rst new file mode 100644 index 0000000..a195548 --- /dev/null +++ b/docs/hyperliquid_exchange.rst @@ -0,0 +1,12 @@ +basana.external.hyperliquid.exchange +=================================== + +.. module:: basana.external.hyperliquid.exchange + +.. autoclass:: basana.external.hyperliquid.exchange.Exchange + :members: +.. autoexception:: basana.external.hyperliquid.exchange.Error + :members: +.. autoclass:: basana.external.hyperliquid.exchange.AssetInfo + :show-inheritance: + :members: diff --git a/docs/hyperliquid_perps.rst b/docs/hyperliquid_perps.rst new file mode 100644 index 0000000..aa60cc5 --- /dev/null +++ b/docs/hyperliquid_perps.rst @@ -0,0 +1,13 @@ +basana.external.hyperliquid.perps +================================ + +.. module:: basana.external.hyperliquid.perps + +.. autoclass:: basana.external.hyperliquid.perps.Account + :members: +.. autoclass:: basana.external.hyperliquid.perps.Position + :show-inheritance: + :members: +.. autoclass:: basana.external.hyperliquid.perps.OrderInfo + :show-inheritance: + :members: diff --git a/docs/index.rst b/docs/index.rst index 540667f..b83a011 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -12,8 +12,8 @@ The framework has 3 main components: * The core, where basic abstractions like events, event sources and the event dispatcher live. * A backtesting exchange that you can use to validate your strategies before using real money. -* External integrations, where you'll find support for live trading at `Binance `_ and - `Bitstamp `_ crypto currency exchanges. +* External integrations, where you'll find support for live trading at `Binance `_, + `Bitstamp `_ and `Hyperliquid `_ crypto currency exchanges. Basana doesn't ship with technical indicators. The `examples at GitHub `_ take advantage of `TALIpp `_ which is a good fit for event driven and real time applications, diff --git a/pyproject.toml b/pyproject.toml index 87bc677..5b7ff33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,9 +16,12 @@ python-dateutil = "^2.9" # Optional dependencies, some of which are included in the below `extras`. They can be opted into by apps. plotly = {version = "^5.14.1", optional = true} kaleido = {version = "0.2.1", optional = true} +hyperliquid-python-sdk = {version = "^0.4.0", optional = true} +eth-account = {version = "^0.11.0", optional = true} [tool.poetry.extras] charts = ["plotly", "kaleido"] +hyperliquid = ["hyperliquid-python-sdk", "eth-account"] [tool.poetry.group.dev.dependencies] aioresponses = "^0.7.7" From b4a00699dc684317b7a9678030ad482afeaceb26 Mon Sep 17 00:00:00 2001 From: Christian Pojoni Date: Mon, 9 Mar 2026 21:34:34 +0000 Subject: [PATCH 13/17] fix: address Hyperliquid review issues --- README.md | 7 +++++ basana/external/hyperliquid/client/rest.py | 6 ++-- basana/external/hyperliquid/exchange.py | 6 ++-- basana/external/hyperliquid/perps.py | 32 +++++++++++++++----- basana/external/hyperliquid/websockets.py | 4 ++- docs/hyperliquid_perps.rst | 7 +++++ tests/test_hyperliquid_client.py | 34 ++++++++++++++++++++++ tests/test_hyperliquid_exchange.py | 17 +++++++++++ 8 files changed, 101 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b931da4..f6741c2 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,13 @@ $ pip install basana[charts] ``` +If you want to use the Hyperliquid connector as well, install the additional +exchange dependencies: + +``` +$ pip install hyperliquid-python-sdk eth-account +``` + The examples use [TALIpp](https://github.com/nardew/talipp) for the technical indicators, pandas, statsmodels and also [Textual](https://textual.textualize.io/) if you want to run the Binance order book mirror. ``` diff --git a/basana/external/hyperliquid/client/rest.py b/basana/external/hyperliquid/client/rest.py index 3c1d2df..8890ee5 100644 --- a/basana/external/hyperliquid/client/rest.py +++ b/basana/external/hyperliquid/client/rest.py @@ -50,8 +50,10 @@ class APIClient: def __init__( self, private_key: Optional[str] = None, - config_overrides: dict = {}, + config_overrides: Optional[dict] = None, ): + if config_overrides is None: + config_overrides = {} base_url = get_config_value(config.DEFAULTS, "api.http.base_url", overrides=config_overrides).rstrip("/") self._info = Info(base_url, skip_ws=True) self._wallet: Optional[Any] = None @@ -206,5 +208,5 @@ def _check_result(result: dict) -> None: @staticmethod async def _run(fn, *args) -> Any: """Run a blocking SDK call in the default thread pool executor.""" - loop = asyncio.get_event_loop() + loop = asyncio.get_running_loop() return await loop.run_in_executor(None, functools.partial(fn, *args)) diff --git a/basana/external/hyperliquid/exchange.py b/basana/external/hyperliquid/exchange.py index 4e18c95..bdf4398 100644 --- a/basana/external/hyperliquid/exchange.py +++ b/basana/external/hyperliquid/exchange.py @@ -90,12 +90,14 @@ def __init__( dispatcher: dispatcher.EventDispatcher, private_key: Optional[str] = None, session: Optional[aiohttp.ClientSession] = None, - config_overrides: dict = {}, + config_overrides: Optional[dict] = None, ): + if config_overrides is None: + config_overrides = {} self._dispatcher = dispatcher self._cli = client.APIClient(private_key=private_key, config_overrides=config_overrides) self._ws = websockets.WebSocketClient(session=session, config_overrides=config_overrides) - self._perps = perps.Account(self._cli, self._ws) + self._perps = perps.Account(self._cli, self._ws, dispatcher) self._asset_info: Dict[str, AssetInfo] = {} # ------------------------------------------------------------------ diff --git a/basana/external/hyperliquid/perps.py b/basana/external/hyperliquid/perps.py index 479d2c4..bbe6f9c 100644 --- a/basana/external/hyperliquid/perps.py +++ b/basana/external/hyperliquid/perps.py @@ -19,6 +19,7 @@ import dataclasses import logging +from basana.core import dispatcher from basana.core.enums import OrderOperation from . import client, websockets @@ -71,9 +72,15 @@ class Account: :param ws_client: The WebSocket client. """ - def __init__(self, api_client: client.APIClient, ws_client: websockets.WebSocketClient): + def __init__( + self, + api_client: client.APIClient, + ws_client: websockets.WebSocketClient, + event_dispatcher: dispatcher.EventDispatcher, + ): self._cli = api_client self._ws = ws_client + self._dispatcher = event_dispatcher # ------------------------------------------------------------------ # Account state @@ -133,7 +140,7 @@ async def market_open(self, coin: str, operation: OrderOperation, size: Decimal, """ is_buy = operation == OrderOperation.BUY result = await self._cli.market_open(coin, is_buy, float(size), slippage) - return self._parse_order_result(result, coin) + return self._parse_order_result(result, coin, is_buy=is_buy) async def market_close(self, coin: str, size: Optional[Decimal] = None, slippage: float = 0.01) -> OrderInfo: """Close a position at market price. @@ -163,7 +170,7 @@ async def limit_order( """ is_buy = operation == OrderOperation.BUY result = await self._cli.limit_order(coin, is_buy, float(size), float(limit_price), reduce_only) - return self._parse_order_result(result, coin) + return self._parse_order_result(result, coin, is_buy=is_buy) async def cancel_order(self, coin: str, oid: int) -> None: """Cancel order ``oid`` for ``coin``.""" @@ -191,22 +198,33 @@ def subscribe_to_fill_events(self, handler: FillEventHandler) -> None: raise client.Error("Private key required to subscribe to fill events") channel = websockets._user_fills_channel(self._cli.address) - event_source = websockets.RawEventSource(producer=self._ws) - self._ws.set_channel_event_source(channel, event_source) + event_source = self._ws.get_channel_event_source(channel) + if event_source is None: + event_source = websockets.RawEventSource(producer=self._ws) + self._ws.set_channel_event_source(channel, event_source) + self._dispatcher.subscribe(event_source, handler) # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ @staticmethod - def _parse_order_result(result: dict, coin: str) -> OrderInfo: + def _parse_order_result(result: dict, coin: str, is_buy: Optional[bool] = None) -> OrderInfo: statuses = result.get("response", {}).get("data", {}).get("statuses", [{}]) s = statuses[0] if statuses else {} filled = s.get("filled", {}) + if is_buy is None: + side = filled.get("side") or s.get("side") + if side == "B": + is_buy = True + elif side == "A": + is_buy = False + else: + is_buy = False return OrderInfo( oid=filled.get("oid", 0), coin=coin, - is_buy=True, + is_buy=is_buy, size=Decimal(str(filled.get("totalSz", "0"))), limit_price=None, filled=Decimal(str(filled.get("totalSz", "0"))), diff --git a/basana/external/hyperliquid/websockets.py b/basana/external/hyperliquid/websockets.py index 4de6686..07091ea 100644 --- a/basana/external/hyperliquid/websockets.py +++ b/basana/external/hyperliquid/websockets.py @@ -73,8 +73,10 @@ class WebSocketClient(core_ws.WebSocketClient): def __init__( self, session: Optional[aiohttp.ClientSession] = None, - config_overrides: dict = {}, + config_overrides: Optional[dict] = None, ): + if config_overrides is None: + config_overrides = {} super().__init__( get_config_value(config.DEFAULTS, "api.websockets.base_url", overrides=config_overrides), session=session, diff --git a/docs/hyperliquid_perps.rst b/docs/hyperliquid_perps.rst index aa60cc5..0995d4b 100644 --- a/docs/hyperliquid_perps.rst +++ b/docs/hyperliquid_perps.rst @@ -5,6 +5,13 @@ basana.external.hyperliquid.perps .. autoclass:: basana.external.hyperliquid.perps.Account :members: + +.. note:: + + ``subscribe_to_fill_events`` registers the fill channel with the shared + websocket client **and** subscribes the provided async handler through the + event dispatcher. As with other live streams in Basana, the dispatcher must + be running for callbacks to fire. .. autoclass:: basana.external.hyperliquid.perps.Position :show-inheritance: :members: diff --git a/tests/test_hyperliquid_client.py b/tests/test_hyperliquid_client.py index 61df482..0cdbde3 100644 --- a/tests/test_hyperliquid_client.py +++ b/tests/test_hyperliquid_client.py @@ -15,10 +15,12 @@ # limitations under the License. import asyncio +from decimal import Decimal from unittest.mock import MagicMock, patch, AsyncMock import pytest from basana.external.hyperliquid.client.rest import APIClient, Error +from basana.external.hyperliquid.perps import Account # --------------------------------------------------------------------------- @@ -163,3 +165,35 @@ def test_address_exposed(self, mock_info, mock_account, mock_exchange_sdk): def test_address_none_without_key(self, mock_info): cli = APIClient() assert cli.address is None + + +class TestOrderResultParsing: + def test_parse_order_result_preserves_buy_side(self): + order = Account._parse_order_result( + { + "response": { + "data": { + "statuses": [{"filled": {"oid": 7, "totalSz": "0.5", "side": "B"}}] + } + } + }, + "ETH", + is_buy=True, + ) + assert order.is_buy is True + assert order.filled == Decimal("0.5") + + def test_parse_order_result_preserves_sell_side(self): + order = Account._parse_order_result( + { + "response": { + "data": { + "statuses": [{"filled": {"oid": 8, "totalSz": "0.25", "side": "A"}}] + } + } + }, + "ETH", + is_buy=False, + ) + assert order.is_buy is False + assert order.filled == Decimal("0.25") diff --git a/tests/test_hyperliquid_exchange.py b/tests/test_hyperliquid_exchange.py index 7301e69..51249c8 100644 --- a/tests/test_hyperliquid_exchange.py +++ b/tests/test_hyperliquid_exchange.py @@ -192,3 +192,20 @@ class TestLifecycle: def test_perps_account_accessible(self, exchange): from basana.external.hyperliquid.perps import Account assert isinstance(exchange.perps_account, Account) + + +class TestPerpsAccount: + def test_subscribe_to_fill_events_registers_handler(self, mock_api_client, mock_ws_client): + d = bs.realtime_dispatcher() + d.subscribe = MagicMock() + mock_api_client.address = "0xDEADBEEF" + mock_ws_client.get_channel_event_source.return_value = None + exchange = Exchange(dispatcher=d, private_key="0xdeadbeef") + handler = AsyncMock() + + exchange.perps_account.subscribe_to_fill_events(handler) + + mock_ws_client.set_channel_event_source.assert_called_once() + d.subscribe.assert_called_once() + subscribe_args = d.subscribe.call_args[0] + assert subscribe_args[1] is handler From 94d1e2c5a16428808fcb06c1067088f914d828c2 Mon Sep 17 00:00:00 2001 From: Christian Pojoni Date: Mon, 9 Mar 2026 21:38:59 +0000 Subject: [PATCH 14/17] fix: adapt Hyperliquid bar events to current Bar API --- basana/external/hyperliquid/exchange.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/basana/external/hyperliquid/exchange.py b/basana/external/hyperliquid/exchange.py index bdf4398..ea19b42 100644 --- a/basana/external/hyperliquid/exchange.py +++ b/basana/external/hyperliquid/exchange.py @@ -57,15 +57,18 @@ def __init__(self, pair: Pair, producer: event.Producer): async def push_from_message(self, message: dict): try: + begin = helpers.timestamp_to_datetime(int(message["t"])) when = helpers.timestamp_to_datetime(int(message["T"])) + duration = when - begin b = bar.Bar( - when, + begin, self._pair, Decimal(str(message["o"])), Decimal(str(message["h"])), Decimal(str(message["l"])), Decimal(str(message["c"])), Decimal(str(message["v"])), + duration, ) self.push(bar.BarEvent(when, b)) except (KeyError, ValueError) as e: From cd60c822687f3dfbacba7d90680aac5c63da29bd Mon Sep 17 00:00:00 2001 From: Christian Pojoni Date: Mon, 9 Mar 2026 21:54:44 +0000 Subject: [PATCH 15/17] fix: address Hyperliquid review feedback --- basana/external/hyperliquid/exchange.py | 16 ++++- basana/external/hyperliquid/websockets.py | 61 ++++++++++------ docs/hyperliquid_exchange.rst | 5 ++ tests/test_hyperliquid_exchange.py | 84 +++++++++++++++-------- 4 files changed, 117 insertions(+), 49 deletions(-) diff --git a/basana/external/hyperliquid/exchange.py b/basana/external/hyperliquid/exchange.py index ea19b42..197d9b8 100644 --- a/basana/external/hyperliquid/exchange.py +++ b/basana/external/hyperliquid/exchange.py @@ -23,7 +23,7 @@ from basana.core import bar, dispatcher, event from basana.core.pair import Pair, PairInfo -from . import client, helpers, perps, websockets +from . import client, config, helpers, perps, websockets logger = logging.getLogger(__name__) @@ -86,6 +86,7 @@ class Exchange: If omitted, only public market data endpoints are available. :param session: Optional :class:`aiohttp.ClientSession` for connection reuse. :param config_overrides: Optional dict for overriding config settings. + :param testnet: If ``True``, use Hyperliquid testnet defaults unless overridden. """ def __init__( @@ -94,9 +95,20 @@ def __init__( private_key: Optional[str] = None, session: Optional[aiohttp.ClientSession] = None, config_overrides: Optional[dict] = None, + testnet: bool = False, ): if config_overrides is None: config_overrides = {} + if testnet: + merged_overrides = { + "api": { + "http": dict(config.TESTNET_DEFAULTS["api"]["http"]), + "websockets": dict(config.TESTNET_DEFAULTS["api"]["websockets"]), + } + } + for section, values in config_overrides.get("api", {}).items(): + merged_overrides["api"].setdefault(section, {}).update(values) + config_overrides = merged_overrides self._dispatcher = dispatcher self._cli = client.APIClient(private_key=private_key, config_overrides=config_overrides) self._ws = websockets.WebSocketClient(session=session, config_overrides=config_overrides) @@ -188,6 +200,8 @@ async def get_pair_info(self, coin: str) -> AssetInfo: sz_decimals=asset.get("szDecimals", 8), max_leverage=asset.get("maxLeverage", 50), ) + if coin not in self._asset_info: + raise Error(f"Unknown coin: {coin}") return self._asset_info[coin] async def list_coins(self) -> List[str]: diff --git a/basana/external/hyperliquid/websockets.py b/basana/external/hyperliquid/websockets.py index 07091ea..e4b5ea1 100644 --- a/basana/external/hyperliquid/websockets.py +++ b/basana/external/hyperliquid/websockets.py @@ -14,8 +14,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Awaitable, Callable, List, Optional -import json +from typing import Any, Awaitable, Callable, Dict, List, Optional import logging import aiohttp @@ -83,6 +82,11 @@ def __init__( config_overrides=config_overrides, heartbeat=get_config_value(config.DEFAULTS, "api.websockets.heartbeat", overrides=config_overrides), ) + self._registered_channels: Dict[str, core_ws.ChannelEventSource] = {} + + def set_channel_event_source(self, channel: str, event_source: core_ws.ChannelEventSource): + self._registered_channels[channel] = event_source + super().set_channel_event_source(channel, event_source) async def subscribe_to_channels(self, channels: List[str], ws_cli: aiohttp.ClientWebSocketResponse): for channel in channels: @@ -98,14 +102,16 @@ async def handle_message(self, message: dict) -> bool: if not channel_name or channel_name == "subscriptionResponse": return True # Ack messages — handled, nothing to dispatch - # Route to the matching ChannelEventSource - matched = False - for registered_channel, event_source in self._event_sources.items(): - if self._matches(registered_channel, channel_name, data): - await event_source.push_from_message(data) - matched = True + lookup_key = self._message_to_registered_channel(channel_name, data) + if lookup_key is None: + return False - return matched + event_source = self._registered_channels.get(lookup_key) + if event_source is None: + return False + + await event_source.push_from_message(data) + return True # ------------------------------------------------------------------ # Internal helpers @@ -125,16 +131,29 @@ def _channel_to_subscription(channel: str) -> Optional[dict]: return None @staticmethod - def _matches(registered_channel: str, ws_channel: str, data: dict) -> bool: - """Check if a WebSocket message belongs to a registered channel.""" - parts = registered_channel.split(":") - if parts[0] != ws_channel: - return False - - # For coin-scoped subscriptions, also verify the coin in the payload. - if ws_channel in ("candle", "trades", "l2Book") and len(parts) >= 2: - payload_coin = data.get("coin") or data.get("s", "") - return payload_coin == parts[1] + def _message_to_registered_channel(ws_channel: str, data: dict) -> Optional[str]: + """Build the registered channel key from a WebSocket payload.""" + if ws_channel == "candle": + coin = data.get("coin") or data.get("s", "") + interval = data.get("interval") or data.get("i") + if coin and interval: + return _candle_channel(coin, interval) + return None + + if ws_channel == "trades": + coin = data.get("coin") or data.get("s", "") + return _trades_channel(coin) if coin else None + + if ws_channel == "l2Book": + coin = data.get("coin") or data.get("s", "") + return _l2_book_channel(coin) if coin else None + + if ws_channel == "orderUpdates": + user = data.get("user") or data.get("address") + return _order_updates_channel(user) if user else None + + if ws_channel == "userFills": + user = data.get("user") or data.get("address") + return _user_fills_channel(user) if user else None - # For user-scoped subscriptions, no extra check needed. - return True + return None diff --git a/docs/hyperliquid_exchange.rst b/docs/hyperliquid_exchange.rst index a195548..8f1a2da 100644 --- a/docs/hyperliquid_exchange.rst +++ b/docs/hyperliquid_exchange.rst @@ -5,6 +5,11 @@ basana.external.hyperliquid.exchange .. autoclass:: basana.external.hyperliquid.exchange.Exchange :members: + +.. note:: + + Pass ``testnet=True`` to ``Exchange`` to use Hyperliquid testnet defaults. + You can still override individual API endpoints through ``config_overrides``. .. autoexception:: basana.external.hyperliquid.exchange.Error :members: .. autoclass:: basana.external.hyperliquid.exchange.AssetInfo diff --git a/tests/test_hyperliquid_exchange.py b/tests/test_hyperliquid_exchange.py index 51249c8..a870d3b 100644 --- a/tests/test_hyperliquid_exchange.py +++ b/tests/test_hyperliquid_exchange.py @@ -27,27 +27,30 @@ # Fixtures # --------------------------------------------------------------------------- + @pytest.fixture() def mock_api_client(): with patch("basana.external.hyperliquid.exchange.client.APIClient") as MockClient: instance = MockClient.return_value - instance.get_all_mids = AsyncMock(return_value={ - "ETH": "2100.0", "BTC": "70000.0", "SOL": "150.0" - }) - instance.get_l2_snapshot = AsyncMock(return_value={ - "coin": "ETH", - "levels": [ - [{"px": "2099.5", "sz": "2.0", "n": 1}], - [{"px": "2100.5", "sz": "1.5", "n": 1}], - ], - }) - instance.get_meta = AsyncMock(return_value={ - "universe": [ - {"name": "BTC", "szDecimals": 5, "maxLeverage": 50}, - {"name": "ETH", "szDecimals": 4, "maxLeverage": 25}, - {"name": "SOL", "szDecimals": 2, "maxLeverage": 20}, - ] - }) + instance.get_all_mids = AsyncMock(return_value={"ETH": "2100.0", "BTC": "70000.0", "SOL": "150.0"}) + instance.get_l2_snapshot = AsyncMock( + return_value={ + "coin": "ETH", + "levels": [ + [{"px": "2099.5", "sz": "2.0", "n": 1}], + [{"px": "2100.5", "sz": "1.5", "n": 1}], + ], + } + ) + instance.get_meta = AsyncMock( + return_value={ + "universe": [ + {"name": "BTC", "szDecimals": 5, "maxLeverage": 50}, + {"name": "ETH", "szDecimals": 4, "maxLeverage": 25}, + {"name": "SOL", "szDecimals": 2, "maxLeverage": 20}, + ] + } + ) yield instance @@ -69,6 +72,7 @@ def exchange(mock_api_client, mock_ws_client): # Market data # --------------------------------------------------------------------------- + class TestMarketData: def test_get_mid_price(self, exchange): price = asyncio.run(exchange.get_mid_price("ETH")) @@ -101,6 +105,11 @@ def test_get_pair_info_cached(self, exchange, mock_api_client): asyncio.run(exchange.get_pair_info("ETH")) mock_api_client.get_meta.assert_called_once() + def test_get_pair_info_unknown_coin_raises(self, exchange, mock_api_client): + with pytest.raises(Error, match="Unknown coin"): + asyncio.run(exchange.get_pair_info("NOTACOIN")) + assert mock_api_client.get_meta.await_count == 1 + def test_list_coins(self, exchange): coins = asyncio.run(exchange.list_coins()) assert "BTC" in coins and "ETH" in coins and "SOL" in coins @@ -111,6 +120,7 @@ def test_list_coins(self, exchange): # WebSocket subscriptions # --------------------------------------------------------------------------- + class TestSubscriptions: def test_subscribe_to_bar_events(self, exchange, mock_ws_client): handler = AsyncMock() @@ -138,6 +148,7 @@ def test_subscribe_to_order_book_events(self, exchange, mock_ws_client): # Bar event construction # --------------------------------------------------------------------------- + class TestBarEventSource: def test_candle_to_bar_event(self): from basana.external.hyperliquid.exchange import BarEventSource @@ -149,17 +160,20 @@ def test_candle_to_bar_event(self): source = BarEventSource(pair=pair, producer=producer) events = [] + async def run(): - await source.push_from_message({ - "t": 1709500000000, - "T": 1709503600000, - "o": "2100.0", - "h": "2150.0", - "l": "2090.0", - "c": "2130.0", - "v": "500.5", - "coin": "ETH", - }) + await source.push_from_message( + { + "t": 1709500000000, + "T": 1709503600000, + "o": "2100.0", + "h": "2150.0", + "l": "2090.0", + "c": "2130.0", + "v": "500.5", + "coin": "ETH", + } + ) while True: event = source.pop() if event is None: @@ -184,13 +198,29 @@ def test_malformed_candle_does_not_raise(self): asyncio.run(source.push_from_message({"invalid": "data"})) +class TestWebSocketRouting: + def test_candle_interval_is_part_of_route_key(self): + from basana.external.hyperliquid import websockets + + assert ( + websockets.WebSocketClient._message_to_registered_channel("candle", {"coin": "ETH", "interval": "1h"}) + == "candle:ETH:1h" + ) + assert ( + websockets.WebSocketClient._message_to_registered_channel("candle", {"coin": "ETH", "interval": "5m"}) + == "candle:ETH:5m" + ) + + # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- + class TestLifecycle: def test_perps_account_accessible(self, exchange): from basana.external.hyperliquid.perps import Account + assert isinstance(exchange.perps_account, Account) From 5fb70f074f33a1c6abd76474eef70f11b14b1968 Mon Sep 17 00:00:00 2001 From: Christian Pojoni Date: Mon, 9 Mar 2026 21:56:49 +0000 Subject: [PATCH 16/17] fix: sync Hyperliquid deps with lockfile --- poetry.lock | 802 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 788 insertions(+), 16 deletions(-) diff --git a/poetry.lock b/poetry.lock index 0c6c994..250acde 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "aiodns" @@ -386,6 +386,154 @@ files = [ {file = "backports_zstd-1.2.0.tar.gz", hash = "sha256:6c3fc19342db750b52fde793e4440a93575761b1493bb4a1d3b26033d2bd3452"}, ] +[[package]] +name = "bitarray" +version = "2.9.3" +description = "efficient arrays of booleans -- C extension" +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "bitarray-2.9.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2cf5f5400636c7dda797fd681795ce63932458620fe8c40955890380acba9f62"}, + {file = "bitarray-2.9.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3487b4718ffa5942fab777835ee36085f8dda7ec4bd0b28433efb117f84852b6"}, + {file = "bitarray-2.9.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:10f44b1e4994035408bea54d7bf0aec79744cad709706bedf28091a48bb7f1a4"}, + {file = "bitarray-2.9.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb5c16f97c65add6535748a9c98c70e7ca79759c38a2eb990127fef72f76111a"}, + {file = "bitarray-2.9.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:13dbfc42971ba84e9c4ba070f720df6570285a3f89187f07ef422efcb611c19f"}, + {file = "bitarray-2.9.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c28076acfbe7f9a5494d7ae98094a6e209c390c340938845f294818ebf5e4d3"}, + {file = "bitarray-2.9.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b7cdd21835936d9a66477836ca23b2cb63295142cb9d9158883e2c0f1f8f6bd"}, + {file = "bitarray-2.9.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f60887ab3a46e507fa6f8544d8d4b0748da48718591dfe3fe80c62bdea60f10"}, + {file = "bitarray-2.9.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f75e1abd4a37cba3002521d3f5e2b50ef4f4a74342207cad3f52468411d5d8ba"}, + {file = "bitarray-2.9.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dc63da9695383c048b83f5ab77eab35a55bbb2e77c7b6e762eba219929b45b84"}, + {file = "bitarray-2.9.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6fe5a57b859d9bc9c2fd27c78c4b7b83158faf984202de6fb44618caeebfff10"}, + {file = "bitarray-2.9.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1fe5a37bd9441a5ecc2f6e71b43df7176fa376a542ef97484310b8b46a45649a"}, + {file = "bitarray-2.9.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8a16e42c169ca818d6a15b5dd5acd5d2a26af0fa0588e1036e0e58d01f8387d4"}, + {file = "bitarray-2.9.3-cp310-cp310-win32.whl", hash = "sha256:5e6b5e7940af3474ffaa930cd1ce8215181cbe864d6b5ddb67a15d3c15e935cd"}, + {file = "bitarray-2.9.3-cp310-cp310-win_amd64.whl", hash = "sha256:c63dbb99ef2ab1281871678624f9c9a5f1682b826e668ce559275ec488b3fa8b"}, + {file = "bitarray-2.9.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:49fb93b488d180f5c84b79fe687c585a84bf0295ff035d63e09ee24ce1da0558"}, + {file = "bitarray-2.9.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c2944fb83bbc2aa7f29a713bc4f8c1318e54fa0d06a72bedd350a3fb4a4b91d8"}, + {file = "bitarray-2.9.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3612d9d3788dc62f1922c917b1539f1cdf02cecc9faef8ae213a8b36093136ca"}, + {file = "bitarray-2.9.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90a9300cdb7c99b1e692bb790cba8acecee1a345a83e58e28c94a0d87c522237"}, + {file = "bitarray-2.9.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1211ed66acbbb221fd7554abf4206a384d79e6192d5cb95325c5c361bbb52a74"}, + {file = "bitarray-2.9.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67757279386accf93eba76b8f97b5acf1664a3e350cbea5f300f53490f8764fd"}, + {file = "bitarray-2.9.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64e19c6a99c32f460c2613f797f77aa37d8e298891d00ea5355158cce80e11ec"}, + {file = "bitarray-2.9.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72734bd3775f43c5a75385730abb9f84fee6c627eb14f579de4be478f1615c8c"}, + {file = "bitarray-2.9.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a92703471b5d3316c7481bc1852f620f42f7a1b62be27f39d13694827635786f"}, + {file = "bitarray-2.9.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d5d77c81300ca430d4b195ccfbb629d6858258f541b6e96c6b11ec1563cd2681"}, + {file = "bitarray-2.9.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3ba8a29c0d091c952ced1607ce715f5e0524899f24333a493807d00f5938463d"}, + {file = "bitarray-2.9.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:418171d035b191dbe5e86cd2bfb5c3e1ae7d947edc22857a897d1c7251674ae5"}, + {file = "bitarray-2.9.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e0bd272eba256183be2a17488f9cb096d2e6d3435ecf2e28c1e0857c6d20749"}, + {file = "bitarray-2.9.3-cp311-cp311-win32.whl", hash = "sha256:cc3fd2b0637a619cf13e122bbcf4729ae214d5f25623675597e67c25f9edfe61"}, + {file = "bitarray-2.9.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1fc2a81a585dbe5e367682156e6350d908a56e2ffd6ca651b0af01994db596f"}, + {file = "bitarray-2.9.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:dc47be026f76f1728af00dc7140cec8483fe2f0c476bbf2a59ef47865e00ff96"}, + {file = "bitarray-2.9.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:82b091742ff511cdb06f90af0d2c22e7af3dbff9b8212e2e0d88dfef6a8570b3"}, + {file = "bitarray-2.9.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d5edb4302a0e3a3d1d0eeb891de3c615d4cb7a446fb41c21eecdcfb29400a6f"}, + {file = "bitarray-2.9.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb4786c5525069c19820549dd2f42d33632bc42959ad167138bd8ee5024b922b"}, + {file = "bitarray-2.9.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bfe2de2b4df61ccb9244871a0fdf1fff83be0c1bd7187048c3cf7f81c5fe631"}, + {file = "bitarray-2.9.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31e4f69538f95d2934587d957eea0d283162322dd1af29e57122b20b8cd60f92"}, + {file = "bitarray-2.9.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8ca44908b2bc08d8995770018638d62626706864f9c599b7818225a12f3dbc2c"}, + {file = "bitarray-2.9.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:279f8de5d251ee521e365df29c927d9b5732f1ed4f373d2dbbd278fcbad94ff5"}, + {file = "bitarray-2.9.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49bb631b38431c09ecd534d56ef04264397d24d18c4ee6653c84e14ae09d92d"}, + {file = "bitarray-2.9.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:192bffc93ee9a5b6c833c98d1dcc81f5633ddd726b85e18341387d0c1d51f691"}, + {file = "bitarray-2.9.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c516cec28c6511df51d87033f40ec420324a2247469b0c989d344f4d27ea37d2"}, + {file = "bitarray-2.9.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:66241cb9a1c1db294f46cd440141e57e8242874e38f3f61877f72d92ae14768a"}, + {file = "bitarray-2.9.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ab1f0e7631110c89bea7b605c0c35832333eb9cc97e5de05d71c76d42a1858c9"}, + {file = "bitarray-2.9.3-cp312-cp312-win32.whl", hash = "sha256:42aa5bee6fe8ad3385eaf5c6585016bbc38a7b75efb52ce5c6f8e00e05237dfa"}, + {file = "bitarray-2.9.3-cp312-cp312-win_amd64.whl", hash = "sha256:dc3fd647d845b94fac3652390866f921f914a17f3807a031c826f68dae3f43e3"}, + {file = "bitarray-2.9.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fcfcc1989e3e021a282624017b7fb754210f5332e933b1c3ebc79643727b6551"}, + {file = "bitarray-2.9.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:71b1e229a706798a9e106ca7b03d4c63455deb40b18c92950ec073a05a8f8285"}, + {file = "bitarray-2.9.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bb49556d3d505d24c942a4206ad4d0d40e89fa3016a7ea6edc994d5c08d4a8e"}, + {file = "bitarray-2.9.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4466aa1e533a59d5f7fd37219d154ec3f2ba73fce3d8a2e11080ec475bc15fb"}, + {file = "bitarray-2.9.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9b75adc0fd0bf278bea89dc3d679d74e10d2df98d3d074b7f3d36f323138818"}, + {file = "bitarray-2.9.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701582bbbeac372b1cd8a3c9daf6c2336dc2d22e14373a6271d788bc4f2b6edc"}, + {file = "bitarray-2.9.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ea1f119668bbdbd68008031491515e84441e505163918819994b28f295f762c"}, + {file = "bitarray-2.9.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f400bc18a70bfdb073532c3054ecd78a0e64f96ff7b6140adde5b122580ec2b"}, + {file = "bitarray-2.9.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:aacff5656fb3e15cede7d02903da2634d376aa928d7a81ec8df19b0724d7972a"}, + {file = "bitarray-2.9.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8a2ae42a14cbf766d4478d7101da6359b0648dd813e60eb3486ac56ad2f5add3"}, + {file = "bitarray-2.9.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:616698edb547d10f0b960cb9f2e8629c55a420dd4c2b1ab46706f49a1815621d"}, + {file = "bitarray-2.9.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f277c50ba184929dfeed39b6cf9468e3446093521b0aeb52bd54a21ca08f5473"}, + {file = "bitarray-2.9.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:661237739b385c90d8837d5e96b06de093cc6e610236977e198f88f5a979686e"}, + {file = "bitarray-2.9.3-cp313-cp313-win32.whl", hash = "sha256:68acec6c19d798051f178a1197b76f891985f683f95a4b12811b68e58b080f5a"}, + {file = "bitarray-2.9.3-cp313-cp313-win_amd64.whl", hash = "sha256:3055720afdcfd7e8f630fa16db7bed7e55c9d0a1f4756195e3b250e203f3b436"}, + {file = "bitarray-2.9.3-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:72bf17d0e7d8a4f645655a07999d23e42472cbf2100b8dad7ce26586075241d7"}, + {file = "bitarray-2.9.3-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cfd332b5f1ad8c4dc3cc79ecef33c19b42d8d8e6a39fd5c9ecb5855be0b9723"}, + {file = "bitarray-2.9.3-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5b466ef1e48f25621c9d27e95deb5e33b8656827ed8aa530b972de73870bd1f"}, + {file = "bitarray-2.9.3-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:938cf26fdaf4d0adfac82d830c025523c5d36ddead0470b735286028231c1784"}, + {file = "bitarray-2.9.3-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0f766669e768ef9a2b23ecfa710b38b6a48da3f91755113c79320b207ae255d"}, + {file = "bitarray-2.9.3-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5b6337c0c64044f35ddfb241143244aac707a68f34ae31a71dad115f773ccc8b"}, + {file = "bitarray-2.9.3-cp36-cp36m-musllinux_1_2_aarch64.whl", hash = "sha256:731b59540167f8b2b20f69f487ecee2339fc4657059906a16cb51acac17f89c3"}, + {file = "bitarray-2.9.3-cp36-cp36m-musllinux_1_2_i686.whl", hash = "sha256:4feed0539a9d6432361fc4d3820eea3a81fa631d542f166cf8430aad81a971da"}, + {file = "bitarray-2.9.3-cp36-cp36m-musllinux_1_2_ppc64le.whl", hash = "sha256:eb65c96a42e73f35175ec738d67992ffdf054c20abee3933cfcfa2343fa1187d"}, + {file = "bitarray-2.9.3-cp36-cp36m-musllinux_1_2_s390x.whl", hash = "sha256:4f40ceac94d182de6135759d81289683ff3e4cf0da709bc5826a7fe00d754114"}, + {file = "bitarray-2.9.3-cp36-cp36m-musllinux_1_2_x86_64.whl", hash = "sha256:5b29f7844080a281635a231a37e99f0bd6f567af6cf19f4f6d212137f99a9cdf"}, + {file = "bitarray-2.9.3-cp36-cp36m-win32.whl", hash = "sha256:947cf522a3b339b73114d12417fd848fa01303dbaa7883ced4c87688dba5637c"}, + {file = "bitarray-2.9.3-cp36-cp36m-win_amd64.whl", hash = "sha256:ea794ea60d514d68777a87a74106110db7a4bbc2c46720e67010e3071afefb95"}, + {file = "bitarray-2.9.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c7bc7cb79dcac8bdce23b305e671c06eaeffb012fa065b8c33bc51df7e1733f0"}, + {file = "bitarray-2.9.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6d6380ad0f929ad9220abadd1c9b7234271c4b6ea9c753a88611d489e93a8f2e"}, + {file = "bitarray-2.9.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:05f4e2451e2ad450b41ede8440e52c1fd798e81027e1dc2256292ec0787d3bf1"}, + {file = "bitarray-2.9.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7267885c98138f3707c710d5b08eedef150a3e5112c760cfe1200f3366fd7064"}, + {file = "bitarray-2.9.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:976957423cb41df8fe0eb811dbb53d8c5ab1ca3beec7a3ca7ff679be44a72714"}, + {file = "bitarray-2.9.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0ec5141a69f73ed6ff17ea7344d5cc166e087095bfe3661dbb42b519e76aa16"}, + {file = "bitarray-2.9.3-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:218a1b7c0652a3c1020f903ded0f9768c3719fb6d43a6e9d346e985292992d35"}, + {file = "bitarray-2.9.3-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:cf0c9ebf2df280794244e1e12ed626357506ddaa2f0d6f69efe493ae7bbf4bf7"}, + {file = "bitarray-2.9.3-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:c450a04a7e091b57d4c0bd1531648522cd0ef26913ad0e5dea0432ea29b0e5c1"}, + {file = "bitarray-2.9.3-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:a212eb89a50e32ef4969387e44a7410447dc59587615e3966d090edc338a1b85"}, + {file = "bitarray-2.9.3-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:4269232026212ee6b73379b88a578107a6b36a6182307a49d5509686c7495261"}, + {file = "bitarray-2.9.3-cp37-cp37m-win32.whl", hash = "sha256:8a0fb358e6a43f216c3fb0871e2ac14c16563aec363c23bc2fbbb18f6201285d"}, + {file = "bitarray-2.9.3-cp37-cp37m-win_amd64.whl", hash = "sha256:a8368774cdc737eec8fce6f28d0abc095fbc0edccf8fab8d29fddc264b68def9"}, + {file = "bitarray-2.9.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:7d0724a4fef6ded914075a3385ea2d05afdeed567902f83490ed4e7e7e75d9bf"}, + {file = "bitarray-2.9.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0e11b37c6dff6f41ebc49914628824ceb8c8d6ebd0fda2ebe3c0fe0c63e8621e"}, + {file = "bitarray-2.9.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:085f4081d72c7468f82f722a9f113e03a1f7a4c132ef4c2a4e680c5d78b7db00"}, + {file = "bitarray-2.9.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b530b5fbed2900634fbc43f546e384abd72ad9c49795ff5bd6a93cac1aa9c4d8"}, + {file = "bitarray-2.9.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09ff88e4385967571146fb0d270442de39393d44198f4d108f3350cfd6486f0b"}, + {file = "bitarray-2.9.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a344bb212ddf87db4976a6711d274660a5d887da4fd3faafcdaa092152f85a6d"}, + {file = "bitarray-2.9.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc569c96b990f92fd5946d5b50501fee48b01a116a286d1de7961ebd9c6f06f3"}, + {file = "bitarray-2.9.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2fbbe7938ef8a7abe3e8519fa0578b51d2787f7171d3144e7d373551b5851fd"}, + {file = "bitarray-2.9.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0b5912fab904507b47217509b01aa903d7f98b6e725e490a7f01661f4d9a4fa7"}, + {file = "bitarray-2.9.3-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:0c836ccfca9cf60927256738ef234dfe500565492eff269610cdd1bca56801d0"}, + {file = "bitarray-2.9.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:af0e4441ebf51c18fc450962f1e201c96f444d63b17cc8dcf7c0b05111bd4486"}, + {file = "bitarray-2.9.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:9e9b57175fb6fe76d7ddd0647e06a25f6e23f4b54b5febf337c5a840ab37dc3b"}, + {file = "bitarray-2.9.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:7f7de81721ae9492926bd067007ac974692182bb83fc8f0ba330a67f37a018bd"}, + {file = "bitarray-2.9.3-cp38-cp38-win32.whl", hash = "sha256:4beafb6b6e344385480df6611fdebfcb3579bbb40636ce1ddf5e72fb744e095f"}, + {file = "bitarray-2.9.3-cp38-cp38-win_amd64.whl", hash = "sha256:d8eaeca98900bd6f06a29cdef57999813a67d314f661d14901d71e04f4cf9f00"}, + {file = "bitarray-2.9.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:413965d9d384aef90e58b959f4a39f1d5060b145c26080297b7b4cf23cf38faa"}, + {file = "bitarray-2.9.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2fbb56f2bb89c3a15304a6c0ea56013dc340a98337d9bbd7fc5c21451dc05f8c"}, + {file = "bitarray-2.9.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b8a84f39f7885627711473872d8fc58fc7a0a1e4ecd9ddf42daf9a3643432742"}, + {file = "bitarray-2.9.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45147a9c8580e857c1344d15bd49d2b4387777bd582a2ede11be2ba740653f28"}, + {file = "bitarray-2.9.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed255423dc60c6b2d5c0d90c13dea2962a31929767fdf1c525ab3210269e75c5"}, + {file = "bitarray-2.9.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4f5bd02671ea5c4ad52bbfe0e8e8197b6e8fa85dec1e93a4a05448c19354cc65"}, + {file = "bitarray-2.9.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1c99c58f044549c93fb6d4cda22678deccaed19845eaa2e6917b5b7ca058f2d"}, + {file = "bitarray-2.9.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:921ee87681e32e17d1849e11c96eb6a8a7edaa1269dd26831013daf8546bde05"}, + {file = "bitarray-2.9.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ed97d8ec40c4658d9f9aa8f26cb473f44fa1dbccba3fa3fbe4a102e38c6a8d7"}, + {file = "bitarray-2.9.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:9d7f7db37edb9c50c9aad6a18f2e87dd7dc5ff2a33406821804a03263fedb2ca"}, + {file = "bitarray-2.9.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:292f726cdb9efc744ed0a1d7453c44151526648148a28d9a2495cc7c7b2c62a8"}, + {file = "bitarray-2.9.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2cc94784238782a9376f307b1aa9a85ce77b6eded9f82d2fe062db7fdb02c645"}, + {file = "bitarray-2.9.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5051436b1d318f6ce0df3b2f8a60bfa66a54c1d9e8719d6cb6b448140e7061f2"}, + {file = "bitarray-2.9.3-cp39-cp39-win32.whl", hash = "sha256:a3d436c686ce59fd0b93438ed2c0e1d3e1716e56bce64b874d05b9f49f1ca5d1"}, + {file = "bitarray-2.9.3-cp39-cp39-win_amd64.whl", hash = "sha256:f168fc45664266a560f2cb28a327041b7f69d4a7faad8ab89e0a1dd7c270a70d"}, + {file = "bitarray-2.9.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ae36787299cff41f212aee33cfe1defee13979a41552665a412b6ca3fa8f7eb8"}, + {file = "bitarray-2.9.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42afe48abb8eeb386d93e7f1165ace1dd027f136a8a31edd2b20bc57a0c071d7"}, + {file = "bitarray-2.9.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451ceecdb86bb95ae101b0d65c8c4524d692ae3666662fef8c89877ce17748c5"}, + {file = "bitarray-2.9.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4d67d3e3de2aede737b12cd75a84963700c941b77b579c14bd05517e05d7a9f"}, + {file = "bitarray-2.9.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2406d13ded84049b4238815a5821e44d6f58ba00fbb6b705b6ef8ccd88be8f03"}, + {file = "bitarray-2.9.3-pp37-pypy37_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0db944fc2a048020fc940841ef46c0295b045d45a5a582cba69f78962a49a384"}, + {file = "bitarray-2.9.3-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25c603f141171a7d108773d5136d14e572c473e4cdb3fb464c39c8a138522eb2"}, + {file = "bitarray-2.9.3-pp37-pypy37_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86c06b02705305cab0914d209caa24effda81316e2f2555a71a9aa399b75c5a5"}, + {file = "bitarray-2.9.3-pp37-pypy37_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ddda45b24a802eaaca8f794e6267ff2b62de5fe7b900b76d6f662d95192bebf"}, + {file = "bitarray-2.9.3-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:81490623950d04870c6dd4d7e6df2eb68dd04eca8bec327895ebee8bbe0cc3c7"}, + {file = "bitarray-2.9.3-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a9e69ac6a514cc574891c24a50847022dac2fef8c3f4df530f92820a07337755"}, + {file = "bitarray-2.9.3-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:545c695ee69d26b41351ced4c76244d8b6225669fc0af3652ff8ed5a6b28325d"}, + {file = "bitarray-2.9.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fbb2e6daabd2a64d091ac7460b0c5c5f9268199ae9a8ce32737cf5273987f1fa"}, + {file = "bitarray-2.9.3-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a969e5cf63144b944ee8d0a0739f53ef1ae54725b5e01258d690a8995d880526"}, + {file = "bitarray-2.9.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:73bbb9301ac9000f869c51db2cc5fcc6541985d3fcdcfe6e02f90c9e672a00be"}, + {file = "bitarray-2.9.3-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c07e346926488a85a48542d898f4168f3587ec42379fef0d18be301e08a3f27"}, + {file = "bitarray-2.9.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a26d8a14cd8ee496306f2afac34833502dd1ae826355af309333b6f252b23fe"}, + {file = "bitarray-2.9.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cef148ed37c892395ca182d6a235524165a9f765f4283d0a1ced891e7c43c67a"}, + {file = "bitarray-2.9.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:94f35a8f0c8a50ee98a8bef9a070d0b68ecf623f20a2148cc039aba5557346a6"}, + {file = "bitarray-2.9.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:b03207460daae828e2743874c84264e8d96a8c6156490279092b624cd5d2de08"}, + {file = "bitarray-2.9.3.tar.gz", hash = "sha256:9eff55cf189b0c37ba97156a00d640eb7392db58a8049be6f26ff2712b93fa89"}, +] + [[package]] name = "brotli" version = "1.2.0" @@ -506,6 +654,11 @@ python-versions = ">=3.8" groups = ["main"] markers = "platform_python_implementation != \"CPython\"" files = [ + {file = "brotlicffi-1.2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b13fb476a96f02e477a506423cb5e7bc21e0e3ac4c060c20ba31c44056e38c68"}, + {file = "brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17db36fb581f7b951635cd6849553a95c6f2f53c1a707817d06eae5aeff5f6af"}, + {file = "brotlicffi-1.2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40190192790489a7b054312163d0ce82b07d1b6e706251036898ce1684ef12e9"}, + {file = "brotlicffi-1.2.0.0-cp314-cp314t-win32.whl", hash = "sha256:a8079e8ecc32ecef728036a1d9b7105991ce6a5385cf51ee8c02297c90fb08c2"}, + {file = "brotlicffi-1.2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ca90c4266704ca0a94de8f101b4ec029624273380574e4cf19301acfa46c61a0"}, {file = "brotlicffi-1.2.0.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:9458d08a7ccde8e3c0afedbf2c70a8263227a68dea5ab13590593f4c0a4fd5f4"}, {file = "brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84e3d0020cf1bd8b8131f4a07819edee9f283721566fe044a20ec792ca8fd8b7"}, {file = "brotlicffi-1.2.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33cfb408d0cff64cd50bef268c0fed397c46fbb53944aa37264148614a62e990"}, @@ -528,14 +681,14 @@ cffi = [ name = "certifi" version = "2025.11.12" description = "Python package for providing Mozilla's CA Bundle." -optional = false +optional = true python-versions = ">=3.7" -groups = ["docs"] -markers = "python_version >= \"3.11\"" +groups = ["main", "docs"] files = [ {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, ] +markers = {main = "extra == \"hyperliquid\"", docs = "python_version >= \"3.11\""} [[package]] name = "cffi" @@ -638,10 +791,9 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} name = "charset-normalizer" version = "3.4.4" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false +optional = true python-versions = ">=3.7" -groups = ["docs"] -markers = "python_version >= \"3.11\"" +groups = ["main", "docs"] files = [ {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, @@ -757,6 +909,7 @@ files = [ {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, ] +markers = {main = "extra == \"hyperliquid\"", docs = "python_version >= \"3.11\""} [[package]] name = "colorama" @@ -879,6 +1032,197 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli ; python_full_version <= \"3.11.0a6\""] +[[package]] +name = "cytoolz" +version = "1.1.0" +description = "Cython implementation of Toolz: High performance functional utilities" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"hyperliquid\" and implementation_name == \"cpython\"" +files = [ + {file = "cytoolz-1.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:72d7043a88ea5e61ba9d17ea0d1c1eff10f645d7edfcc4e56a31ef78be287644"}, + {file = "cytoolz-1.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d759e9ed421bacfeb456d47af8d734c057b9912b5f2441f95b27ca35e5efab07"}, + {file = "cytoolz-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fdb5be8fbcc0396141189022724155a4c1c93712ac4aef8c03829af0c2a816d7"}, + {file = "cytoolz-1.1.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c8c0a513dc89bc05cc72893609118815bced5ef201f1a317b4cc3423b3a0e750"}, + {file = "cytoolz-1.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ce94db4f8ebe842c30c0ece42ff5de977c47859088c2c363dede5a68f6906484"}, + {file = "cytoolz-1.1.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b622d4f54e370c853ded94a668f94fe72c6d70e06ac102f17a2746661c27ab52"}, + {file = "cytoolz-1.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:375a65baa5a5b4ff6a0c5ff17e170cf23312e4c710755771ca966144c24216b5"}, + {file = "cytoolz-1.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c0d51bcdb3203a062a78f66bbe33db5e3123048e24a5f0e1402422d79df8ee2d"}, + {file = "cytoolz-1.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1010869529bb05dc9802b6d776a34ca1b6d48b9deec70ad5e2918ae175be5c2f"}, + {file = "cytoolz-1.1.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a8f2e83295bdb33f35454d6bafcb7845b03b5881dcaed66ecbd726c7f16772"}, + {file = "cytoolz-1.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0499c5e0a8e688ed367a2e51cc13792ae8f08226c15f7d168589fc44b9b9cada"}, + {file = "cytoolz-1.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87d44e6033d4c5e95a7d39ba59b8e105ba1c29b1ccd1d215f26477cc1d64be39"}, + {file = "cytoolz-1.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a68cef396a7de237f7b97422a6a450dfb111722296ba217ba5b34551832f1f6e"}, + {file = "cytoolz-1.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:06ad4c95b258141f138a93ebfdc1d76ac087afc1a82f1401100a1f44b44ba656"}, + {file = "cytoolz-1.1.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ada59a4b3c59d4ac7162e0ed08667ffa78abf48e975c8a9f9d5b9bc50720f4fd"}, + {file = "cytoolz-1.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:a8957bcaea1ba01327a9b219d2adb84144377684f51444253890dab500ca171f"}, + {file = "cytoolz-1.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6d8cdc299d67eb0f3b9ecdafeeb55eb3b7b7470e2d950ac34b05ed4c7a5572b8"}, + {file = "cytoolz-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d8e08464c5cdea4f6df31e84b11ed6bfd79cedb99fbcbfdc15eb9361a6053c5a"}, + {file = "cytoolz-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:7e49922a7ed54262d41960bf3b835a7700327bf79cff1e9bfc73d79021132ff8"}, + {file = "cytoolz-1.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:943a662d2e72ffc4438d43ab5a1de8d852237775a423236594a3b3e381b8032c"}, + {file = "cytoolz-1.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dba8e5a8c6e3c789d27b0eb5e7ce5ed7d032a7a9aae17ca4ba5147b871f6e327"}, + {file = "cytoolz-1.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:44b31c05addb0889167a720123b3b497b28dd86f8a0aeaf3ae4ffa11e2c85d55"}, + {file = "cytoolz-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:653cb18c4fc5d8a8cfce2bce650aabcbe82957cd0536827367d10810566d5294"}, + {file = "cytoolz-1.1.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:853a5b4806915020c890e1ce70cc056bbc1dd8bc44f2d74d555cccfd7aefba7d"}, + {file = "cytoolz-1.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7b44e9de86bea013fe84fd8c399d6016bbb96c37c5290769e5c99460b9c53e5"}, + {file = "cytoolz-1.1.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:098d628a801dc142e9740126be5624eb7aef1d732bc7a5719f60a2095547b485"}, + {file = "cytoolz-1.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:779ee4096ed7a82cffab89372ffc339631c285079dbf33dbe7aff1f6174985df"}, + {file = "cytoolz-1.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f2ce18dd99533d077e9712f9faa852f389f560351b1efd2f2bdb193a95eddde2"}, + {file = "cytoolz-1.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac266a34437812cf841cecbfe19f355ab9c3dd1ef231afc60415d40ff12a76e4"}, + {file = "cytoolz-1.1.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1920b9b9c13d60d0bb6cd14594b3bce0870022eccb430618c37156da5f2b7a55"}, + {file = "cytoolz-1.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47caa376dafd2bdc29f8a250acf59c810ec9105cd6f7680b9a9d070aae8490ec"}, + {file = "cytoolz-1.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5ab2c97d8aaa522b038cca9187b1153347af22309e7c998b14750c6fdec7b1cb"}, + {file = "cytoolz-1.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4bce006121b120e8b359244ee140bb0b1093908efc8b739db8dbaa3f8fb42139"}, + {file = "cytoolz-1.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7fc0f1e4e9bb384d26e73c6657bbc26abdae4ff66a95933c00f3d578be89181b"}, + {file = "cytoolz-1.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd3f894ff972da1994d06ac6157d74e40dda19eb31fe5e9b7863ca4278c3a167"}, + {file = "cytoolz-1.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0846f49cf8a4496bd42659040e68bd0484ce6af819709cae234938e039203ba0"}, + {file = "cytoolz-1.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:16a3af394ade1973226d64bb2f9eb3336adbdea03ed5b134c1bbec5a3b20028e"}, + {file = "cytoolz-1.1.0-cp311-cp311-win32.whl", hash = "sha256:b786c9c8aeab76cc2f76011e986f7321a23a56d985b77d14f155d5e5514ea781"}, + {file = "cytoolz-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:ebf06d1c5344fb22fee71bf664234733e55db72d74988f2ecb7294b05e4db30c"}, + {file = "cytoolz-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:b63f5f025fac893393b186e132e3e242de8ee7265d0cd3f5bdd4dda93f6616c9"}, + {file = "cytoolz-1.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:99f8e134c9be11649342853ec8c90837af4089fc8ff1e8f9a024a57d1fa08514"}, + {file = "cytoolz-1.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0a6f44cf9319c30feb9a50aa513d777ef51efec16f31c404409e7deb8063df64"}, + {file = "cytoolz-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:945580dc158c557172fca899a35a99a16fbcebf6db0c77cb6621084bc82189f9"}, + {file = "cytoolz-1.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:257905ec050d04f2f856854620d1e25556fd735064cebd81b460f54939b9f9d5"}, + {file = "cytoolz-1.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82779049f352fb3ab5e8c993ab45edbb6e02efb1f17f0b50f4972c706cc51d76"}, + {file = "cytoolz-1.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d3e405e435320e08c5a1633afaf285a392e2d9cef35c925d91e2a31dfd7a688"}, + {file = "cytoolz-1.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:923df8f5591e0d20543060c29909c149ab1963a7267037b39eee03a83dbc50a8"}, + {file = "cytoolz-1.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:25db9e4862f22ea0ae2e56c8bec9fc9fd756b655ae13e8c7b5625d7ed1c582d4"}, + {file = "cytoolz-1.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c7a98deb11ccd8e5d9f9441ef2ff3352aab52226a2b7d04756caaa53cd612363"}, + {file = "cytoolz-1.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dce4ee9fc99104bc77efdea80f32ca5a650cd653bcc8a1d984a931153d3d9b58"}, + {file = "cytoolz-1.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80d6da158f7d20c15819701bbda1c041f0944ede2f564f5c739b1bc80a9ffb8b"}, + {file = "cytoolz-1.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3b5c5a192abda123ad45ef716ec9082b4cf7d95e9ada8291c5c2cc5558be858b"}, + {file = "cytoolz-1.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5b399ce7d967b1cb6280250818b786be652aa8ddffd3c0bb5c48c6220d945ab5"}, + {file = "cytoolz-1.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e7e29a1a03f00b4322196cfe8e2c38da9a6c8d573566052c586df83aacc5663c"}, + {file = "cytoolz-1.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5291b117d71652a817ec164e7011f18e6a51f8a352cc9a70ed5b976c51102fda"}, + {file = "cytoolz-1.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8caef62f846a9011676c51bda9189ae394cdd6bb17f2946ecaedc23243268320"}, + {file = "cytoolz-1.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:de425c5a8e3be7bb3a195e19191d28d9eb3c2038046064a92edc4505033ec9cb"}, + {file = "cytoolz-1.1.0-cp312-cp312-win32.whl", hash = "sha256:296440a870e8d1f2e1d1edf98f60f1532b9d3ab8dfbd4b25ec08cd76311e79e5"}, + {file = "cytoolz-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:07156987f224c6dac59aa18fb8bf91e1412f5463961862716a3381bf429c8699"}, + {file = "cytoolz-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:23e616b38f5b3160c7bb45b0f84a8f3deb4bd26b29fb2dfc716f241c738e27b8"}, + {file = "cytoolz-1.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:76c9b58555300be6dde87a41faf1f97966d79b9a678b7a526fcff75d28ef4945"}, + {file = "cytoolz-1.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d1d638b10d3144795655e9395566ce35807df09219fd7cacd9e6acbdef67946a"}, + {file = "cytoolz-1.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:26801c1a165e84786a99e03c9c9973356caaca002d66727b761fb1042878ef06"}, + {file = "cytoolz-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2a9a464542912d3272f6dccc5142df057c71c6a5cbd30439389a732df401afb7"}, + {file = "cytoolz-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed6104fa942aa5784bf54f339563de637557e3443b105760bc4de8f16a7fc79b"}, + {file = "cytoolz-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56161f0ab60dc4159ec343509abaf809dc88e85c7e420e354442c62e3e7cbb77"}, + {file = "cytoolz-1.1.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:832bd36cc9123535f1945acf6921f8a2a15acc19cfe4065b1c9b985a28671886"}, + {file = "cytoolz-1.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1842636b6e034f229bf084c2bcdcfd36c8437e752eefd2c74ce9e2f10415cb6e"}, + {file = "cytoolz-1.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:823df012ab90d2f2a0f92fea453528539bf71ac1879e518524cd0c86aa6df7b9"}, + {file = "cytoolz-1.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f1fcf9e7e7b3487883ff3f815abc35b89dcc45c4cf81c72b7ee457aa72d197b"}, + {file = "cytoolz-1.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4cdb3fa1772116827f263f25b0cdd44c663b6701346a56411960534a06c082de"}, + {file = "cytoolz-1.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1b5c95041741b81430454db65183e133976f45ac3c03454cfa8147952568529"}, + {file = "cytoolz-1.1.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b2079fd9f1a65f4c61e6278c8a6d4f85edf30c606df8d5b32f1add88cbbe2286"}, + {file = "cytoolz-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a92a320d72bef1c7e2d4c6d875125cf57fc38be45feb3fac1bfa64ea401f54a4"}, + {file = "cytoolz-1.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06d1c79aa51e6a92a90b0e456ebce2288f03dd6a76c7f582bfaa3eda7692e8a5"}, + {file = "cytoolz-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e1d7be25f6971e986a52b6d3a0da28e1941850985417c35528f6823aef2cfec5"}, + {file = "cytoolz-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:964b248edc31efc50a65e9eaa0c845718503823439d2fa5f8d2c7e974c2b5409"}, + {file = "cytoolz-1.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c9ff2b3c57c79b65cb5be14a18c6fd4a06d5036fb3f33e973a9f70e9ac13ca28"}, + {file = "cytoolz-1.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:22290b73086af600042d99f5ce52a43d4ad9872c382610413176e19fc1d4fd2d"}, + {file = "cytoolz-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a2ade74fccd080ea793382968913ee38d7a35c921df435bbf0a6aeecf0d17574"}, + {file = "cytoolz-1.1.0-cp313-cp313-win32.whl", hash = "sha256:db5dbcfda1c00e937426cbf9bdc63c24ebbc358c3263bfcbc1ab4a88dc52aa8e"}, + {file = "cytoolz-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9e2d3fe3b45c3eb7233746f7aca37789be3dceec3e07dcc406d3e045ea0f7bdc"}, + {file = "cytoolz-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:32c559f95ff44a9ebcbd934acaa1e6dc8f3e6ffce4762a79a88528064873d6d5"}, + {file = "cytoolz-1.1.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9e2cd93b28f667c5870a070ab2b8bb4397470a85c4b204f2454b0ad001cd1ca3"}, + {file = "cytoolz-1.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f494124e141a9361f31d79875fe7ea459a3be2b9dadd90480427c0c52a0943d4"}, + {file = "cytoolz-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:53a3262bf221f19437ed544bf8c0e1980c81ac8e2a53d87a9bc075dba943d36f"}, + {file = "cytoolz-1.1.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:47663e57d3f3f124921f38055e86a1022d0844c444ede2e8f090d3bbf80deb65"}, + {file = "cytoolz-1.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5a8755c4104ee4e3d5ba434c543b5f85fdee6a1f1df33d93f518294da793a60"}, + {file = "cytoolz-1.1.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4d96ff3d381423af1b105295f97de86d1db51732c9566eb37378bab6670c5010"}, + {file = "cytoolz-1.1.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0ec96b3d537cdf47d4e76ded199f7440715f4c71029b45445cff92c1248808c2"}, + {file = "cytoolz-1.1.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:208e2f2ef90a32b0acbff3303d90d89b13570a228d491d2e622a7883a3c68148"}, + {file = "cytoolz-1.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d416a81bb0bd517558668e49d30a7475b5445f9bbafaab7dcf066f1e9adba36"}, + {file = "cytoolz-1.1.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f32e94c91ffe49af04835ee713ebd8e005c85ebe83e7e1fdcc00f27164c2d636"}, + {file = "cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15d0c6405efc040499c46df44056a5c382f551a7624a41cf3e4c84a96b988a15"}, + {file = "cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:bf069c5381d757debae891401b88b3a346ba3a28ca45ba9251103b282463fad8"}, + {file = "cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d5cf15892e63411ec1bd67deff0e84317d974e6ab2cdfefdd4a7cea2989df66"}, + {file = "cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3e3872c21170f8341656f8692f8939e8800dcee6549ad2474d4c817bdefd62cd"}, + {file = "cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b9ddeff8e8fd65eb1fcefa61018100b2b627e759ea6ad275d2e2a93ffac147bf"}, + {file = "cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:02feeeda93e1fa3b33414eb57c2b0aefd1db8f558dd33fdfcce664a0f86056e4"}, + {file = "cytoolz-1.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d08154ad45349162b6c37f12d5d1b2e6eef338e657b85e1621e4e6a4a69d64cb"}, + {file = "cytoolz-1.1.0-cp313-cp313t-win32.whl", hash = "sha256:10ae4718a056948d73ca3e1bb9ab1f95f897ec1e362f829b9d37cc29ab566c60"}, + {file = "cytoolz-1.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:1bb77bc6197e5cb19784b6a42bb0f8427e81737a630d9d7dda62ed31733f9e6c"}, + {file = "cytoolz-1.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:563dda652c6ff52d215704fbe6b491879b78d7bbbb3a9524ec8e763483cb459f"}, + {file = "cytoolz-1.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d542cee7c7882d2a914a33dec4d3600416fb336734df979473249d4c53d207a1"}, + {file = "cytoolz-1.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31922849b701b0f24bb62e56eb2488dcd3aa6ae3057694bd6b3b7c4c2bc27c2f"}, + {file = "cytoolz-1.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e68308d32afd31943314735c1335e4ab5696110e96b405f6bdb8f2a8dc771a16"}, + {file = "cytoolz-1.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fc4bb48b3b866e1867f7c6411a4229e5b44be3989060663713e10efc24c9bd5f"}, + {file = "cytoolz-1.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:456f77207d1445025d7ef262b8370a05492dcb1490cb428b0f3bf1bd744a89b0"}, + {file = "cytoolz-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:174ebc71ebb20a9baeffce6ee07ee2cd913754325c93f99d767380d8317930f7"}, + {file = "cytoolz-1.1.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8b3604fef602bcd53415055a4f68468339192fd17be39e687ae24f476d23d56e"}, + {file = "cytoolz-1.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3604b959a01f64c366e7d10ec7634d5f5cfe10301e27a8f090f6eb3b2a628a18"}, + {file = "cytoolz-1.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6db2127a3c1bc2f59f08010d2ae53a760771a9de2f67423ad8d400e9ba4276e8"}, + {file = "cytoolz-1.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56584745ac647993a016a21bc76399113b7595e312f8d0a1b140c9fcf9b58a27"}, + {file = "cytoolz-1.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db2c4c3a7f7bd7e03bb1a236a125c8feb86c75802f4ecda6ecfaf946610b2930"}, + {file = "cytoolz-1.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:48cb8a692111a285d2b9acd16d185428176bfbffa8a7c274308525fccd01dd42"}, + {file = "cytoolz-1.1.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d2f344ba5eb17dcf38ee37fdde726f69053f54927db8f8a1bed6ac61e5b1890d"}, + {file = "cytoolz-1.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abf76b1c1abd031f098f293b6d90ee08bdaa45f8b5678430e331d991b82684b1"}, + {file = "cytoolz-1.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ddf9a38a5b686091265ff45b53d142e44a538cd6c2e70610d3bc6be094219032"}, + {file = "cytoolz-1.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:946786755274f07bb2be0400f28adb31d7d85a7c7001873c0a8e24a503428fb3"}, + {file = "cytoolz-1.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d5b8f78b9fed79cf185ad4ddec099abeef45951bdcb416c5835ba05f0a1242c7"}, + {file = "cytoolz-1.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fccde6efefdbc02e676ccb352a2ccc8a8e929f59a1c6d3d60bb78e923a49ca44"}, + {file = "cytoolz-1.1.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:717b7775313da5f51b0fbf50d865aa9c39cb241bd4cb605df3cf2246d6567397"}, + {file = "cytoolz-1.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5158744a09d0e0e4a4f82225e3a3c4ebf38f9ae74467aaa905467270e52f2794"}, + {file = "cytoolz-1.1.0-cp314-cp314-win32.whl", hash = "sha256:1ed534bdbbf063b2bb28fca7d0f6723a3e5a72b086e7c7fe6d74ae8c3e4d00e2"}, + {file = "cytoolz-1.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:472c1c9a085f5ad973ec0ad7f0b9ba0969faea6f96c9e397f6293d386f3a25ec"}, + {file = "cytoolz-1.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:a7ad7ca3386fa86bd301be3fa36e7f0acb024f412f665937955acfc8eb42deff"}, + {file = "cytoolz-1.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:64b63ed4b71b1ba813300ad0f06b8aff19a12cf51116e0e4f1ed837cea4debcf"}, + {file = "cytoolz-1.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a60ba6f2ed9eb0003a737e1ee1e9fa2258e749da6477946008d4324efa25149f"}, + {file = "cytoolz-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1aa58e2434d732241f7f051e6f17657e969a89971025e24578b5cbc6f1346485"}, + {file = "cytoolz-1.1.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6965af3fc7214645970e312deb9bd35a213a1eaabcfef4f39115e60bf2f76867"}, + {file = "cytoolz-1.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddd2863f321d67527d3b67a93000a378ad6f967056f68c06467fe011278a6d0e"}, + {file = "cytoolz-1.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4e6b428e9eb5126053c2ae0efa62512ff4b38ed3951f4d0888ca7005d63e56f5"}, + {file = "cytoolz-1.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d758e5ef311d2671e0ae8c214c52e44617cf1e58bef8f022b547b9802a5a7f30"}, + {file = "cytoolz-1.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a95416eca473e6c1179b48d86adcf528b59c63ce78f4cb9934f2e413afa9b56b"}, + {file = "cytoolz-1.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36c8ede93525cf11e2cc787b7156e5cecd7340193ef800b816a16f1404a8dc6d"}, + {file = "cytoolz-1.1.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c949755b6d8a649c5fbc888bc30915926f1b09fe42fea9f289e297c2f6ddd3"}, + {file = "cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1b6d37545816905a76d9ed59fa4e332f929e879f062a39ea0f6f620405cdc27"}, + {file = "cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:05332112d4087904842b36954cd1d3fc0e463a2f4a7ef9477bd241427c593c3b"}, + {file = "cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:31538ca2fad2d688cbd962ccc3f1da847329e2258a52940f10a2ac0719e526be"}, + {file = "cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:747562aa70abf219ea16f07d50ac0157db856d447f7f498f592e097cbc77df0b"}, + {file = "cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3dc15c48b20c0f467e15e341e102896c8422dccf8efc6322def5c1b02f074629"}, + {file = "cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3c03137ee6103ba92d5d6ad6a510e86fded69cd67050bd8a1843f15283be17ac"}, + {file = "cytoolz-1.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be8e298d88f88bd172b59912240558be3b7a04959375646e7fd4996401452941"}, + {file = "cytoolz-1.1.0-cp314-cp314t-win32.whl", hash = "sha256:3d407140f5604a89578285d4aac7b18b8eafa055cf776e781aabb89c48738fad"}, + {file = "cytoolz-1.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:56e5afb69eb6e1b3ffc34716ee5f92ffbdb5cb003b3a5ca4d4b0fe700e217162"}, + {file = "cytoolz-1.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:27b19b4a286b3ff52040efa42dbe403730aebe5fdfd2def704eb285e2125c63e"}, + {file = "cytoolz-1.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:08a63935c66488511b7b29b06233be0be5f4123622fc8fd488f28dc1b7e4c164"}, + {file = "cytoolz-1.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:93bd0afcc4cc05794507084afaefb161c3639f283ee629bd0e8654b5c0327ba8"}, + {file = "cytoolz-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8f3d4da470cfd5cf44f6d682c6eb01363066e0af53ebe111225e44a618f9453d"}, + {file = "cytoolz-1.1.0-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba6c12d0e6a67399f4102b4980f4f1bebdbf226ed0a68e84617709d4009b4e71"}, + {file = "cytoolz-1.1.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b557071405b4aeeaa7cbec1a95d15d6c8f37622fe3f4b595311e0e226ce772c"}, + {file = "cytoolz-1.1.0-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cdb406001474726a47fbe903f3aba0de86f5c0b9c9861f55c09c366368225ae0"}, + {file = "cytoolz-1.1.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6072876ba56446d9ac29d349983677d6f44c6d1c6c1c6be44e66e377c57c767"}, + {file = "cytoolz-1.1.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c3784c965c9a6822d315d099c3a85b0884ac648952815891c667b469116f1d0"}, + {file = "cytoolz-1.1.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cc537ad78981df1a827773069fd3b7774f4478db43f518b1616efaf87d7d8f9"}, + {file = "cytoolz-1.1.0-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:574ee9dfdc632db8bf9237f27f2a687d1a0b90d29d5e96cab2b21fd2b419c17d"}, + {file = "cytoolz-1.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6594efbaea72dc58b368b53e745ad902c8d8cc41286f00b3743ceac464d5ef3f"}, + {file = "cytoolz-1.1.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:7c849f9ddaf3c7faba938440f9c849235a2908b303063d49da3092a93acd695b"}, + {file = "cytoolz-1.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1fef0296fb3577d0a08ad9b70344ee418f728f1ec21a768ffe774437d67ac859"}, + {file = "cytoolz-1.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:1dce1e66fdf72cc474367bd7a7f2b90ec67bb8197dc3fe8ecd08f4ce3ab950a1"}, + {file = "cytoolz-1.1.0-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:202fe9975efaec0085cab14a6a6050418bc041f5316f2cf098c0cd2aced4c50e"}, + {file = "cytoolz-1.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:528349434601b9d55e65c6a495494de0001c9a06b431547fea4c60b5edc7d5b3"}, + {file = "cytoolz-1.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3e248cdbf2a54bafdadf4486ddd32e8352f816d3caa2014e44de99f8c525d4a8"}, + {file = "cytoolz-1.1.0-cp39-cp39-win32.whl", hash = "sha256:e63f2b70f4654648a5c6a176ae80897c0de6401f385540dce8e365019e800cfe"}, + {file = "cytoolz-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:f731c53ed29959f105ae622b62e39603c207ed8e8cb2a40cd4accb63d9f92901"}, + {file = "cytoolz-1.1.0-cp39-cp39-win_arm64.whl", hash = "sha256:5a2120bf9e6e8f25e1b32748424a5571e319ef03a995a8fde663fd2feec1a696"}, + {file = "cytoolz-1.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f32e93a55681d782fc6af939f6df36509d65122423cbc930be39b141064adff8"}, + {file = "cytoolz-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5d9bc596751cbda8073e65be02ca11706f00029768fbbbc81e11a8c290bb41aa"}, + {file = "cytoolz-1.1.0-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b16660d01c3931951fab49db422c627897c38c1a1f0393a97582004019a4887"}, + {file = "cytoolz-1.1.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b7de5718e2113d4efccea3f06055758cdbc17388ecc3341ba4d1d812837d7c1a"}, + {file = "cytoolz-1.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a12a2a1a6bc44099491c05a12039efa08cc33a3d0f8c7b0566185e085e139283"}, + {file = "cytoolz-1.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:047defa7f5f9a32f82373dbc3957289562e8a3fa58ae02ec8e4dca4f43a33a21"}, + {file = "cytoolz-1.1.0.tar.gz", hash = "sha256:13a7bf254c3c0d28b12e2290b82aed0f0977a4c2a2bf84854fcdc7796a29f3b0"}, +] + +[package.dependencies] +toolz = ">=0.8.0" + +[package.extras] +cython = ["cython (>=0.29)"] +test = ["pytest"] + [[package]] name = "docutils" version = "0.21.2" @@ -892,6 +1236,199 @@ files = [ {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"}, ] +[[package]] +name = "eth-abi" +version = "3.0.1" +description = "eth_abi: Python utilities for working with Ethereum ABI definitions, especially encoding and decoding" +optional = true +python-versions = ">=3.7, <4" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "eth_abi-3.0.1-py3-none-any.whl", hash = "sha256:63d16f1f60870afc974cb0a3325fb275fa97822be1723b8878598df25eea8096"}, + {file = "eth_abi-3.0.1.tar.gz", hash = "sha256:c3872e3ac1e9ef3f8c6599aaca4ee536d536eefca63a6892ab937f0560edb656"}, +] + +[package.dependencies] +eth-typing = ">=3.0.0,<4.0.0" +eth-utils = ">=2.0.0,<3.0.0" +parsimonious = ">=0.8.0,<0.9.0" + +[package.extras] +dev = ["Sphinx (>=1.6.5,<2)", "black", "bumpversion (>=0.5.3,<1)", "eth-hash[pycryptodome]", "flake8 (==4.0.1)", "hypothesis (>=4.18.2,<5.0.0)", "ipython", "isort (>=4.2.15,<5)", "jinja2 (>=3.0.0,<3.1.0)", "mypy (==0.910)", "pydocstyle (>=6.0.0,<7)", "pytest (>=6.2.5,<7)", "pytest-pythonpath (>=0.7.1)", "pytest-watch (>=4.1.0,<5)", "pytest-xdist (>=2.5.0,<3)", "sphinx-rtd-theme (>=0.1.9)", "towncrier (>=21,<22)", "tox (>=2.9.1,<3)", "twine", "wheel"] +doc = ["Sphinx (>=1.6.5,<2)", "jinja2 (>=3.0.0,<3.1.0)", "sphinx-rtd-theme (>=0.1.9)", "towncrier (>=21,<22)"] +lint = ["black", "flake8 (==4.0.1)", "isort (>=4.2.15,<5)", "mypy (==0.910)", "pydocstyle (>=6.0.0,<7)"] +test = ["eth-hash[pycryptodome]", "hypothesis (>=4.18.2,<5.0.0)", "pytest (>=6.2.5,<7)", "pytest-pythonpath (>=0.7.1)", "pytest-xdist (>=2.5.0,<3)", "tox (>=2.9.1,<3)"] +tools = ["hypothesis (>=4.18.2,<5.0.0)"] + +[[package]] +name = "eth-account" +version = "0.8.0" +description = "eth-account: Sign Ethereum transactions and messages with local private keys" +optional = true +python-versions = ">=3.6, <4" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "eth-account-0.8.0.tar.gz", hash = "sha256:ccb2d90a16c81c8ea4ca4dc76a70b50f1d63cea6aff3c5a5eddedf9e45143eca"}, + {file = "eth_account-0.8.0-py3-none-any.whl", hash = "sha256:0ccc0edbb17021004356ae6e37887528b6e59e6ae6283f3917b9759a5887203b"}, +] + +[package.dependencies] +bitarray = ">=2.4.0,<3" +eth-abi = ">=3.0.1" +eth-keyfile = ">=0.6.0,<0.7.0" +eth-keys = ">=0.4.0,<0.5" +eth-rlp = ">=0.3.0,<1" +eth-utils = ">=2.0.0,<3" +hexbytes = ">=0.1.0,<1" +rlp = ">=1.0.0,<4" + +[package.extras] +dev = ["Sphinx (>=1.6.5,<5)", "black (>=22,<23)", "bumpversion (>=0.5.3,<1)", "coverage", "flake8 (==3.7.9)", "hypothesis (>=4.18.0,<5)", "ipython", "isort (>=4.2.15,<5)", "jinja2 (>=3.0.0,<3.1.0)", "mypy (==0.910)", "pydocstyle (>=5.0.0,<6)", "pytest (>=6.2.5,<7)", "pytest-watch (>=4.1.0,<5)", "pytest-xdist", "sphinx-rtd-theme (>=0.1.9,<1)", "towncrier (>=21,<22)", "tox (==3.25.0)", "twine", "wheel"] +doc = ["Sphinx (>=1.6.5,<5)", "jinja2 (>=3.0.0,<3.1.0)", "sphinx-rtd-theme (>=0.1.9,<1)", "towncrier (>=21,<22)"] +lint = ["black (>=22,<23)", "flake8 (==3.7.9)", "isort (>=4.2.15,<5)", "mypy (==0.910)", "pydocstyle (>=5.0.0,<6)"] +test = ["coverage", "hypothesis (>=4.18.0,<5)", "pytest (>=6.2.5,<7)", "pytest-xdist", "tox (==3.25.0)"] + +[[package]] +name = "eth-hash" +version = "0.7.1" +description = "eth-hash: The Ethereum hashing function, keccak256, sometimes (erroneously) called sha3" +optional = true +python-versions = "<4,>=3.8" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "eth_hash-0.7.1-py3-none-any.whl", hash = "sha256:0fb1add2adf99ef28883fd6228eb447ef519ea72933535ad1a0b28c6f65f868a"}, + {file = "eth_hash-0.7.1.tar.gz", hash = "sha256:d2411a403a0b0a62e8247b4117932d900ffb4c8c64b15f92620547ca5ce46be5"}, +] + +[package.extras] +dev = ["build (>=0.9.0)", "bump_my_version (>=0.19.0)", "ipython", "mypy (==1.10.0)", "pre-commit (>=3.4.0)", "pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)", "sphinx (>=6.0.0)", "sphinx-autobuild (>=2021.3.14)", "sphinx_rtd_theme (>=1.0.0)", "towncrier (>=24,<25)", "tox (>=4.0.0)", "twine", "wheel"] +docs = ["sphinx (>=6.0.0)", "sphinx-autobuild (>=2021.3.14)", "sphinx_rtd_theme (>=1.0.0)", "towncrier (>=24,<25)"] +pycryptodome = ["pycryptodome (>=3.6.6,<4)"] +pysha3 = ["pysha3 (>=1.0.0,<2.0.0) ; python_version < \"3.9\"", "safe-pysha3 (>=1.0.0) ; python_version >= \"3.9\""] +test = ["pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] + +[[package]] +name = "eth-keyfile" +version = "0.6.1" +description = "A library for handling the encrypted keyfiles used to store ethereum private keys." +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "eth-keyfile-0.6.1.tar.gz", hash = "sha256:471be6e5386fce7b22556b3d4bde5558dbce46d2674f00848027cb0a20abdc8c"}, + {file = "eth_keyfile-0.6.1-py3-none-any.whl", hash = "sha256:609773a1ad5956944a33348413cad366ec6986c53357a806528c8f61c4961560"}, +] + +[package.dependencies] +eth-keys = ">=0.4.0,<0.5.0" +eth-utils = ">=2,<3" +pycryptodome = ">=3.6.6,<4" + +[package.extras] +dev = ["bumpversion (>=0.5.3,<1)", "eth-keys (>=0.4.0,<0.5.0)", "eth-utils (>=2,<3)", "flake8 (==4.0.1)", "idna (==2.7)", "pluggy (>=1.0.0,<2)", "pycryptodome (>=3.6.6,<4)", "pytest (>=6.2.5,<7)", "requests (>=2.20,<3)", "setuptools (>=38.6.0)", "tox (>=2.7.0)", "twine", "wheel"] +keyfile = ["eth-keys (>=0.4.0,<0.5.0)", "eth-utils (>=2,<3)", "pycryptodome (>=3.6.6,<4)"] +lint = ["flake8 (==4.0.1)"] +test = ["pytest (>=6.2.5,<7)"] + +[[package]] +name = "eth-keys" +version = "0.4.0" +description = "Common API for Ethereum key operations." +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "eth-keys-0.4.0.tar.gz", hash = "sha256:7d18887483bc9b8a3fdd8e32ddcb30044b9f08fcb24a380d93b6eee3a5bb3216"}, + {file = "eth_keys-0.4.0-py3-none-any.whl", hash = "sha256:e07915ffb91277803a28a379418bdd1fad1f390c38ad9353a0f189789a440d5d"}, +] + +[package.dependencies] +eth-typing = ">=3.0.0,<4" +eth-utils = ">=2.0.0,<3.0.0" + +[package.extras] +coincurve = ["coincurve (>=7.0.0,<16.0.0)"] +dev = ["asn1tools (>=0.146.2,<0.147)", "bumpversion (==0.5.3)", "eth-hash[pycryptodome] ; implementation_name == \"pypy\"", "eth-hash[pysha3] ; implementation_name == \"cpython\"", "eth-typing (>=3.0.0,<4)", "eth-utils (>=2.0.0,<3.0.0)", "factory-boy (>=3.0.1,<3.1)", "flake8 (==3.0.4)", "hypothesis (>=5.10.3,<6.0.0)", "mypy (==0.782)", "pyasn1 (>=0.4.5,<0.5)", "pytest (==6.2.5)", "tox (==3.20.0)", "twine"] +eth-keys = ["eth-typing (>=3.0.0,<4)", "eth-utils (>=2.0.0,<3.0.0)"] +lint = ["flake8 (==3.0.4)", "mypy (==0.782)"] +test = ["asn1tools (>=0.146.2,<0.147)", "eth-hash[pycryptodome] ; implementation_name == \"pypy\"", "eth-hash[pysha3] ; implementation_name == \"cpython\"", "factory-boy (>=3.0.1,<3.1)", "hypothesis (>=5.10.3,<6.0.0)", "pyasn1 (>=0.4.5,<0.5)", "pytest (==6.2.5)"] + +[[package]] +name = "eth-rlp" +version = "0.3.0" +description = "eth-rlp: RLP definitions for common Ethereum objects in Python" +optional = true +python-versions = ">=3.7, <4" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "eth-rlp-0.3.0.tar.gz", hash = "sha256:f3263b548df718855d9a8dbd754473f383c0efc82914b0b849572ce3e06e71a6"}, + {file = "eth_rlp-0.3.0-py3-none-any.whl", hash = "sha256:e88e949a533def85c69fa94224618bbbd6de00061f4cff645c44621dab11cf33"}, +] + +[package.dependencies] +eth-utils = ">=2.0.0,<3" +hexbytes = ">=0.1.0,<1" +rlp = ">=0.6.0,<4" + +[package.extras] +dev = ["Sphinx (>=1.6.5,<2)", "bumpversion (>=0.5.3,<1)", "eth-hash[pycryptodome]", "flake8 (==3.7.9)", "ipython", "isort (>=4.2.15,<5)", "mypy (==0.770)", "pydocstyle (>=3.0.0,<4)", "pytest (>=6.2.5,<7)", "pytest-watch (>=4.1.0,<5)", "pytest-xdist", "sphinx-rtd-theme (>=0.1.9)", "towncrier (>=19.2.0,<20)", "tox (==3.14.6)", "twine", "wheel"] +doc = ["Sphinx (>=1.6.5,<2)", "sphinx-rtd-theme (>=0.1.9)", "towncrier (>=19.2.0,<20)"] +lint = ["flake8 (==3.7.9)", "isort (>=4.2.15,<5)", "mypy (==0.770)", "pydocstyle (>=3.0.0,<4)"] +test = ["eth-hash[pycryptodome]", "pytest (>=6.2.5,<7)", "pytest-xdist", "tox (==3.14.6)"] + +[[package]] +name = "eth-typing" +version = "3.5.2" +description = "eth-typing: Common type annotations for ethereum python packages" +optional = true +python-versions = ">=3.7.2, <4" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "eth-typing-3.5.2.tar.gz", hash = "sha256:22bf051ddfaa35ff827c30090de167e5c5b8cc6d343f7f35c9b1c7553f6ab64d"}, + {file = "eth_typing-3.5.2-py3-none-any.whl", hash = "sha256:1842e628fb1ffa929b94f89a9d33caafbeb9978dc96abb6036a12bc91f1c624b"}, +] + +[package.dependencies] +typing-extensions = ">=4.0.1" + +[package.extras] +dev = ["black (>=23)", "build (>=0.9.0)", "bumpversion (>=0.5.3)", "flake8 (==6.0.0)", "flake8-bugbear (==23.3.23)", "ipython", "isort (>=5.10.1)", "mypy (==0.971)", "pydocstyle (>=6.0.0)", "pytest (>=7.0.0)", "pytest-watch (>=4.1.0)", "pytest-xdist (>=2.4.0)", "sphinx (>=5.0.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)", "tox (>=4.0.0)", "twine", "types-setuptools", "wheel"] +docs = ["sphinx (>=5.0.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)"] +lint = ["black (>=23)", "flake8 (==6.0.0)", "flake8-bugbear (==23.3.23)", "isort (>=5.10.1)", "mypy (==0.971)", "pydocstyle (>=6.0.0)", "types-setuptools"] +test = ["pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] + +[[package]] +name = "eth-utils" +version = "2.3.2" +description = "eth-utils: Common utility functions for python code that interacts with Ethereum" +optional = true +python-versions = "<4,>=3.7" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "eth_utils-2.3.2-py3-none-any.whl", hash = "sha256:4470be372674a25b8440b69cb35bda634a079876930853814ea307248c3d198b"}, + {file = "eth_utils-2.3.2.tar.gz", hash = "sha256:1986d704b29202386c9bc4b27b948a134320c11c8104c45ca367e4663ae7d10e"}, +] + +[package.dependencies] +cytoolz = {version = ">=0.10.1", markers = "implementation_name == \"cpython\""} +eth-hash = ">=0.3.1" +eth-typing = ">=3.0.0" +toolz = {version = ">0.8.2", markers = "implementation_name == \"pypy\""} + +[package.extras] +dev = ["black (>=23)", "build (>=0.9.0)", "bumpversion (>=0.5.3)", "eth-hash[pycryptodome]", "flake8 (==3.8.3)", "hypothesis (>=4.43.0)", "ipython", "isort (>=5.11.0)", "mypy (==0.991)", "pydocstyle (>=5.0.0)", "pytest (>=7.0.0)", "pytest-watch (>=4.1.0)", "pytest-xdist (>=2.4.0)", "sphinx (>=5.0.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)", "tox (>=4.0.0)", "twine", "types-setuptools", "wheel"] +docs = ["sphinx (>=5.0.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)"] +lint = ["black (>=23)", "flake8 (==3.8.3)", "isort (>=5.11.0)", "mypy (==0.991)", "pydocstyle (>=5.0.0)", "types-setuptools"] +test = ["hypothesis (>=4.43.0)", "mypy (==0.991)", "pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)", "types-setuptools"] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -1051,6 +1588,46 @@ files = [ {file = "frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad"}, ] +[[package]] +name = "hexbytes" +version = "0.3.1" +description = "hexbytes: Python `bytes` subclass that decodes hex, with a readable console output" +optional = true +python-versions = ">=3.7, <4" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "hexbytes-0.3.1-py3-none-any.whl", hash = "sha256:383595ad75026cf00abd570f44b368c6cdac0c6becfae5c39ff88829877f8a59"}, + {file = "hexbytes-0.3.1.tar.gz", hash = "sha256:a3fe35c6831ee8fafd048c4c086b986075fc14fd46258fa24ecb8d65745f9a9d"}, +] + +[package.extras] +dev = ["black (>=22)", "bumpversion (>=0.5.3)", "eth-utils (>=1.0.1,<3)", "flake8 (==6.0.0)", "flake8-bugbear (==23.3.23)", "hypothesis (>=3.44.24,<=6.31.6)", "ipython", "isort (>=5.10.1)", "mypy (==0.971)", "pydocstyle (>=5.0.0)", "pytest (>=7.0.0)", "pytest-watch (>=4.1.0)", "pytest-xdist (>=2.4.0)", "sphinx (>=5.0.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)", "tox (>=4.0.0)", "twine", "wheel"] +doc = ["sphinx (>=5.0.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)"] +lint = ["black (>=22)", "flake8 (==6.0.0)", "flake8-bugbear (==23.3.23)", "isort (>=5.10.1)", "mypy (==0.971)", "pydocstyle (>=5.0.0)"] +test = ["eth-utils (>=1.0.1,<3)", "hypothesis (>=3.44.24,<=6.31.6)", "pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] + +[[package]] +name = "hyperliquid-python-sdk" +version = "0.4.0" +description = "SDK for Hyperliquid API trading with Python." +optional = true +python-versions = "<4.0,>=3.7" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "hyperliquid_python_sdk-0.4.0-py3-none-any.whl", hash = "sha256:1ec5c254468c9d7c5afe367b0d979fa38e6baddf6c7f2df5ed5384072963eab1"}, + {file = "hyperliquid_python_sdk-0.4.0.tar.gz", hash = "sha256:cb69f86cf08b2e8dd9864dc2b73ba2afc59588199974dcee0069e6e3e5b9bb60"}, +] + +[package.dependencies] +eth-abi = ">=3.0.1,<4.0.0" +eth-account = ">=0.8.0,<0.9.0" +eth-utils = ">=2.1.0,<3.0.0" +msgpack = ">=1.0.5,<2.0.0" +requests = ">=2.31.0,<3.0.0" +websocket-client = ">=1.5.1,<2.0.0" + [[package]] name = "idna" version = "3.11" @@ -1393,6 +1970,79 @@ files = [ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, ] +[[package]] +name = "msgpack" +version = "1.1.2" +description = "MessagePack serializer" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "msgpack-1.1.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0051fffef5a37ca2cd16978ae4f0aef92f164df86823871b5162812bebecd8e2"}, + {file = "msgpack-1.1.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a605409040f2da88676e9c9e5853b3449ba8011973616189ea5ee55ddbc5bc87"}, + {file = "msgpack-1.1.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b696e83c9f1532b4af884045ba7f3aa741a63b2bc22617293a2c6a7c645f251"}, + {file = "msgpack-1.1.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:365c0bbe981a27d8932da71af63ef86acc59ed5c01ad929e09a0b88c6294e28a"}, + {file = "msgpack-1.1.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:41d1a5d875680166d3ac5c38573896453bbbea7092936d2e107214daf43b1d4f"}, + {file = "msgpack-1.1.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:354e81bcdebaab427c3df4281187edc765d5d76bfb3a7c125af9da7a27e8458f"}, + {file = "msgpack-1.1.2-cp310-cp310-win32.whl", hash = "sha256:e64c8d2f5e5d5fda7b842f55dec6133260ea8f53c4257d64494c534f306bf7a9"}, + {file = "msgpack-1.1.2-cp310-cp310-win_amd64.whl", hash = "sha256:db6192777d943bdaaafb6ba66d44bf65aa0e9c5616fa1d2da9bb08828c6b39aa"}, + {file = "msgpack-1.1.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2e86a607e558d22985d856948c12a3fa7b42efad264dca8a3ebbcfa2735d786c"}, + {file = "msgpack-1.1.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:283ae72fc89da59aa004ba147e8fc2f766647b1251500182fac0350d8af299c0"}, + {file = "msgpack-1.1.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61c8aa3bd513d87c72ed0b37b53dd5c5a0f58f2ff9f26e1555d3bd7948fb7296"}, + {file = "msgpack-1.1.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:454e29e186285d2ebe65be34629fa0e8605202c60fbc7c4c650ccd41870896ef"}, + {file = "msgpack-1.1.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7bc8813f88417599564fafa59fd6f95be417179f76b40325b500b3c98409757c"}, + {file = "msgpack-1.1.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bafca952dc13907bdfdedfc6a5f579bf4f292bdd506fadb38389afa3ac5b208e"}, + {file = "msgpack-1.1.2-cp311-cp311-win32.whl", hash = "sha256:602b6740e95ffc55bfb078172d279de3773d7b7db1f703b2f1323566b878b90e"}, + {file = "msgpack-1.1.2-cp311-cp311-win_amd64.whl", hash = "sha256:d198d275222dc54244bf3327eb8cbe00307d220241d9cec4d306d49a44e85f68"}, + {file = "msgpack-1.1.2-cp311-cp311-win_arm64.whl", hash = "sha256:86f8136dfa5c116365a8a651a7d7484b65b13339731dd6faebb9a0242151c406"}, + {file = "msgpack-1.1.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:70a0dff9d1f8da25179ffcf880e10cf1aad55fdb63cd59c9a49a1b82290062aa"}, + {file = "msgpack-1.1.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:446abdd8b94b55c800ac34b102dffd2f6aa0ce643c55dfc017ad89347db3dbdb"}, + {file = "msgpack-1.1.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c63eea553c69ab05b6747901b97d620bb2a690633c77f23feb0c6a947a8a7b8f"}, + {file = "msgpack-1.1.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:372839311ccf6bdaf39b00b61288e0557916c3729529b301c52c2d88842add42"}, + {file = "msgpack-1.1.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2929af52106ca73fcb28576218476ffbb531a036c2adbcf54a3664de124303e9"}, + {file = "msgpack-1.1.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:be52a8fc79e45b0364210eef5234a7cf8d330836d0a64dfbb878efa903d84620"}, + {file = "msgpack-1.1.2-cp312-cp312-win32.whl", hash = "sha256:1fff3d825d7859ac888b0fbda39a42d59193543920eda9d9bea44d958a878029"}, + {file = "msgpack-1.1.2-cp312-cp312-win_amd64.whl", hash = "sha256:1de460f0403172cff81169a30b9a92b260cb809c4cb7e2fc79ae8d0510c78b6b"}, + {file = "msgpack-1.1.2-cp312-cp312-win_arm64.whl", hash = "sha256:be5980f3ee0e6bd44f3a9e9dea01054f175b50c3e6cdb692bc9424c0bbb8bf69"}, + {file = "msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf"}, + {file = "msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7"}, + {file = "msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999"}, + {file = "msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e"}, + {file = "msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162"}, + {file = "msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794"}, + {file = "msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c"}, + {file = "msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9"}, + {file = "msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84"}, + {file = "msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00"}, + {file = "msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939"}, + {file = "msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e"}, + {file = "msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931"}, + {file = "msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014"}, + {file = "msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2"}, + {file = "msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717"}, + {file = "msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b"}, + {file = "msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af"}, + {file = "msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a"}, + {file = "msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b"}, + {file = "msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245"}, + {file = "msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90"}, + {file = "msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20"}, + {file = "msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27"}, + {file = "msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b"}, + {file = "msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff"}, + {file = "msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46"}, + {file = "msgpack-1.1.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ea5405c46e690122a76531ab97a079e184c0daf491e588592d6a23d3e32af99e"}, + {file = "msgpack-1.1.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9fba231af7a933400238cb357ecccf8ab5d51535ea95d94fc35b7806218ff844"}, + {file = "msgpack-1.1.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a8f6e7d30253714751aa0b0c84ae28948e852ee7fb0524082e6716769124bc23"}, + {file = "msgpack-1.1.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94fd7dc7d8cb0a54432f296f2246bc39474e017204ca6f4ff345941d4ed285a7"}, + {file = "msgpack-1.1.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:350ad5353a467d9e3b126d8d1b90fe05ad081e2e1cef5753f8c345217c37e7b8"}, + {file = "msgpack-1.1.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6bde749afe671dc44893f8d08e83bf475a1a14570d67c4bb5cec5573463c8833"}, + {file = "msgpack-1.1.2-cp39-cp39-win32.whl", hash = "sha256:ad09b984828d6b7bb52d1d1d0c9be68ad781fa004ca39216c8a1e63c0f34ba3c"}, + {file = "msgpack-1.1.2-cp39-cp39-win_amd64.whl", hash = "sha256:67016ae8c8965124fdede9d3769528ad8284f14d635337ffa6a713a580f6c030"}, + {file = "msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e"}, +] + [[package]] name = "multidict" version = "6.7.0" @@ -1888,6 +2538,21 @@ sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-d test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] xml = ["lxml (>=4.9.2)"] +[[package]] +name = "parsimonious" +version = "0.8.1" +description = "(Soon to be) the fastest pure-Python PEG parser I could muster" +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "parsimonious-0.8.1.tar.gz", hash = "sha256:3add338892d580e0cb3b1a39e4a1b427ff9f687858fdd61097053742391a9f6b"}, +] + +[package.dependencies] +six = ">=1.9.0" + [[package]] name = "pathspec" version = "0.12.1" @@ -2224,6 +2889,58 @@ files = [ {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"}, ] +[[package]] +name = "pycryptodome" +version = "3.23.0" +description = "Cryptographic library for Python" +optional = true +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "pycryptodome-3.23.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a176b79c49af27d7f6c12e4b178b0824626f40a7b9fed08f712291b6d54bf566"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:573a0b3017e06f2cffd27d92ef22e46aa3be87a2d317a5abf7cc0e84e321bd75"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:63dad881b99ca653302b2c7191998dd677226222a3f2ea79999aa51ce695f720"}, + {file = "pycryptodome-3.23.0-cp27-cp27m-win32.whl", hash = "sha256:b34e8e11d97889df57166eda1e1ddd7676da5fcd4d71a0062a760e75060514b4"}, + {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:7ac1080a8da569bde76c0a104589c4f414b8ba296c0b3738cf39a466a9fb1818"}, + {file = "pycryptodome-3.23.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:6fe8258e2039eceb74dfec66b3672552b6b7d2c235b2dfecc05d16b8921649a8"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:0011f7f00cdb74879142011f95133274741778abba114ceca229adbf8e62c3e4"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:90460fc9e088ce095f9ee8356722d4f10f86e5be06e2354230a9880b9c549aae"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4764e64b269fc83b00f682c47443c2e6e85b18273712b98aa43bcb77f8570477"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb8f24adb74984aa0e5d07a2368ad95276cf38051fe2dc6605cbcf482e04f2a7"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d97618c9c6684a97ef7637ba43bdf6663a2e2e77efe0f863cce97a76af396446"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9a53a4fe5cb075075d515797d6ce2f56772ea7e6a1e5e4b96cf78a14bac3d265"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:763d1d74f56f031788e5d307029caef067febf890cd1f8bf61183ae142f1a77b"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:954af0e2bd7cea83ce72243b14e4fb518b18f0c1649b576d114973e2073b273d"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win32.whl", hash = "sha256:257bb3572c63ad8ba40b89f6fc9d63a2a628e9f9708d31ee26560925ebe0210a"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6501790c5b62a29fcb227bd6b62012181d886a767ce9ed03b303d1f22eb5c625"}, + {file = "pycryptodome-3.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9a77627a330ab23ca43b48b130e202582e91cc69619947840ea4d2d1be21eb39"}, + {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:187058ab80b3281b1de11c2e6842a357a1f71b42cb1e15bce373f3d238135c27"}, + {file = "pycryptodome-3.23.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:cfb5cd445280c5b0a4e6187a7ce8de5a07b5f3f897f235caa11f1f435f182843"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67bd81fcbe34f43ad9422ee8fd4843c8e7198dd88dd3d40e6de42ee65fbe1490"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8987bd3307a39bc03df5c8e0e3d8be0c4c3518b7f044b0f4c15d1aa78f52575"}, + {file = "pycryptodome-3.23.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa0698f65e5b570426fc31b8162ed4603b0c2841cbb9088e2b01641e3065915b"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:53ecbafc2b55353edcebd64bf5da94a2a2cdf5090a6915bcca6eca6cc452585a"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:156df9667ad9f2ad26255926524e1c136d6664b741547deb0a86a9acf5ea631f"}, + {file = "pycryptodome-3.23.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:dea827b4d55ee390dc89b2afe5927d4308a8b538ae91d9c6f7a5090f397af1aa"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win32.whl", hash = "sha256:507dbead45474b62b2bbe318eb1c4c8ee641077532067fec9c1aa82c31f84886"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win_amd64.whl", hash = "sha256:c75b52aacc6c0c260f204cbdd834f76edc9fb0d8e0da9fbf8352ef58202564e2"}, + {file = "pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c"}, + {file = "pycryptodome-3.23.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:350ebc1eba1da729b35ab7627a833a1a355ee4e852d8ba0447fafe7b14504d56"}, + {file = "pycryptodome-3.23.0-pp27-pypy_73-win32.whl", hash = "sha256:93837e379a3e5fd2bb00302a47aee9fdf7940d83595be3915752c74033d17ca7"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ddb95b49df036ddd264a0ad246d1be5b672000f12d6961ea2c267083a5e19379"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e95564beb8782abfd9e431c974e14563a794a4944c29d6d3b7b5ea042110b4"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14e15c081e912c4b0d75632acd8382dfce45b258667aa3c67caf7a4d4c13f630"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7fc76bf273353dc7e5207d172b83f569540fc9a28d63171061c42e361d22353"}, + {file = "pycryptodome-3.23.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:45c69ad715ca1a94f778215a11e66b7ff989d792a4d63b68dc586a1da1392ff5"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:865d83c906b0fc6a59b510deceee656b6bc1c4fa0d82176e2b77e97a420a996a"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d4d56153efc4d81defe8b65fd0821ef8b2d5ddf8ed19df31ba2f00872b8002"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3f2d0aaf8080bda0587d58fc9fe4766e012441e2eed4269a77de6aea981c8be"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:64093fc334c1eccfd3933c134c4457c34eaca235eeae49d69449dc4728079339"}, + {file = "pycryptodome-3.23.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ce64e84a962b63a47a592690bdc16a7eaf709d2c2697ababf24a0def566899a6"}, + {file = "pycryptodome-3.23.0.tar.gz", hash = "sha256:447700a657182d60338bab09fdb27518f8856aecd80ae4c6bdddb67ff5da44ef"}, +] + [[package]] name = "pygments" version = "2.19.2" @@ -2332,14 +3049,14 @@ files = [ name = "requests" version = "2.32.5" description = "Python HTTP for Humans." -optional = false +optional = true python-versions = ">=3.9" -groups = ["docs"] -markers = "python_version >= \"3.11\"" +groups = ["main", "docs"] files = [ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, ] +markers = {main = "extra == \"hyperliquid\"", docs = "python_version >= \"3.11\""} [package.dependencies] certifi = ">=2017.4.17" @@ -2370,6 +3087,29 @@ pygments = ">=2.13.0,<3.0.0" [package.extras] jupyter = ["ipywidgets (>=7.5.1,<9)"] +[[package]] +name = "rlp" +version = "3.0.0" +description = "A package for Recursive Length Prefix encoding and decoding" +optional = true +python-versions = "*" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "rlp-3.0.0-py2.py3-none-any.whl", hash = "sha256:d2a963225b3f26795c5b52310e0871df9824af56823d739511583ef459895a7d"}, + {file = "rlp-3.0.0.tar.gz", hash = "sha256:63b0465d2948cd9f01de449d7adfb92d207c1aef3982f20310f8009be4a507e8"}, +] + +[package.dependencies] +eth-utils = ">=2.0.0,<3" + +[package.extras] +dev = ["Sphinx (>=1.6.5,<2)", "bumpversion (>=0.5.3,<1)", "flake8 (==3.4.1)", "hypothesis (==5.19.0)", "ipython", "pytest (>=6.2.5,<7)", "pytest-watch (>=4.1.0,<5)", "pytest-xdist", "setuptools (>=36.2.0)", "sphinx-rtd-theme (>=0.1.9)", "tox (>=2.9.1,<3)", "twine", "wheel"] +doc = ["Sphinx (>=1.6.5,<2)", "sphinx-rtd-theme (>=0.1.9)"] +lint = ["flake8 (==3.4.1)"] +rust-backend = ["rusty-rlp (>=0.2.1,<0.3)"] +test = ["hypothesis (==5.19.0)", "pytest (>=6.2.5,<7)", "tox (>=2.9.1,<3)"] + [[package]] name = "roman-numerals" version = "4.1.0" @@ -2920,6 +3660,19 @@ files = [ {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, ] +[[package]] +name = "toolz" +version = "1.1.0" +description = "List processing tools and functional utilities" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"hyperliquid\" and (implementation_name == \"cpython\" or implementation_name == \"pypy\")" +files = [ + {file = "toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8"}, + {file = "toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b"}, +] + [[package]] name = "types-python-dateutil" version = "2.9.0.20251115" @@ -2943,7 +3696,7 @@ files = [ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] -markers = {main = "python_version <= \"3.12\""} +markers = {main = "python_version <= \"3.12\" or extra == \"hyperliquid\""} [[package]] name = "tzdata" @@ -2976,14 +3729,14 @@ test = ["coverage", "pytest", "pytest-cov"] name = "urllib3" version = "2.6.2" description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false +optional = true python-versions = ">=3.9" -groups = ["docs"] -markers = "python_version >= \"3.11\"" +groups = ["main", "docs"] files = [ {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, ] +markers = {main = "extra == \"hyperliquid\"", docs = "python_version >= \"3.11\""} [package.extras] brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""] @@ -2991,6 +3744,24 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] +[[package]] +name = "websocket-client" +version = "1.9.0" +description = "WebSocket client for Python with low level API options" +optional = true +python-versions = ">=3.9" +groups = ["main"] +markers = "extra == \"hyperliquid\"" +files = [ + {file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"}, + {file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"}, +] + +[package.extras] +docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"] +optional = ["python-socks", "wsaccel"] +test = ["pytest", "websockets"] + [[package]] name = "websockets" version = "13.1" @@ -3234,8 +4005,9 @@ propcache = ">=0.2.1" [extras] charts = ["kaleido", "plotly"] +hyperliquid = ["eth-account", "hyperliquid-python-sdk"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "bc92720d0cafea8877d0edc2d9ccf32fd27121dbd43a281c154cb2c49b487040" +content-hash = "292fd448feb9227bd32597d6e51ce827014fecfa6e663a6f9cc4105cd84c4757" diff --git a/pyproject.toml b/pyproject.toml index 5b7ff33..cc4b6ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ python-dateutil = "^2.9" plotly = {version = "^5.14.1", optional = true} kaleido = {version = "0.2.1", optional = true} hyperliquid-python-sdk = {version = "^0.4.0", optional = true} -eth-account = {version = "^0.11.0", optional = true} +eth-account = {version = ">=0.8.0,<0.9.0", optional = true} [tool.poetry.extras] charts = ["plotly", "kaleido"] From 1663203a6d79085e884d97437d3f04127b84b71c Mon Sep 17 00:00:00 2001 From: Claudia Date: Tue, 10 Mar 2026 07:43:07 +0000 Subject: [PATCH 17/17] fix: satisfy Hyperliquid mypy checks --- basana/external/hyperliquid/client/rest.py | 33 ++++++++++++++-------- basana/external/hyperliquid/perps.py | 4 +-- basana/external/hyperliquid/websockets.py | 11 +++++++- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/basana/external/hyperliquid/client/rest.py b/basana/external/hyperliquid/client/rest.py index 8890ee5..04c55b5 100644 --- a/basana/external/hyperliquid/client/rest.py +++ b/basana/external/hyperliquid/client/rest.py @@ -14,14 +14,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, cast import asyncio import functools import logging import eth_account -from hyperliquid.info import Info -from hyperliquid.exchange import Exchange as HLExchange +from hyperliquid.info import Info # type: ignore[import-untyped] +from hyperliquid.exchange import Exchange as HLExchange # type: ignore[import-untyped] from basana.core.config import get_config_value from basana.external.hyperliquid import config @@ -100,22 +100,26 @@ async def get_funding_history(self, coin: str, start_time: int, end_time: Option async def get_user_state(self) -> dict: """Return account state: balances, open positions, margin summary.""" self._require_auth() - return await self._run(self._info.user_state, self._wallet.address) + wallet = cast(Any, self._wallet) + return await self._run(self._info.user_state, wallet.address) async def get_open_orders(self) -> List[dict]: """Return all open orders for this account.""" self._require_auth() - return await self._run(self._info.open_orders, self._wallet.address) + wallet = cast(Any, self._wallet) + return await self._run(self._info.open_orders, wallet.address) async def get_order_status(self, oid: int) -> dict: """Return the status of order ``oid``.""" self._require_auth() - return await self._run(self._info.query_order_by_oid, self._wallet.address, oid) + wallet = cast(Any, self._wallet) + return await self._run(self._info.query_order_by_oid, wallet.address, oid) async def get_user_fills(self, start_time: int) -> List[dict]: """Return trade fills since ``start_time`` (milliseconds).""" self._require_auth() - return await self._run(self._info.user_fills_by_time, self._wallet.address, start_time) + wallet = cast(Any, self._wallet) + return await self._run(self._info.user_fills_by_time, wallet.address, start_time) # ------------------------------------------------------------------ # Trading (requires private key) @@ -130,7 +134,8 @@ async def market_open(self, coin: str, is_buy: bool, sz: float, slippage: float :param slippage: Maximum acceptable slippage (default 1 %). """ self._require_auth() - result = await self._run(self._exchange.market_open, coin, is_buy, sz, None, slippage) + exchange = cast(Any, self._exchange) + result = await self._run(exchange.market_open, coin, is_buy, sz, None, slippage) self._check_result(result) return result @@ -142,7 +147,8 @@ async def market_close(self, coin: str, sz: Optional[float] = None, slippage: fl :param slippage: Maximum acceptable slippage (default 1 %). """ self._require_auth() - result = await self._run(self._exchange.market_close, coin, sz, None, slippage) + exchange = cast(Any, self._exchange) + result = await self._run(exchange.market_close, coin, sz, None, slippage) self._check_result(result) return result @@ -163,15 +169,17 @@ async def limit_order( :param reduce_only: If ``True``, the order can only reduce an existing position. """ self._require_auth() + exchange = cast(Any, self._exchange) order_type = {"limit": {"tif": "Gtc"}} - result = await self._run(self._exchange.order, coin, is_buy, sz, limit_px, order_type, reduce_only) + result = await self._run(exchange.order, coin, is_buy, sz, limit_px, order_type, reduce_only) self._check_result(result) return result async def cancel_order(self, coin: str, oid: int) -> dict: """Cancel order ``oid`` for ``coin``.""" self._require_auth() - result = await self._run(self._exchange.cancel, coin, oid) + exchange = cast(Any, self._exchange) + result = await self._run(exchange.cancel, coin, oid) self._check_result(result) return result @@ -183,7 +191,8 @@ async def set_leverage(self, coin: str, leverage: int, is_cross: bool = True) -> :param is_cross: ``True`` for cross margin, ``False`` for isolated. """ self._require_auth() - result = await self._run(self._exchange.update_leverage, leverage, coin, is_cross) + exchange = cast(Any, self._exchange) + result = await self._run(exchange.update_leverage, leverage, coin, is_cross) self._check_result(result) return result diff --git a/basana/external/hyperliquid/perps.py b/basana/external/hyperliquid/perps.py index bbe6f9c..e2726e0 100644 --- a/basana/external/hyperliquid/perps.py +++ b/basana/external/hyperliquid/perps.py @@ -15,7 +15,7 @@ # limitations under the License. from decimal import Decimal -from typing import Any, Awaitable, Callable, List, Optional +from typing import Any, Awaitable, Callable, List, Optional, cast import dataclasses import logging @@ -202,7 +202,7 @@ def subscribe_to_fill_events(self, handler: FillEventHandler) -> None: if event_source is None: event_source = websockets.RawEventSource(producer=self._ws) self._ws.set_channel_event_source(channel, event_source) - self._dispatcher.subscribe(event_source, handler) + self._dispatcher.subscribe(event_source, cast(dispatcher.EventHandler, handler)) # ------------------------------------------------------------------ # Internal helpers diff --git a/basana/external/hyperliquid/websockets.py b/basana/external/hyperliquid/websockets.py index e4b5ea1..0ffab24 100644 --- a/basana/external/hyperliquid/websockets.py +++ b/basana/external/hyperliquid/websockets.py @@ -15,6 +15,7 @@ # limitations under the License. from typing import Any, Awaitable, Callable, Dict, List, Optional +import datetime import logging import aiohttp @@ -49,6 +50,14 @@ def _user_fills_channel(address: str) -> str: return f"userFills:{address}" +class RawEvent(event.Event): + """A raw Hyperliquid websocket payload wrapped as a Basana event.""" + + def __init__(self, message: dict): + super().__init__(datetime.datetime.now(datetime.timezone.utc)) + self.message = message + + class RawEventSource(core_ws.ChannelEventSource): """Passes raw WebSocket message dicts through as events. @@ -59,7 +68,7 @@ def __init__(self, producer: event.Producer): super().__init__(producer=producer) async def push_from_message(self, message: dict): - self.push(message) + self.push(RawEvent(message)) class WebSocketClient(core_ws.WebSocketClient):