Skip to content

Commit c099d6c

Browse files
committed
Merge branch 'release/1.7'
2 parents 3ce5249 + 26fa8df commit c099d6c

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+2693
-1333
lines changed

.github/workflows/runtests.yml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,17 @@ jobs:
2525
strategy:
2626
fail-fast: false
2727
matrix:
28-
python-version: ["3.8", "3.9", "3.10", "3.11"]
29-
# windows-latest due to static image export hanging when using kaleido.
30-
os: [ubuntu-latest, macos-13]
28+
python-version: ["3.9", "3.10", "3.11", "3.12"]
29+
# windows-latest is not enabled due to static image export hanging when using kaleido: https://github.com/plotly/Kaleido/issues/126
30+
os: [ubuntu-latest, macos-latest]
3131

3232
runs-on: ${{ matrix.os }}
3333
name: Test on ${{ matrix.os }} with Python ${{ matrix.python-version }}.
3434

3535
steps:
36-
- uses: actions/checkout@v2
36+
- uses: actions/checkout@v4
3737
- name: Set up Python ${{ matrix.python-version }}
38-
uses: actions/setup-python@v1
38+
uses: actions/setup-python@v5
3939
with:
4040
python-version: ${{ matrix.python-version }}
4141
- name: Update system
@@ -48,7 +48,7 @@ jobs:
4848
pip install poetry
4949
- name: Initialize the virtual environment.
5050
run: |
51-
poetry install --no-root --all-extras
51+
poetry install --no-root --all-extras --without=docs
5252
- name: Static checks
5353
run: |
5454
poetry run -- mypy basana

CHANGELOG.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,26 @@
11
# Changelog
22

3+
## 1.7
4+
5+
### Features
6+
7+
* Added support for Binance order and user data events via websockets.
8+
* Added support for backtesting order events.
9+
* Added loan ids to order info.
10+
11+
### Bug fixes
12+
13+
* `bitstamp.orders.Order.amount` was returning the order amount left to be executed instead of the original amount.
14+
15+
### Misc
16+
17+
* Updated dependencies and minimum Python version.
18+
319
## 1.6.2
420

521
### Bug fixes
622

7-
* VolumeShareImpact.calculate_price and VolumeShareImpact.calculate_amount were failing when there was no available liquidity.
23+
* `VolumeShareImpact.calculate_price` and `VolumeShareImpact.calculate_amount` were failing when there was no available liquidity.
824

925
## 1.6.1
1026

@@ -25,7 +41,7 @@
2541

2642
## 1.5.0
2743

28-
* basana.backtesting.fees.Percentage now supports a minimum fee.
44+
* `basana.backtesting.fees.Percentage` now supports a minimum fee.
2945

3046
## 1.4.1
3147

DEVELOPERS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Requirements
44

