diff --git a/roboquant/brokers/broker.py b/roboquant/brokers/broker.py index 3dccd59..3ff4d56 100644 --- a/roboquant/brokers/broker.py +++ b/roboquant/brokers/broker.py @@ -7,7 +7,9 @@ class Broker(ABC): - """A broker accepts orders and communicates its latest state through the account object""" + """A broker accepts orders and communicates its latest state through the account object when + the `sync` method is invoked. + """ @abstractmethod def place_orders(self, orders: list[Order]): @@ -70,6 +72,11 @@ def __init__(self) -> None: self.max_delay = timedelta(minutes=30) def guard(self, event: Event | None = None) -> datetime: + """This method will evaluate an event and if it occurs to far in the past, + it will raise a ValueError. Implementations of `LiveBroker` should call this + method in their `sync` implementation to ensure the `LiveBroker` isn't used + in a back test. + """ now = datetime.now(timezone.utc) diff --git a/roboquant/brokers/ibkr.py b/roboquant/brokers/ibkr.py index 89611b4..4b01b44 100644 --- a/roboquant/brokers/ibkr.py +++ b/roboquant/brokers/ibkr.py @@ -51,7 +51,8 @@ def get_next_order_id(self): def position(self, account: str, contract: Contract, position: Decimal, avgCost: float): logger.debug("position=%s symbol=%s avgCost=%s", position, contract.localSymbol, avgCost) symbol = contract.localSymbol or contract.symbol - asset = Stock(symbol, contract.currency) + currency = Currency(contract.currency) + asset = Stock(symbol, currency) old_position = self.positions.get(asset) mkt_price = old_position.mkt_price if old_position else avgCost self.positions[asset] = Position(position, avgCost, mkt_price) @@ -75,7 +76,8 @@ def openOrder(self, orderId: int, contract, order: IBKROrder, orderState: OrderS ) size = order.totalQuantity if order.action == "BUY" else -order.totalQuantity symbol = contract.localSymbol - asset = Stock(symbol, contract.currency) + currency = Currency(contract.currency) + asset = Stock(symbol, currency) rq_order = Order(asset, size, order.lmtPrice) rq_order.id = str(orderId) # rq_order.created_at = datetime.fromisoformat(order.activeStartTime) diff --git a/roboquant/brokers/simbroker.py b/roboquant/brokers/simbroker.py index a924bbf..213126c 100644 --- a/roboquant/brokers/simbroker.py +++ b/roboquant/brokers/simbroker.py @@ -22,7 +22,7 @@ class _Trx: class SimBroker(Broker): - """Implementation of a Broker that simulates order execution. + """Implementation of a Broker that simulates order execution and can be used in back tests. This class can be extended to support different types of use-cases, like margin trading. """ diff --git a/roboquant/config.py b/roboquant/config.py index 829c842..5f0fbc3 100644 --- a/roboquant/config.py +++ b/roboquant/config.py @@ -1,5 +1,6 @@ import os import os.path +from pathlib import Path from configparser import ConfigParser @@ -7,13 +8,19 @@ class Config: """Access to a roboquant configuration file. This allows sharing the same property file between both the Python and Kotlin version of roboquant. + + If no path is provided, the Config will search for "~/.roboquant/.env" """ - def __init__(self, path=None): - path = path or os.path.expanduser("~/.roboquant/.env") - with open(path, "r", encoding="utf8") as f: - config_string = "[default]\n" + f.read() + def __init__(self, path: Path | str | None = None): self.config = ConfigParser() + if path: + assert Path(path).exists(), "invalid path" + path = path or os.path.expanduser("~/.roboquant/.env") + config_string = "[default]\n" + if Path(path).exists(): + with open(path, "r", encoding="utf8") as f: + config_string += f.read() self.config.read_string(config_string) def get(self, key: str) -> str: diff --git a/roboquant/feeds/avro.py b/roboquant/feeds/avro.py index 8f9bed8..2c59a0c 100644 --- a/roboquant/feeds/avro.py +++ b/roboquant/feeds/avro.py @@ -16,6 +16,9 @@ class AvroFeed(Feed): + """Feed that uses Avro files to store historic prices. Supports Quotes, Trades and Bars. + Besides play back, there is also recording functionality to transfer another feed into an AvroFeed. + """ _schema = { "namespace": "org.roboquant.avro.schema", diff --git a/roboquant/monetary.py b/roboquant/monetary.py index 9f21cca..f4846fe 100644 --- a/roboquant/monetary.py +++ b/roboquant/monetary.py @@ -19,7 +19,13 @@ class Currency(str): - """Currency class represents a monetary currency and is s subclass of `str`""" + """Currency class represents a monetary currency and is a subclass of `str`. + + It is possible to create an `Amount` using a combination of `number` and `Currency`: + + amount1 = 100@USD + amount2 = 200.50@EUR + """ def __rmatmul__(self, other: float | int): assert isinstance(other, (float, int)) diff --git a/roboquant/order.py b/roboquant/order.py index 786d072..3baf3ab 100644 --- a/roboquant/order.py +++ b/roboquant/order.py @@ -11,9 +11,14 @@ @dataclass(slots=True) class Order: """ - A trading order for an asset. - The `id` is automatically assigned by the broker and should not be set manually. - Also, the `fill` are managed by the broker and should not be manually set. + A trading order for an asset. Each order has a `size` and a `limit` price. + The `gtd` (good till date) is optional and if not set implies the order is valid + for ever. + + The `info` will hold any abritrary properties set on the order. + + The `id` is automatically assigned by the `Broker` and should not be set manually. + Also, the `fill` is managed by the broker and should not be manually set. """ asset: Asset @@ -46,6 +51,7 @@ def cancel(self) -> "Order": return result def is_expired(self, dt: datetime) -> bool: + """Return True of this order has expired, False otherwise""" return dt > self.gtd if self.gtd else False def modify(self, size: Decimal | str | int | float | None = None, limit: float | None = None) -> "Order": @@ -70,9 +76,11 @@ def __deepcopy__(self, memo): return result def value(self) -> float: + """Return the total value of this order""" return self.asset.contract_value(self.size, self.limit) def amount(self) -> Amount: + """Return the total vlaue of this order as an Amount""" return Amount(self.asset.currency, self.value()) @property diff --git a/roboquant/signal.py b/roboquant/signal.py index 7b541f8..43201db 100644 --- a/roboquant/signal.py +++ b/roboquant/signal.py @@ -27,6 +27,8 @@ class Signal: A rating is a float normally between -1.0 and 1.0, where -1.0 is a strong sell, and 1.0 is a strong buy. But this range isn't enforced. It is up to the used trader to handle these values. + The type indicates if it is an ENTRY, EXIT or ENTRY_EXIT signal. + Examples: Signal.buy("XYZ") Signal.sell("XYZ", SignalType.EXIT) diff --git a/roboquant/strategies/emacrossover.py b/roboquant/strategies/emacrossover.py index 4b61bca..f833bb8 100644 --- a/roboquant/strategies/emacrossover.py +++ b/roboquant/strategies/emacrossover.py @@ -7,7 +7,7 @@ class EMACrossover(Strategy): - """EMA Crossover Strategy that server as an example strategy.""" + """EMA Crossover Strategy that serves as an example strategy.""" def __init__(self, fast_period=13, slow_period=26, smoothing=2.0, price_type="DEFAULT"): super().__init__() diff --git a/roboquant/strategies/multistrategy.py b/roboquant/strategies/multistrategy.py index 4a9a5df..8aba884 100644 --- a/roboquant/strategies/multistrategy.py +++ b/roboquant/strategies/multistrategy.py @@ -8,7 +8,7 @@ class MultiStrategy(Strategy): - """Combine one or more signal strategies. The MultiStrategy provides additional control on how to handle conflicting + """Combine one or more strategies. The MultiStrategy provides additional control on how to handle conflicting signals for the same asset via the signal_filter: - first: in case of multiple signals for the same asset, the first one wins @@ -20,11 +20,11 @@ class MultiStrategy(Strategy): def __init__( self, *strategies: Strategy, - order_filter: Literal["last", "first", "none", "mean"] = "none" + signal_filter: Literal["last", "first", "none", "mean"] = "none" ): super().__init__() self.strategies = list(strategies) - self.filter = order_filter + self.filter = signal_filter def create_signals(self, event: Event) -> list[Signal]: signals: list[Signal] = [] diff --git a/roboquant/traders/trader.py b/roboquant/traders/trader.py index 98e28c8..5cd7c60 100644 --- a/roboquant/traders/trader.py +++ b/roboquant/traders/trader.py @@ -7,12 +7,12 @@ class Trader(ABC): - """A trader creates the orders, typically based on the signals it receives from a strategy. - + """A Trader creates orders, typically based on the signals it receives from a strategy. But it is also possible to implement all logic in a Trader and don't rely on signals at all. - In contrast to a `Strategy`, a `Trader` can also access the `Account` object. - So, for example, it is possible to create sell orders for open positions that have a large - unrealized loss. + + In contrast to a `Strategy`, a `Trader` has also access the `Account` object and can make + decisions based on that state. So, for example, it is possible to create sell orders + for open positions that have a large unrealized loss. """ @abstractmethod diff --git a/tests/samples/walkforward_multiprocess.py b/tests/samples/walkforward_multiprocess.py index 01eccec..62f5d9e 100644 --- a/tests/samples/walkforward_multiprocess.py +++ b/tests/samples/walkforward_multiprocess.py @@ -1,4 +1,9 @@ +"""This example shows how to perform a walk-forward using a multi-process approach. +Each run is over a certain timeframe and set of parameters for the EMA Crossover strategy. +""" + from multiprocessing import get_context +from itertools import product import roboquant as rq @@ -7,20 +12,30 @@ print(FEED) -def _walkforward(timeframe: rq.Timeframe): +def _walkforward(params): """Perform a run over the provided timeframe""" - print("starting:", timeframe) - strategy = rq.strategies.EMACrossover() + timeframe, (fast, slow) = params + strategy = rq.strategies.EMACrossover(fast, slow) acc = rq.run(FEED, strategy, timeframe=timeframe) - print(timeframe, "==>", acc.equity()) - + print(f"{timeframe} EMA({fast:2},{slow:2}) ==> {acc.equity()}") + return acc.equity_value() if __name__ == "__main__": # Using "fork" ensures that the FEED object is not being created for each process # The pool is created with default number of processes (equals CPU cores available) with get_context("fork").Pool() as p: - # Perform a walkforward over 8 equal timeframes - timeframes = FEED.timeframe().split(8) - p.map(_walkforward, timeframes) + # Split overal timeframe into 5 equal non-overlapping timeframes + timeframes = FEED.timeframe().split(5) + + # Test the following combinations of parameters for EMACrossover strategy + ema_params = [(3, 5), (5, 7), (10, 15), (15, 21)] + params = product(timeframes, ema_params) + + # run the back tests in parallel + equities = p.map(_walkforward, params) + + # print some result + print("max equity =>", max(equities)) + print("min equity =>", min(equities)) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py new file mode 100644 index 0000000..2aefb59 --- /dev/null +++ b/tests/unit/test_config.py @@ -0,0 +1,19 @@ +from configparser import NoOptionError +import unittest + +import roboquant as rq + + +class TestConfig(unittest.TestCase): + + def test_config(self): + config = rq.Config() + with self.assertRaises(NoOptionError): + config.get("unknown_key") + + with self.assertRaises(AssertionError): + config = rq.Config("non_existing_file.conf") + + +if __name__ == "__main__": + unittest.main()