Skip to content

Commit

Permalink
Added test/samples and improved docs
Browse files Browse the repository at this point in the history
  • Loading branch information
jbaron committed Dec 13, 2024
1 parent 7f17acd commit 7b3fc0f
Show file tree
Hide file tree
Showing 13 changed files with 98 additions and 29 deletions.
9 changes: 8 additions & 1 deletion roboquant/brokers/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]):
Expand Down Expand Up @@ -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)

Expand Down
6 changes: 4 additions & 2 deletions roboquant/brokers/ibkr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion roboquant/brokers/simbroker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
Expand Down
15 changes: 11 additions & 4 deletions roboquant/config.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import os
import os.path
from pathlib import Path
from configparser import ConfigParser


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:
Expand Down
3 changes: 3 additions & 0 deletions roboquant/feeds/avro.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion roboquant/monetary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
14 changes: 11 additions & 3 deletions roboquant/order.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions roboquant/signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion roboquant/strategies/emacrossover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__()
Expand Down
6 changes: 3 additions & 3 deletions roboquant/strategies/multistrategy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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] = []
Expand Down
10 changes: 5 additions & 5 deletions roboquant/traders/trader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 23 additions & 8 deletions tests/samples/walkforward_multiprocess.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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))

19 changes: 19 additions & 0 deletions tests/unit/test_config.py
Original file line number Diff line number Diff line change
@@ -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()

0 comments on commit 7b3fc0f

Please sign in to comment.