5-
* Python 3.8.1 or greater.
5+
* Python 3.9 or greater.
66
* [Poetry](https://python-poetry.org/) for dependency and package management.
77
* Optionally, [Invoke](https://www.pyinvoke.org/).
88

basana/backtesting/charts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,15 +94,15 @@ def add_traces(self, figure: go.Figure, row: int):
9494
if self._include_buys:
9595
x, y = self._get_order_fills(OrderOperation.BUY).get_x_y()
9696
figure.add_trace(
97-
go.Scatter(x=x, y=y, name="Buy", mode="markers", marker=dict(symbol="arrow-up")),
97+
go.Scatter(x=x, y=y, name="Buy", mode="markers", marker=dict(symbol="arrow-up", color="green")),
9898
row=row, col=1
9999
)
100100

101101
# Add a trace with sell prices.
102102
if self._include_sells:
103103
x, y = self._get_order_fills(OrderOperation.SELL).get_x_y()
104104
figure.add_trace(
105-
go.Scatter(x=x, y=y, name="Sell", mode="markers", marker=dict(symbol="arrow-down")),
105+
go.Scatter(x=x, y=y, name="Sell", mode="markers", marker=dict(symbol="arrow-down", color="red")),
106106
row=row, col=1
107107
)
108108

basana/backtesting/exchange.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# limitations under the License.
1616

1717
from decimal import Decimal
18-
from typing import cast, Any, Awaitable, Callable, Dict, List, Optional, Sequence, Tuple
18+
from typing import cast, Callable, Dict, List, Optional, Sequence, Tuple
1919
import dataclasses
2020
import logging
2121
import uuid
@@ -29,9 +29,11 @@
2929

3030
logger = logging.getLogger(__name__)
3131

32-
BarEventHandler = Callable[[bar.BarEvent], Awaitable[Any]]
32+
BarEventHandler = bar.BarEventHandler
3333
Error = errors.Error
3434
LiquidityStrategyFactory = Callable[[], liquidity.LiquidityStrategy]
35+
OrderEvent = order_mgr.OrderEvent
36+
OrderEventHandler = order_mgr.OrderEventHandler
3537
OrderInfo = orders.OrderInfo
3638
OrderOperation = enums.OrderOperation
3739

@@ -93,7 +95,7 @@ class Exchange:
9395
"""
9496
def __init__(
9597
self,
96-
dispatcher: dispatcher.EventDispatcher,
98+
dispatcher: dispatcher.BacktestingDispatcher,
9799
initial_balances: Dict[str, Decimal],
98100
liquidity_strategy_factory: LiquidityStrategyFactory = liquidity.VolumeShareImpact,
99101
fee_strategy: fees.FeeStrategy = fees.NoFee(),
@@ -344,6 +346,14 @@ def subscribe_to_bar_events(self, pair: Pair, event_handler: BarEventHandler):
344346
self._bar_event_source[pair] = event_source
345347
self._dispatcher.subscribe(event_source, cast(dispatcher.EventHandler, event_handler))
346348

349+
def subscribe_to_order_events(self, event_handler: OrderEventHandler):
350+
"""
351+
Registers an async callable that will be called when an order is accepted or updated.
352+
353+
:param event_handler: The event handler.
354+
"""
355+
self._order_mgr.subscribe_to_order_events(event_handler)
356+
347357
async def get_pair_info(self, pair: Pair) -> PairInfo:
348358
"""
349359
Returns information about a trading pair.
@@ -427,7 +437,7 @@ async def _on_bar_event(self, event: event.Event):
427437
def _get_all_orders(self) -> Sequence[orders.Order]:
428438
return list(self._order_mgr.get_all_orders())
429439

430-
def _get_dispatcher(self) -> dispatcher.EventDispatcher:
440+
def _get_dispatcher(self) -> dispatcher.BacktestingDispatcher:
431441
return self._dispatcher
432442

433443
def _get_balance(self, symbol: str) -> Balance:

basana/backtesting/lending/base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def calculate_collateral(self, prices: prices.Prices) -> ValueMapDict:
9696

9797
@dataclasses.dataclass
9898
class ExchangeContext:
99-
dispatcher: dispatcher.EventDispatcher
99+
dispatcher: dispatcher.BacktestingDispatcher
100100
account_balances: account_balances.AccountBalances
101101
prices: prices.Prices
102102
config: config.Config

basana/backtesting/order_mgr.py

Lines changed: 58 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,27 +15,30 @@
1515
# limitations under the License.
1616

1717
from decimal import Decimal
18-
from typing import cast, Callable, Dict, Generator, Iterable, List, Optional
18+
from typing import Any, Awaitable, cast, Callable, Dict, Generator, Iterable, List, Optional
1919
import dataclasses
20+
import datetime
2021
import decimal
2122
import logging
2223

2324
from basana.backtesting import account_balances, config, errors, fees, helpers, lending, loan_mgr, liquidity, prices
24-
from basana.backtesting.orders import Order
25+
from basana.backtesting.orders import Order, OrderInfo
2526
from basana.backtesting.value_map import ValueMap, ValueMapDict
2627
from basana.core import bar, dispatcher, helpers as core_helpers, logs
2728
from basana.core.enums import OrderOperation
2829
from basana.core.pair import Pair
30+
import basana as bs
2931

3032

3133
logger = logging.getLogger(__name__)
3234

3335
LiquidityStrategyFactory = Callable[[], liquidity.LiquidityStrategy]
36+
OrderEventHandler = Callable[["OrderEvent"], Awaitable[Any]]
3437

3538

3639
@dataclasses.dataclass
3740
class ExchangeContext:
38-
dispatcher: dispatcher.EventDispatcher
41+
dispatcher: dispatcher.BacktestingDispatcher
3942
account_balances: account_balances.AccountBalances
4043
prices: prices.Prices
4144
fee_strategy: fees.FeeStrategy
@@ -44,12 +47,24 @@ class ExchangeContext:
4447
config: config.Config
4548

4649

50+
class OrderEvent(bs.Event):
51+
"""
52+
An event for order updates.
53+
"""
54+
55+
def __init__(self, when: datetime.datetime, order: OrderInfo):
56+
super().__init__(when)
57+
#: The order.
58+
self.order: OrderInfo = order
59+
60+
4761
class OrderManager:
4862
def __init__(self, exchange_ctx: ExchangeContext):
4963
self._ctx = exchange_ctx
5064
self._liquidity_strategies: Dict[Pair, liquidity.LiquidityStrategy] = {}
5165
self._orders = helpers.ExchangeObjectContainer[Order]()
5266
self._holds_by_order: Dict[str, ValueMap] = {}
67+
self._order_updates = core_helpers.LazyProxy(bs.FifoQueueEventSource)
5368

5469
def on_bar_event(self, bar_event: bar.BarEvent):
5570
if (liquidity_strategy := self._liquidity_strategies.get(bar_event.bar.pair)) is None:
@@ -69,7 +84,12 @@ def add_order(self, order: Order):
6984
self._ctx.account_balances.update(hold_updates=required_balances)
7085
self._holds_by_order[order.id] = required_balances
7186

87+
# The order got accepted.
7288
self._orders.add(order)
89+
# Checking dispatcher.now_available is necessary to avoid calling dispatcher.now() when no events have been
90+
# processed yet.
91+
if self._order_updates.initialized and self._ctx.dispatcher.now_available and self._ctx.dispatcher.now():
92+
self._order_updates.push(OrderEvent(self._ctx.dispatcher.now(), order.get_order_info()))
7393

7494
except errors.NotEnoughBalance as e:
7595
logger.debug(logs.StructuredMessage(
@@ -95,6 +115,14 @@ def cancel_order(self, order_id: str):
95115
order.cancel()
96116
self._order_closed(order)
97117

118+
def subscribe_to_order_events(self, event_handler: OrderEventHandler):
119+
"""
120+
Registers an async callable that will be called when an order is updated.
121+
122+
:param event_handler: The event handler.
123+
"""
124+
self._ctx.dispatcher.subscribe(self._order_updates.obj, cast(dispatcher.EventHandler, event_handler))
125+
98126
def _update_balances(self, order: Order, balance_updates: ValueMapDict):
99127
# If we have holds associated with the order, it may be time to release some/all of those.
100128
hold_updates = {}
@@ -148,31 +176,43 @@ def _borrow(self, required_balances: ValueMap, order: Order):
148176
self._ctx.loan_mgr.cancel_loan(loan_id)
149177
raise
150178

151-
def _repay_loans(self, symbol: str):
179+
# Add loans to order.
180+
for loan_id in loan_ids:
181+
order.add_loan(loan_id)
182+
183+
def _repay_loans(self, order: Order):
184+
if order.operation == OrderOperation.BUY:
185+
credit_symbol = order.pair.base_symbol
186+
else:
187+
credit_symbol = order.pair.quote_symbol
188+
152189
candidate_loans = [
153190
loan for loan in self._ctx.loan_mgr.get_loans(is_open=True)
154-
if loan.borrowed_symbol == symbol
191+
if loan.borrowed_symbol == credit_symbol
155192
]
156193
# Try to cancel bigger loans first.
157194
candidate_loans.sort(key=lambda loan: loan.borrowed_amount, reverse=True)
195+
loan_ids: List[str] = []
158196
for loan in candidate_loans:
159197
try:
160198
self._ctx.loan_mgr.repay_loan(loan.id)
199+
loan_ids.append(loan.id)
161200
loan = cast(lending.LoanInfo, self._ctx.loan_mgr.get_loan(loan.id))
162201
logger.debug(logs.StructuredMessage("Repayed loan", loan=dataclasses.asdict(loan)))
163202
except errors.NotEnoughBalance:
164203
pass
165204

205+
# Add loans to order.
206+
for loan_id in loan_ids:
207+
order.add_loan(loan_id)
208+
166209
def _order_closed(self, order: Order):
167210
# The order is closed and there might be balances on hold that have to be released.
168211
self._update_balances(order, {})
169-
# If the order has auto_repay set and is filled, either fully or partially, then we need to cancel any open
170-
# loans matching the order's base/quote currency.
212+
# If the order has auto_repay set and is filled, either fully or partially, then we need to cancel matching open
213+
# loans
171214
if order.auto_repay and order.amount_filled:
172-
if order.operation == OrderOperation.BUY:
173-
self._repay_loans(order.pair.base_symbol)
174-
else:
175-
self._repay_loans(order.pair.quote_symbol)
215+
self._repay_loans(order)
176216

177217
def _process_order(
178218
self, order: Order, bar_event: bar.BarEvent, liquidity_strategy: liquidity.LiquidityStrategy
@@ -182,6 +222,9 @@ def order_not_filled():
182222
logger.debug(logs.StructuredMessage("Order not filled", order_id=order.id, order_state=order.state))
183223
if not order.is_open:
184224
self._order_closed(order)
225+
# Push the order event since the order is now closed.
226+
if self._order_updates.initialized:
227+
self._order_updates.push(OrderEvent(bar_event.when, order.get_order_info()))
185228

186229
# Calculate balance updates for the current bar.
187230
logger.debug(logs.StructuredMessage(
@@ -225,6 +268,10 @@ def order_not_filled():
225268
if not order.is_open:
226269
self._order_closed(order)
227270

271+
# Push the order event since the order got updated.
272+
if self._order_updates.initialized:
273+
self._order_updates.push(OrderEvent(bar_event.when, order.get_order_info()))
274+
228275
except errors.NotEnoughBalance as e:
229276
logger.debug(logs.StructuredMessage(
230277
"Balance short processing order", order=order.get_debug_info(), error=str(e)

basana/backtesting/orders.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# limitations under the License.
1616

1717
from decimal import Decimal
18-
from typing import Dict, List, Optional
18+
from typing import Dict, List, Optional, Set
1919
import abc
2020
import dataclasses
2121
import datetime
@@ -60,6 +60,8 @@ class OrderInfo:
6060
limit_price: Optional[Decimal] = None
6161
#: The stop price.
6262
stop_price: Optional[Decimal] = None
63+
#: The ids of the associated loans.
64+
loan_ids: List[str] = dataclasses.field(default_factory=list)
6365

6466
@property
6567
def fill_price(self) -> Optional[Decimal]:
@@ -95,6 +97,7 @@ def __init__(
9597
self._fills: List[Fill] = []
9698
self._auto_borrow = auto_borrow
9799
self._auto_repay = auto_repay
100+
self._loan_ids: Set[str] = set()
98101

99102
@property
100103
def id(self) -> str:
@@ -163,12 +166,16 @@ def add_fill(self, when: datetime.datetime, balance_updates: Dict[str, Decimal],
163166
self._state = OrderState.COMPLETED
164167
self._fills.append(Fill(when=when, balance_updates=balance_updates, fees=fees))
165168

169+
def add_loan(self, loan_id: str):
170+
self._loan_ids.add(loan_id)
171+
166172
def get_order_info(self) -> OrderInfo:
167173
return OrderInfo(
168174
id=self.id, is_open=self._state == OrderState.OPEN, operation=self.operation,
169175
amount=self.amount, amount_filled=self.amount_filled, amount_remaining=self.amount_pending,
170176
quote_amount_filled=self.quote_amount_filled,
171-
fees={symbol: -amount for symbol, amount in self._fees.items() if amount}
177+
fees={symbol: -amount for symbol, amount in self._fees.items() if amount},
178+
loan_ids=[loan_id for loan_id in self._loan_ids]
172179
)
173180

174181
@abc.abstractmethod

basana/core/bar.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
# limitations under the License.
1616

1717
from decimal import Decimal
18-
from typing import Any, List, Optional, Tuple
18+
from typing import Any, Awaitable, Callable, List, Optional, Tuple
1919
import asyncio
2020
import datetime
2121
import logging
@@ -163,3 +163,6 @@ async def main(self):
163163
self._flush(begin, end)
164164
begin += datetime.timedelta(seconds=self._bar_duration)
165165
end += datetime.timedelta(seconds=self._bar_duration)
166+
167+
168+
BarEventHandler = Callable[[BarEvent], Awaitable[Any]]

0 commit comments

Comments
 (0)