Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Currencies in backtest #164

Merged
merged 42 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
2538007
First draft of currency parameter in backtest
eirik-thorp Jun 3, 2024
e4cd1a5
Fix trailing whitespace
eirik-thorp Jun 3, 2024
9a8e7a4
Update currency implementation for backtest
eirik-thorp Jun 4, 2024
c4365b3
Add exchange rate ticker class
eirik-thorp Jun 4, 2024
b33ffe7
Fix Binance ticker
eirik-thorp Jun 4, 2024
b2a67db
Merge branch 'master' of github.com:quarkfin/qf-lib into backtest-cur…
eirik-thorp Jul 8, 2024
5c7c500
Removed ColorBar and ValueAnnotations from HeatmapChart. Added defaul…
Jul 9, 2024
0bab80d
Updated heatmap chart
Jul 10, 2024
066818f
Merge branch 'heatmap_fix' of github.com:quarkfin/qf-lib into backtes…
eirik-thorp Jul 10, 2024
76cb301
Remove currency enum and refactor currencies in backtest
eirik-thorp Jul 12, 2024
67a7416
Merge branch 'master' of github.com:quarkfin/qf-lib into backtest-cur…
eirik-thorp Jul 12, 2024
2557623
Remove currency enum file
eirik-thorp Jul 12, 2024
9652658
Fix target division error causing tests to fail
eirik-thorp Jul 12, 2024
e99779d
Merge branch 'master' of github.com:quarkfin/qf-lib into backtest-cur…
eirik-thorp Aug 16, 2024
a0c6b45
Add tests for portfolio with currency
eirik-thorp Sep 3, 2024
0fa4238
Add dummy tickers for currency exchange
eirik-thorp Sep 3, 2024
8bee4d9
Remove unused import
eirik-thorp Sep 3, 2024
a6e40e9
Reformat tests
eirik-thorp Sep 3, 2024
280db2d
Add tests for portfolio with currency
eirik-thorp Sep 10, 2024
f5c7bae
change naming in currency ticker
eirik-thorp Nov 5, 2024
8e9cda2
Fix tests and remove dummy exchange ticker
eirik-thorp Nov 5, 2024
b0c3e21
Fix trailing whitespace
eirik-thorp Nov 5, 2024
35c6cb6
Refactor currency exhanges
eirik-thorp Nov 6, 2024
16d827a
Remove abstract method from base data provider class
eirik-thorp Nov 6, 2024
127bc4a
Add currencies to data handler
eirik-thorp Nov 7, 2024
a8ba3b8
Fix exchange rate ticker in backtest trading session
eirik-thorp Nov 7, 2024
4ac00d2
Add support for currencies in prefetching dataprovider
eirik-thorp Nov 11, 2024
4079b20
Remove unused import
eirik-thorp Nov 11, 2024
18104ea
Avoid currency conversion between identical currencies
eirik-thorp Nov 11, 2024
7edfdb5
Refactor currency exchange
eirik-thorp Nov 14, 2024
c36c2c2
Add default frequency to mock dataprovider in portfolio tests
eirik-thorp Nov 14, 2024
3af8400
Change default currency in backtest trading session builder to None
eirik-thorp Nov 14, 2024
1ca67e6
Add pass to abstract class method
eirik-thorp Nov 15, 2024
2fa4b7c
Refactor data handler
eirik-thorp Nov 26, 2024
4031aad
Fix portfolio.py
eirik-thorp Nov 26, 2024
4b2fc72
Remove data handler from tests
eirik-thorp Nov 26, 2024
bb53f48
Refactor data_handler
eirik-thorp Nov 28, 2024
d783714
Fix flake8
eirik-thorp Nov 28, 2024
aa04e56
Add info in NotImplementedError
eirik-thorp Nov 29, 2024
5f56d97
Add test to assert NotImplementedError
eirik-thorp Nov 29, 2024
3367253
Remove hardcoded values
eirik-thorp Nov 29, 2024
6626c0d
Minor changes
eirik-thorp Nov 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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)

Check warning on line 34 in qf_lib/backtesting/broker/backtest_broker.py

View check run for this annotation

Codecov / codecov/patch

qf_lib/backtesting/broker/backtest_broker.py#L34

