Skip to content

Commit 7db149c

Browse files
committed
Merge branch 'release/1.8'
2 parents 1a8fc1f + ddf84d8 commit 7db149c

Some content is hidden

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

51 files changed

+3339
-2449
lines changed

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Changelog
22

3+
## 1.8
4+
5+
### Features
6+
7+
* Backtesting `OrderInfo` now includes pair and order fills.
8+
* Backtesting exchange returns a full `OrderInfo` when creating an order.
9+
* Backtesting exchange support for immediate order processing.
10+
11+
### Bug fixes
12+
13+
* Backtesting margin level calculations were wrong. `margin_requirement` was moved from `MarginLoanConditions` into `MarginLoans`
14+
315
## 1.7.1
416

517
### Bug fixes
@@ -47,7 +59,7 @@
4759

4860
## 1.5.0
4961

50-
* `basana.backtesting.fees.Percentage` now supports a minimum fee.
62+
* `backtesting.fees.Percentage` now supports a minimum fee.
5163

5264
## 1.4.1
5365

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ $ pip install basana[charts] talipp pandas statsmodels
2626

2727
### Backtest a pairs trading strategy
2828

29-
1. Download and unzip [samples](https://github.com/gbeced/basana/releases/download/1.7.1/samples.zip).
29+
1. Download and unzip [samples](https://github.com/gbeced/basana/releases/download/1.8/samples.zip).
3030

3131
2. Download historical data for backtesting
3232

basana/backtesting/account_balances.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,18 @@
2525

2626
class UpdateRule(metaclass=abc.ABCMeta):
2727
@abc.abstractmethod
28-
def check(self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap):
28+
def check(
29+
self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap,
30+
delta_balances: ValueMap, delta_holds: ValueMap, delta_borrowed: ValueMap
31+
):
2932
raise NotImplementedError()
3033

3134

3235
class NonZero(UpdateRule):
33-
def check(self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap):
36+
def check(
37+
self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap,
38+
delta_balances: ValueMap, delta_holds: ValueMap, delta_borrowed: ValueMap
39+
):
3440
# balance >= 0
3541
for symbol, value in updated_balances.items():
3642
if value < Decimal(0):
@@ -47,7 +53,10 @@ def check(self, updated_balances: ValueMap, updated_holds: ValueMap, updated_bor
4753

4854
class ValidHold(UpdateRule):
4955
# * hold <= balance
50-
def check(self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap):
56+
def check(
57+
self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap,
58+
delta_balances: ValueMap, delta_holds: ValueMap, delta_borrowed: ValueMap
59+
):
5160
symbols = set(itertools.chain(updated_holds.keys(), updated_balances.keys()))
5261
for symbol in symbols:
5362
updated_hold = updated_holds.get(symbol, Decimal(0))
@@ -82,7 +91,10 @@ def update(
8291
updated_borrowed = self.borrowed + borrowed_updates
8392

8493
for rule in self._update_rules:
85-
rule.check(updated_balances, updated_holds, updated_borrowed)
94+
rule.check(
95+
updated_balances, updated_holds, updated_borrowed,
96+
ValueMap(balance_updates), ValueMap(hold_updates), ValueMap(borrowed_updates)
97+
)
8698

8799
# Update if no error ocurred.
88100
self.balances = updated_balances

basana/backtesting/charts.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,8 @@ async def _on_any_event(self, event: event.Event):
166166

167167

168168
class PortfolioValueLineChart(LineChart):
169-
def __init__(self, symbol: str, exchange: Exchange, precision: int = 2):
170-
self._symbol = symbol
169+
def __init__(self, quote_symbol: str, exchange: Exchange, precision: int = 2):
170+
self._quote_symbol = quote_symbol
171171
self._exchange = exchange
172172
self._ts = TimeSeries()
173173
self._precision = precision
@@ -177,12 +177,12 @@ def __init__(self, symbol: str, exchange: Exchange, precision: int = 2):
177177
exchange._get_dispatcher().subscribe_all(self._on_any_event)
178178

179179
def get_title(self) -> str:
180-
return f"Portfolio value in {self._symbol}"
180+
return f"Portfolio value in {self._quote_symbol}"
181181

182182
def add_traces(self, figure: go.Figure, row: int):
183183
# Add a trace with the portfolio values.
184184
x, y = self._ts.get_x_y()
185-
figure.add_trace(go.Scatter(x=x, y=y, name=f"Portfolio ({self._symbol})"), row=row, col=1)
185+
figure.add_trace(go.Scatter(x=x, y=y, name=f"Portfolio ({self._quote_symbol})"), row=row, col=1)
186186

187187
async def _on_any_event(self, event: event.Event):
188188
portfolio_value = Decimal(0)
@@ -192,10 +192,11 @@ async def _on_any_event(self, event: event.Event):
192192
continue
193193

194194
try:
195-
rate: Decimal = Decimal(1)
196-
if symbol != self._symbol:
197-
rate, _ = await self._exchange.get_bid_ask(Pair(symbol, self._symbol))
198-
portfolio_value += rate * balance.total
195+
price = Decimal(1)
196+
if symbol != self._quote_symbol:
197+
bid, ask = await self._exchange.get_bid_ask(Pair(symbol, self._quote_symbol))
198+
price = bid if balance.total > 0 else ask
199+
portfolio_value += balance.total * price
199200
except errors.Error as e:
200201
logger.debug(str(e))
201202

basana/backtesting/errors.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,3 @@ class NotEnoughBalance(Error):
2929

3030
class NotFound(Error):
3131
pass
32-
33-
34-
class NoPrice(Error):
35-
pass

basana/backtesting/exchange.py

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# See the License for the specific language governing permissions and
1515
# limitations under the License.
1616

17+
from collections import defaultdict
1718
from decimal import Decimal
1819
from typing import cast, Callable, Dict, List, Optional, Sequence, Tuple
1920
import dataclasses
@@ -31,6 +32,7 @@
3132

3233
BarEventHandler = bar.BarEventHandler
3334
Error = errors.Error
35+
Fill = orders.Fill
3436
LiquidityStrategyFactory = Callable[[], liquidity.LiquidityStrategy]
3537
OrderEvent = order_mgr.OrderEvent
3638
OrderEventHandler = order_mgr.OrderEventHandler
@@ -53,10 +55,8 @@ def __post_init__(self):
5355
self.total = self.available + self.hold - self.borrowed
5456

5557

56-
@dataclasses.dataclass
57-
class CreatedOrder:
58-
#: The order id.
59-
id: str
58+
class CreatedOrder(OrderInfo):
59+
pass
6060

6161

6262
@dataclasses.dataclass
@@ -92,6 +92,8 @@ class Exchange:
9292
:meth:`Exchange.set_pair_info`.
9393
:param bid_ask_spread: The spread to use for :meth:`Exchange.get_bid_ask`.
9494
:param lending_strategy: The strategy to use for managing loans.
95+
:param immediate_order_processing: If True, orders will be processed immediately after being added,
96+
using the closing price of the last bar available. If False, orders will be processed in the next bar event.
9597
"""
9698
def __init__(
9799
self,
@@ -101,11 +103,12 @@ def __init__(
101103
fee_strategy: fees.FeeStrategy = fees.NoFee(),
102104
default_pair_info: Optional[PairInfo] = PairInfo(base_precision=0, quote_precision=2),
103105
bid_ask_spread: Decimal = Decimal("0.5"),
104-
lending_strategy: lending.LendingStrategy = lending.NoLoans()
106+
lending_strategy: lending.LendingStrategy = lending.NoLoans(),
107+
immediate_order_processing: bool = False
105108
):
106109
self._dispatcher = dispatcher
107110
self._balances = account_balances.AccountBalances(initial_balances)
108-
self._bar_event_source: Dict[Pair, event.FifoQueueEventSource] = {}
111+
self._bar_event_source: Dict[Pair, event.FifoQueueEventSource] = defaultdict(event.FifoQueueEventSource)
109112
self._config = config.Config(None, default_pair_info)
110113
self._prices = prices.Prices(bid_ask_spread, self._config)
111114
self._loan_mgr = loan_mgr.LoanManager(
@@ -122,7 +125,8 @@ def __init__(
122125
dispatcher=dispatcher, account_balances=self._balances, prices=self._prices,
123126
fee_strategy=fee_strategy, liquidity_strategy_factory=liquidity_strategy_factory,
124127
loan_mgr=self._loan_mgr, config=self._config
125-
)
128+
),
129+
immediate_order_processing=immediate_order_processing
126130
)
127131

128132
async def get_balance(self, symbol: str) -> Balance:
@@ -161,7 +165,14 @@ async def create_order(self, order_request: requests.ExchangeOrder) -> CreatedOr
161165
order = order_request.create_order(uuid.uuid4().hex)
162166
self._order_mgr.add_order(order)
163167
logger.debug(logs.StructuredMessage("Request accepted", order_id=order.id))
164-
return CreatedOrder(id=order.id)
168+
order_info = order.get_order_info()
169+
return CreatedOrder(
170+
id=order_info.id, pair=order_info.pair, is_open=order_info.is_open, operation=order_info.operation,
171+
amount=order_info.amount, amount_filled=order_info.amount_filled,
172+
amount_remaining=order_info.amount_remaining, quote_amount_filled=order_info.quote_amount_filled,
173+
fees=order_info.fees, limit_price=order_info.limit_price, stop_price=order_info.stop_price,
174+
loan_ids=order_info.loan_ids, fills=order_info.fills,
175+
)
165176

166177
async def create_market_order(
167178
self, operation: OrderOperation, pair: Pair, amount: Decimal, auto_borrow: bool = False,
@@ -340,10 +351,7 @@ def subscribe_to_bar_events(self, pair: Pair, event_handler: BarEventHandler):
340351
:param event_handler: An async callable that receives a basana.BarEvent.
341352
"""
342353
# Get/create the event source for the given pair.
343-
event_source = self._bar_event_source.get(pair)
344-
if event_source is None:
345-
event_source = event.FifoQueueEventSource()
346-
self._bar_event_source[pair] = event_source
354+
event_source = self._bar_event_source[pair]
347355
self._dispatcher.subscribe(event_source, cast(dispatcher.EventHandler, event_handler))
348356

349357
def subscribe_to_order_events(self, event_handler: OrderEventHandler):

basana/backtesting/lending/margin.py

Lines changed: 35 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,6 @@ class MarginLoanConditions:
3636
interest_period: datetime.timedelta
3737
#: The minimum interest to charge.
3838
min_interest: Decimal
39-
# Minimum threshold for the value of the collateral relative to the loan amount + outstanding interests.
40-
margin_requirement: Decimal
4139

4240

4341
class MarginLoan(base.Loan):
@@ -73,10 +71,17 @@ class MarginLoans(base.LendingStrategy):
7371
This strategy will use the accounts assets as collateral for the loans.
7472
7573
:param quote_symbol: The symbol to use to normalize balances.
74+
:param margin_requirement: Minimum threshold for the value of the collateral relative to the total position.
7675
:param default_conditions: The default margin loan conditions.
7776
"""
78-
def __init__(self, quote_symbol: str, default_conditions: Optional[MarginLoanConditions] = None):
77+
def __init__(
78+
self, quote_symbol: str, margin_requirement: Decimal,
79+
default_conditions: Optional[MarginLoanConditions] = None
80+
):
81+
assert margin_requirement > 0, "Margin requirement must be greater than zero"
82+
7983
self._quote_symbol = quote_symbol
84+
self._margin_requirement = margin_requirement
8085
self._conditions: Dict[str, MarginLoanConditions] = {}
8186
self._default_conditions = default_conditions
8287
self._loan_mgr: Optional[loan_mgr.LoanManager] = None
@@ -118,56 +123,47 @@ def margin_level(self) -> Decimal:
118123
"""
119124
assert self._exchange_ctx, "Not yet connected with the exchange"
120125
acc_balances = self._exchange_ctx.account_balances
121-
return self._calculate_margin_level(
126+
return self.calculate_margin_level(
122127
acc_balances.balances, acc_balances.holds, acc_balances.borrowed
123128
)
124129

125-
def _calculate_margin_level(
130+
def calculate_margin_level(
126131
self, updated_balances: ValueMapDict, updated_holds: ValueMapDict, updated_borrowed: ValueMapDict
127132
) -> Decimal:
128133
assert self._exchange_ctx and self._loan_mgr, "Not yet connected with the exchange"
129134

130-
# Calculate used margin.
131-
margin_requirements = ValueMap(
132-
{symbol: self.get_conditions(symbol).margin_requirement for symbol in updated_borrowed}
133-
)
134-
used_margin_by_symbol = margin_requirements * updated_borrowed
135-
used_margin = self._exchange_ctx.prices.convert_value_map(used_margin_by_symbol, self._quote_symbol)
136-
if used_margin == Decimal(0):
137-
return Decimal(0)
135+
# If we haven't borrowed anything yet, the margin level is infinite.
136+
if all(v == Decimal(0) for v in updated_borrowed.values()):
137+
# used_margin = 0, margin_level = Infinity
138+
return Decimal("Infinity")
138139

139140
# Calculate outstanding interest.
140-
interest_by_symbol = ValueMap()
141+
outstanding_interest = ValueMap()
141142
for loan in self._loan_mgr.get_loans(is_open=True):
142-
interest_by_symbol += loan.outstanding_interest
143-
interest = self._exchange_ctx.prices.convert_value_map(interest_by_symbol, self._quote_symbol)
144-
145-
# Calculate equity.
146-
equity = Decimal(0)
147-
for symbol, balance in updated_balances.items():
148-
borrowed = updated_borrowed.get(symbol, Decimal(0))
149-
net = balance - borrowed
150-
if net <= Decimal(0):
151-
continue
152-
153-
if symbol != self._quote_symbol:
154-
net = self._exchange_ctx.prices.convert(net, symbol, self._quote_symbol)
143+
outstanding_interest += loan.outstanding_interest
144+
outstanding_interest = self._exchange_ctx.prices.convert_value_map(outstanding_interest, self._quote_symbol)
155145

156-
equity += net
157-
158-
return equity / (used_margin + interest) * Decimal(100)
159-
160-
def _check_margin_level(
161-
self, updated_balances: ValueMapDict, updated_holds: ValueMapDict, updated_borrowed: ValueMapDict
162-
):
163-
margin_level = self._calculate_margin_level(updated_balances, updated_holds, updated_borrowed)
164-
if margin_level > Decimal(0) and margin_level < Decimal(100):
165-
raise errors.NotEnoughBalance(f"Margin level too low {margin_level}")
146+
# Calculate margin level.
147+
borrowed = self._exchange_ctx.prices.convert_value_map(updated_borrowed, self._quote_symbol)
148+
total_position_size = self._exchange_ctx.prices.convert_value_map(updated_balances, self._quote_symbol)
149+
total_position_size -= outstanding_interest
150+
equity = total_position_size - borrowed
151+
used_margin = Decimal(sum(total_position_size.values())) * self._margin_requirement
152+
margin_level = Decimal(sum(equity.values())) / used_margin * Decimal(100)
153+
return margin_level
166154

167155

168156
class CheckMarginLevel(account_balances.UpdateRule):
169157
def __init__(self, margin_loans: MarginLoans):
170158
self._margin_loans = margin_loans
159+
self._threshold = Decimal(100)
171160

172-
def check(self, updated_balances: ValueMapDict, updated_holds: ValueMapDict, updated_borrowed: ValueMapDict):
173-
self._margin_loans._check_margin_level(updated_balances, updated_holds, updated_borrowed)
161+
def check(
162+
self, updated_balances: ValueMap, updated_holds: ValueMap, updated_borrowed: ValueMap,
163+
delta_balances: ValueMap, delta_holds: ValueMap, delta_borrowed: ValueMap
164+
):
165+
# If we're increasing any borrowed amount we need to check the margin level.
166+
if any(v > 0 for v in delta_borrowed.values()):
167+
margin_level = self._margin_loans.calculate_margin_level(updated_balances, updated_holds, updated_borrowed)
168+
if margin_level < self._threshold:
169+
raise errors.NotEnoughBalance(f"Margin level too low {margin_level}")

0 commit comments

Comments
 (0)