Skip to content

Commit

Permalink
Currencies in backtest (#164)
Browse files Browse the repository at this point in the history
  • Loading branch information
eirik-thorp authored Nov 29, 2024
1 parent db26e2a commit eadf8df
Show file tree
Hide file tree
Showing 16 changed files with 429 additions and 79 deletions.
7 changes: 5 additions & 2 deletions qf_lib/backtesting/broker/backtest_broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ def __init__(self, contract_ticker_mapper: ContractTickerMapper, portfolio: Port
self.portfolio = portfolio
self.execution_handler = execution_handler

def get_portfolio_value(self) -> Optional[float]:
return self.portfolio.net_liquidation
def get_portfolio_value(self, currency: str = None) -> Optional[float]:
if currency:
return self.portfolio.net_liquidation_in_currency(currency)
else:
return self.portfolio.net_liquidation

def get_positions(self) -> List[Position]:
return list(self.portfolio.open_positions_dict.values())
Expand Down
2 changes: 1 addition & 1 deletion qf_lib/backtesting/broker/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def __init__(self, contract_ticker_mapper: ContractTickerMapper):
self.contract_ticker_mapper = contract_ticker_mapper

@abstractmethod
def get_portfolio_value(self) -> float:
def get_portfolio_value(self, currency: Optional[str] = None) -> float:
pass

@abstractmethod
Expand Down
6 changes: 2 additions & 4 deletions qf_lib/backtesting/order/order_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,7 @@ def percent_orders(self, percentages: Mapping[Ticker, float], execution_style: E
"""
self._log_function_call(vars())
self._check_tickers_type(list(percentages.keys()))
portfolio_value = self.broker.get_portfolio_value()
values = {ticker: portfolio_value * fraction for ticker, fraction in percentages.items()}
values = {ticker: self.broker.get_portfolio_value(ticker.currency) * fraction for ticker, fraction in percentages.items()}

return self.value_orders(values, execution_style, time_in_force, frequency)

Expand Down Expand Up @@ -291,9 +290,8 @@ def target_percent_orders(self, target_percentages: Mapping[Ticker, float], exec
assert 0.0 <= tolerance_percentage < 1.0, "The tolerance_percentage should belong to [0, 1) interval"
self._check_tickers_type(list(target_percentages.keys()))

portfolio_value = self.broker.get_portfolio_value()
target_values = {
ticker: portfolio_value * target_percent for ticker, target_percent in target_percentages.items()
ticker: self.broker.get_portfolio_value(ticker.currency) * target_percent for ticker, target_percent in target_percentages.items()
}
return self.target_value_orders(target_values, execution_style, time_in_force, tolerance_percentage, frequency)

Expand Down
39 changes: 34 additions & 5 deletions qf_lib/backtesting/portfolio/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from datetime import datetime
from typing import List, Dict
from typing import List, Dict, Optional

from qf_lib.backtesting.portfolio.backtest_position import BacktestPosition, BacktestPositionSummary
from qf_lib.backtesting.portfolio.position_factory import BacktestPositionFactory
Expand All @@ -24,16 +24,18 @@
from qf_lib.containers.series.prices_series import PricesSeries
from qf_lib.containers.series.qf_series import QFSeries
from qf_lib.data_providers.abstract_price_data_provider import AbstractPriceDataProvider
from qf_lib.data_providers.exchange_rate_provider import ExchangeRateProvider


class Portfolio:
def __init__(self, data_provider: AbstractPriceDataProvider, initial_cash: float):
def __init__(self, data_provider: AbstractPriceDataProvider, initial_cash: float, currency: Optional[str] = None):
"""
On creation, the Portfolio object contains no positions and all values are "reset" to the initial
cash, with no PnL.
"""
self.initial_cash = initial_cash
self.data_provider = data_provider
self.currency = currency

self.net_liquidation = initial_cash
""" Cash value includes futures P&L + stock value + securities options value + bond value + fund value. """
Expand Down Expand Up @@ -62,6 +64,27 @@ def __init__(self, data_provider: AbstractPriceDataProvider, initial_cash: float

self.logger = qf_logger.getChild(self.__class__.__name__)

def _current_exchange_rate(self, currency: str) -> float:
"""Last available exchange rate from the specified currency to the portfolio currency."""
if currency == self.currency:
return 1.

if isinstance(self.data_provider, ExchangeRateProvider):
return self.data_provider.get_last_available_exchange_rate(currency, self.currency,
frequency=self.data_provider.frequency)
else:
raise NotImplementedError(f"Portfolio currency is set to {self.currency} but {type(self.data_provider)} "
"does not extend ExchangeRateProvider.")

def net_liquidation_in_currency(self, currency: str = None) -> float:
"""Converts the current net liquidation from the portfolio currency into the specified currency"""
if currency == self.currency:
return self.net_liquidation
elif self.currency is not None:
return self.net_liquidation/self._current_exchange_rate(currency)
else:
raise ValueError("Portfolio currency is not specified.")

def transact_transaction(self, transaction: Transaction):
"""
Adjusts positions to account for a transaction.
Expand All @@ -88,7 +111,10 @@ def transact_transaction(self, transaction: Transaction):
new_position = self._create_new_position(remaining_transaction)
transaction_cost += new_position.transact_transaction(remaining_transaction)

self.current_cash += transaction_cost
if self.currency is not None:
self.current_cash += transaction_cost*self._current_exchange_rate(transaction.ticker.currency)
else:
self.current_cash += transaction_cost

def update(self, record=False):
"""
Expand All @@ -109,8 +135,11 @@ def update(self, record=False):
position.update_price(bid_price=security_price, ask_price=security_price)
position_value = position.market_value()
position_exposure = position.total_exposure()
self.net_liquidation += position_value
self.gross_exposure_of_positions += abs(position_exposure)

current_exchange_rate = self._current_exchange_rate(ticker.currency) if self.currency is not None else 1.
self.net_liquidation += position_value*current_exchange_rate
self.gross_exposure_of_positions += abs(position_exposure)*current_exchange_rate

if record:
current_positions[ticker] = BacktestPositionSummary(position)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ def _compute_target_value(self, signal: Signal, frequency=Frequency.DAILY) -> fl
"""
ticker: Ticker = signal.ticker

portfolio_value = self._broker.get_portfolio_value()
portfolio_value = self._broker.get_portfolio_value(ticker.currency)
target_percentage = self._compute_target_percentage(signal)
target_value = portfolio_value * target_percentage

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from qf_lib.common.utils.miscellaneous.to_list_conversion import convert_to_list
from qf_lib.containers.helpers import compute_container_hash
from qf_lib.data_providers.abstract_price_data_provider import AbstractPriceDataProvider
from qf_lib.data_providers.exchange_rate_provider import ExchangeRateProvider
from qf_lib.data_providers.prefetching_data_provider import PrefetchingDataProvider


Expand Down Expand Up @@ -80,6 +81,14 @@ def use_data_preloading(self, tickers: Union[Ticker, Sequence[Ticker]], time_del
# the same set of tickers and fields
tickers, _ = convert_to_list(tickers, Ticker)

if self.portfolio.currency is not None and isinstance(self.data_provider, ExchangeRateProvider):
currencies = set(ticker.currency for ticker in tickers)
currencies.discard(self.portfolio.currency)
if currencies:
tickers = tickers + [
self.data_provider.create_exchange_rate_ticker(currency, self.portfolio.currency) for currency in currencies
]

self.data_provider = PrefetchingDataProvider(self.data_provider, sorted(tickers), sorted(PriceField.ohlcv()),
data_start, self.end_date, self.frequency,
timer=self.data_provider.timer)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def __init__(self, settings: Settings, pdf_exporter: PDFExporter, excel_exporter

self._backtest_name = "Backtest Results"
self._initial_cash = 10000000
self._currency = None
self._initial_risk = None
self._benchmark_tms = None
self._monitor_settings = None
Expand Down Expand Up @@ -195,6 +196,17 @@ def set_initial_cash(self, initial_cash: int):
assert type(initial_cash) is int and initial_cash > 0
self._initial_cash = initial_cash

@ConfigExporter.update_config
def set_portfolio_currency(self, currency: str):
"""Sets the portfolio currency.
Parameters
-----------
currency: str
ISO code of the portfolio currency (Ex. 'EUR' for Euro)
"""
self._currency = currency

@ConfigExporter.update_config
def set_initial_risk(self, initial_risk: float):
"""Sets the initial risk value.
Expand Down Expand Up @@ -392,7 +404,7 @@ def build(self, start_date: datetime, end_date: datetime) -> BacktestTradingSess

signals_register = self._signals_register if self._signals_register else BacktestSignalsRegister()

self._portfolio = Portfolio(self._data_provider, self._initial_cash)
self._portfolio = Portfolio(self._data_provider, self._initial_cash, self._currency)

self._backtest_result = BacktestResult(self._portfolio, signals_register, self._backtest_name, start_date,
end_date, self._initial_risk)
Expand Down
Loading

0 comments on commit eadf8df

Please sign in to comment.