Added line #L34 was not covered by tests
eirik-thorp marked this conversation as resolved.
Show resolved Hide resolved
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: str = None) -> float:
eirik-thorp marked this conversation as resolved.
Show resolved Hide resolved
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
41 changes: 37 additions & 4 deletions qf_lib/backtesting/portfolio/portfolio.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from qf_lib.backtesting.portfolio.position_factory import BacktestPositionFactory
from qf_lib.backtesting.portfolio.transaction import Transaction
from qf_lib.backtesting.portfolio.utils import split_transaction_if_needed
from qf_lib.common.tickers.exchange_rate_ticker import CurrencyExchangeTicker
from qf_lib.common.tickers.tickers import Ticker
from qf_lib.common.utils.dateutils.timer import Timer
from qf_lib.common.utils.logging.qf_parent_logger import qf_logger
Expand All @@ -28,14 +29,20 @@


class Portfolio:
def __init__(self, data_handler: DataHandler, initial_cash: float, timer: Timer):
def __init__(self, data_handler: DataHandler, initial_cash: float, timer: Timer,
currency: str = None, currency_exchange_tickers: List[CurrencyExchangeTicker] = None):
eirik-thorp marked this conversation as resolved.
Show resolved Hide resolved
"""
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_handler = data_handler
self.timer = timer
self.currency = currency

if self.currency is not None:
assert currency_exchange_tickers is not None
self.currency_exchange_tickers = {(t.from_currency, t.to_currency): t for t in currency_exchange_tickers}

self.net_liquidation = initial_cash
""" Cash value includes futures P&L + stock value + securities options value + bond value + fund value. """
Expand Down Expand Up @@ -64,6 +71,26 @@

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

def _get_currency_ticker(self, from_currency: str, to_currency: str) -> CurrencyExchangeTicker:
ticker = self.currency_exchange_tickers.get((from_currency, to_currency))
if not ticker:
raise ValueError(f"No currency exchange ticker found from {from_currency} to {to_currency}.")

Check warning on line 77 in qf_lib/backtesting/portfolio/portfolio.py

View check run for this annotation

Codecov / codecov/patch

qf_lib/backtesting/portfolio/portfolio.py#L77

Added line #L77 was not covered by tests
return ticker

def _current_exchange_rate(self, currency: str) -> float:
"""Last available exchange rate from the specified currency to the portfolio currency."""
if currency != self.currency:
currency_ticker = self._get_currency_ticker(from_currency=currency, to_currency=self.currency)
return self.data_handler.get_last_available_price(tickers=currency_ticker)/currency_ticker.point_value
return 1.

def net_liquidation_in_currency(self, currency: str = None) -> float:
"""Converts the current net liquidation from the portfolio currency into the specified currency"""
if 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 @@ -90,7 +117,10 @@
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 @@ -111,8 +141,11 @@
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 @@ -46,6 +46,7 @@
from qf_lib.common.utils.dateutils.relative_delta import RelativeDelta
from qf_lib.common.utils.dateutils.timer import SettableTimer
from qf_lib.common.utils.logging.qf_parent_logger import qf_logger
from qf_lib.common.tickers.exchange_rate_ticker import CurrencyExchangeTicker
from qf_lib.containers.series.qf_series import QFSeries
from qf_lib.data_providers.data_provider import DataProvider
from qf_lib.documents_utils.document_exporting.pdf_exporter import PDFExporter
Expand Down Expand Up @@ -81,9 +82,11 @@ def __init__(self, settings: Settings, pdf_exporter: PDFExporter, excel_exporter

self._backtest_name = "Backtest Results"
self._initial_cash = 10000000
self._currency = "USD"
eirik-thorp marked this conversation as resolved.
Show resolved Hide resolved
self._initial_risk = None
self._benchmark_tms = None
self._monitor_settings = None
self.currency_exchange_tickers = None

self._contract_ticker_mapper = SimulatedContractTickerMapper()

Expand Down Expand Up @@ -333,6 +336,9 @@ def set_position_sizer(self, position_sizer_type: Type[PositionSizer], **kwargs)
except TypeError as e:
self._logger.error("The Position Sizer could not be set correctly - {}".format(e))

def set_currency_exchange_tickers(self, currency_exchange_tickers: List[CurrencyExchangeTicker]):
self.currency_exchange_tickers = currency_exchange_tickers

@ConfigExporter.append_config
def add_orders_filter(self, orders_filter_type: Type[OrdersFilter], **kwargs):
"""Adds orders filter to the pipeline. Ths parameters to initialize the OrdersFilter should be passed as keyword
Expand Down Expand Up @@ -408,7 +414,8 @@ def build(self, start_date: datetime, end_date: datetime) -> BacktestTradingSess
self._data_handler = self._create_data_handler(self._data_provider, self._timer)
signals_register = self._signals_register if self._signals_register else BacktestSignalsRegister()

self._portfolio = Portfolio(self._data_handler, self._initial_cash, self._timer)
self._portfolio = Portfolio(self._data_handler, self._initial_cash, self._timer,
self._currency, self.currency_exchange_tickers)

self._backtest_result = BacktestResult(self._portfolio, signals_register, self._backtest_name, start_date,
end_date, self._initial_risk)
Expand Down
63 changes: 63 additions & 0 deletions qf_lib/common/tickers/exchange_rate_ticker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
from typing import Optional, Sequence, Union
from qf_lib.common.enums.security_type import SecurityType
from qf_lib.common.tickers.tickers import BloombergTicker, Ticker


class CurrencyExchangeTicker(Ticker):
"""Ticker representing a foreign exchange rate from one ticker to another.

Parameters
----------
ticker: str
identifier of the security in a specific database.
from_currency: str
eirik-thorp marked this conversation as resolved.
Show resolved Hide resolved
The ISO code of the from currency in the exchange rate.
to_currency: str
The ISO code of the to currency in the exchange rate.
point_value: int
Used to define the size of the contract.
eirik-thorp marked this conversation as resolved.
Show resolved Hide resolved
security_type: SecurityType
Enum which denotes the type of the security.

"""
def __init__(self, ticker: str, from_currency: str, to_currency: str, point_value: int = 1,
security_type: SecurityType = SecurityType.FX):
super().__init__(ticker, security_type, point_value, to_currency)
self.from_currency = from_currency
self.to_currency = to_currency

def from_string(cls, ticker_str: Union[str, Sequence[str]], security_type: SecurityType = SecurityType.FX,
point_value: int = 1, currency: Optional[str] = None
) -> Union["CurrencyExchangeTicker", Sequence["CurrencyExchangeTicker"]]:
if isinstance(ticker_str, str):
return CurrencyExchangeTicker(ticker_str, security_type, point_value, currency)

Check warning on line 33 in qf_lib/common/tickers/exchange_rate_ticker.py

View check run for this annotation

Codecov / codecov/patch

qf_lib/common/tickers/exchange_rate_ticker.py#L32-L33

Added lines #L32 - L33 were not covered by tests


class BloombergCurrencyExchangeTicker(BloombergTicker):
eirik-thorp marked this conversation as resolved.
Show resolved Hide resolved
"""BloombergTicker representing a foreign exchange rate from one ticker to another.

Parameters
----------
ticker: str
identifier of the security in a specific database.
from_currency: str
The ISO code of the from currency in the exchange rate.
to_currency: str
The ISO code of the to currency in the exchange rate.
point_value: int
Used to define the size of the contract.
security_type: SecurityType
Enum which denotes the type of the security.

"""
def __init__(self, ticker: str, from_currency: str, to_currency: str, point_value: int = 1,
security_type: SecurityType = SecurityType.FX):
super().__init__(ticker, security_type, point_value, to_currency)
self.from_currency = from_currency
self.to_currency = to_currency

Check warning on line 57 in qf_lib/common/tickers/exchange_rate_ticker.py

View check run for this annotation

Codecov / codecov/patch

qf_lib/common/tickers/exchange_rate_ticker.py#L55-L57

Added lines #L55 - L57 were not covered by tests

def from_string(cls, ticker_str: Union[str, Sequence[str]], security_type: SecurityType = SecurityType.FX,
point_value: int = 1, currency: Optional[str] = None
) -> Union["CurrencyExchangeTicker", Sequence["CurrencyExchangeTicker"]]:
if isinstance(ticker_str, str):
return CurrencyExchangeTicker(ticker_str, security_type, point_value, currency)

Check warning on line 63 in qf_lib/common/tickers/exchange_rate_ticker.py

View check run for this annotation

Codecov / codecov/patch

qf_lib/common/tickers/exchange_rate_ticker.py#L62-L63

Added lines #L62 - L63 were not covered by tests
Loading
Loading