diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 3d5169d..6535765 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -19,9 +19,9 @@ jobs: python-version: ["3.10", "3.11"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/bin/local_install.sh b/bin/local_install.sh new file mode 100755 index 0000000..8dac796 --- /dev/null +++ b/bin/local_install.sh @@ -0,0 +1,18 @@ + +[[ ! -f "LICENSE" ]] && echo "run the script from the project root directory like this: ./bin/publish.sh" && exit 1 + +source .venv/bin/activate + +rm -rf ./runs + +# QA +flake8 roboquant tests || exit 1 +pylint roboquant tests || exit 1 +python -m unittest discover -s tests/unit || exit 1 + +# Build +rm -rf dist +python -m build || exit 1 + +# Install +pip install . diff --git a/roboquant/__init__.py b/roboquant/__init__.py index 1773ac8..92a9680 100644 --- a/roboquant/__init__.py +++ b/roboquant/__init__.py @@ -11,5 +11,5 @@ from .event import Event, PriceItem, Bar, Trade, Quote from .order import Order, OrderStatus from .run import run -from .signal import SignalType, Signal, BUY, SELL +from .signal import SignalType, Signal from .timeframe import Timeframe diff --git a/roboquant/brokers/alpacabroker.py b/roboquant/brokers/alpacabroker.py index 16ad696..f765375 100644 --- a/roboquant/brokers/alpacabroker.py +++ b/roboquant/brokers/alpacabroker.py @@ -1,6 +1,5 @@ import logging import time -from datetime import datetime, timezone, timedelta from decimal import Decimal from alpaca.trading.client import TradingClient from alpaca.trading.enums import OrderSide, TimeInForce @@ -13,16 +12,17 @@ from roboquant.account import Account, Position from roboquant.config import Config from roboquant.event import Event -from roboquant.brokers.broker import Broker +from roboquant.brokers.broker import LiveBroker from roboquant.order import Order, OrderStatus logger = logging.getLogger(__name__) -class AlpacaBroker(Broker): +class AlpacaBroker(LiveBroker): def __init__(self, api_key=None, secret_key=None) -> None: + super().__init__() self.__account = Account() config = Config() api_key = api_key or config.get("alpaca.public.key") @@ -59,13 +59,7 @@ def _sync_positions(self): self.__account.positions[p.symbol] = new_pos def sync(self, event: Event | None = None) -> Account: - now = datetime.now(timezone.utc) - - if event: - # Let make sure we don't use IBKRBroker by mistake during a back-test. - if now - event.time > timedelta(minutes=30): - logger.critical("received event from the past, now=%s event-time=%s", now, event.time) - raise ValueError(f"received event too far in the past now={now} event-time={event.time}") + now = self.guard(event) client = self.__client acc: TradeAccount = client.get_account() # type: ignore @@ -122,8 +116,17 @@ def _get_replace_request(self, order: Order): broker = AlpacaBroker() account = broker.sync() print(account) + tsla_order = Order("TSLA", 10) broker.place_orders([tsla_order]) time.sleep(5) account = broker.sync() print(account) + + tesla_size = account.get_position_size("TSLA") + if tesla_size: + tsla_order = Order("TSLA", -tesla_size) + broker.place_orders([tsla_order]) + time.sleep(5) + account = broker.sync() + print(account) diff --git a/roboquant/brokers/broker.py b/roboquant/brokers/broker.py index b32f6ae..97bed36 100644 --- a/roboquant/brokers/broker.py +++ b/roboquant/brokers/broker.py @@ -60,11 +60,14 @@ def __init__(self) -> None: super().__init__() self.max_delay = timedelta(minutes=30) - def guard(self, event: Event | None = None): - if not event: - return + def guard(self, event: Event | None = None) -> datetime: now = datetime.now(timezone.utc) + if not event: + return now + if now - event.time > self.max_delay: raise ValueError(f"received event too far in the past now={now} event-time={event.time}") + + return now diff --git a/roboquant/brokers/ibkr.py b/roboquant/brokers/ibkr.py index 91243c4..cf086ce 100644 --- a/roboquant/brokers/ibkr.py +++ b/roboquant/brokers/ibkr.py @@ -1,7 +1,7 @@ import logging import threading import time -from datetime import datetime, timezone, timedelta +from datetime import datetime, timedelta from decimal import Decimal from ibapi import VERSION @@ -14,7 +14,7 @@ from roboquant.account import Account, Position from roboquant.event import Event from roboquant.order import Order, OrderStatus -from roboquant.brokers.broker import Broker, _update_positions +from roboquant.brokers.broker import LiveBroker, _update_positions assert VERSION["major"] == 10 and VERSION["minor"] == 19, "Wrong version of the IBAPI found" @@ -116,7 +116,7 @@ def orderStatus( logger.warning("received status for unknown order id=%s status=%s", orderId, status) -class IBKRBroker(Broker): +class IBKRBroker(LiveBroker): """ Attributes ========== @@ -137,6 +137,7 @@ class IBKRBroker(Broker): """ def __init__(self, host="127.0.0.1", port=4002, client_id=123) -> None: + super().__init__() self.__account = Account() self.contract_mapping: dict[str, Contract] = {} api = _IBApi() @@ -170,15 +171,7 @@ def _should_sync(self, now: datetime): def sync(self, event: Event | None = None) -> Account: """Sync with the IBKR account""" - - logger.debug("start sync") - now = datetime.now(timezone.utc) - - if event: - # Let make sure we don't use IBKRBroker by mistake during a back-test. - if now - event.time > timedelta(minutes=30): - logger.critical("received event from the past, now=%s event-time=%s", now, event.time) - raise ValueError(f"received event too far in the past now={now} event-time={event.time}") + now = self.guard(event) api = self.__api acc = self.__account @@ -196,7 +189,6 @@ def sync(self, event: Event | None = None) -> Account: acc.cash = api.get_cash() _update_positions(acc, event) - logger.debug("end sync") return acc def place_orders(self, orders): diff --git a/roboquant/journals/journal.py b/roboquant/journals/journal.py index 5160d95..fadddde 100644 --- a/roboquant/journals/journal.py +++ b/roboquant/journals/journal.py @@ -14,7 +14,7 @@ class Journal(Protocol): It serves as a tool to track and analyze their performance, decisions, and outcomes over time """ - def track(self, event: Event, account: Account, signals: dict[str, Signal], orders: list[Order]): + def track(self, event: Event, account: Account, signals: list[Signal], orders: list[Order]): """invoked at each step of a run that provides the journal with the opportunity to track and log various metrics.""" ... diff --git a/roboquant/ml/envs.py b/roboquant/ml/envs.py index 5601ddf..a72a722 100644 --- a/roboquant/ml/envs.py +++ b/roboquant/ml/envs.py @@ -28,7 +28,7 @@ def __init__(self, symbols: list[str]): self.symbols = symbols def get_signals(self, action, _): - return {symbol: Signal(rating) for symbol, rating in zip(self.symbols, action)} + return [Signal(symbol, float(rating)) for symbol, rating in zip(self.symbols, action)] def get_action_space(self): return spaces.Box(-1.0, 1.0, shape=(len(self.symbols),), dtype=np.float32) @@ -137,7 +137,7 @@ def reset(self, *, seed=None, options=None): self.event = self.channel.get() assert self.event is not None, "feed empty during warmup" self.account = self.broker.sync(self.event) - self.trader.create_orders({}, self.event, self.account) + self.trader.create_orders([], self.event, self.account) observation = self.get_observation(self.event) self.get_reward(self.event, self.account) if not np.any(np.isnan(observation)): diff --git a/roboquant/ml/strategies.py b/roboquant/ml/strategies.py index 173f22b..c488eb3 100644 --- a/roboquant/ml/strategies.py +++ b/roboquant/ml/strategies.py @@ -10,7 +10,7 @@ from roboquant.ml.envs import Action2Signals, StrategyEnv, TraderEnv from roboquant.ml.features import Feature, NormalizeFeature from roboquant.order import Order -from roboquant.signal import BUY, SELL, Signal +from roboquant.signal import Signal from roboquant.strategies.strategy import Strategy from roboquant.traders.trader import Trader @@ -30,7 +30,7 @@ def __init__(self, obs_feature: Feature, action_2_signals: Action2Signals, polic def from_env(cls, env: StrategyEnv, policy): return cls(env.obs_feature, env.action_2_signals, policy) - def create_signals(self, event) -> dict[str, Signal]: + def create_signals(self, event): obs = self.obs_feature.calc(event, None) if np.any(np.isnan(obs)): return {} @@ -81,17 +81,17 @@ def __init__(self, input_feature: Feature, label_feature: Feature, history: int, self._hist = deque(maxlen=history) self._dtype = dtype - def create_signals(self, event: Event) -> dict[str, Signal]: + def create_signals(self, event: Event): h = self._hist row = self.input_feature.calc(event, None) h.append(row) if len(h) == h.maxlen: x = np.asarray(h, dtype=self._dtype) return self.predict(x) - return {} + return [] @abstractmethod - def predict(self, x: NDArray) -> dict[str, Signal]: ... + def predict(self, x: NDArray) -> list[Signal]: ... def _get_xy(self, feed, timeframe=None, warmup=0) -> tuple[NDArray, NDArray]: channel = feed.play_background(timeframe) @@ -153,7 +153,7 @@ def __init__( self.symbol = symbol self.prediction_results = [] - def predict(self, x) -> dict[str, Signal]: + def predict(self, x): x = torch.asarray(x) x = torch.unsqueeze(x, dim=0) # add the batch dimension @@ -168,9 +168,9 @@ def predict(self, x) -> dict[str, Signal]: self.prediction_results.append(p) if p >= self.buy_pct: - return {self.symbol: BUY} + return [Signal.buy(self.symbol)] if p <= self.sell_pct: - return {self.symbol: SELL} + return [Signal.sell(self.symbol)] return {} diff --git a/roboquant/run.py b/roboquant/run.py index 16e7f4a..4f2c84b 100644 --- a/roboquant/run.py +++ b/roboquant/run.py @@ -42,7 +42,7 @@ def run( while event := channel.get(heartbeat_timeout): account = broker.sync(event) - signals = strategy.create_signals(event) if strategy else {} + signals = strategy.create_signals(event) if strategy else [] orders = trader.create_orders(signals, event, account) broker.place_orders(orders) if journal: diff --git a/roboquant/signal.py b/roboquant/signal.py index 34229ea..dd65c3d 100644 --- a/roboquant/signal.py +++ b/roboquant/signal.py @@ -15,8 +15,10 @@ def __str__(self): @dataclass(slots=True, frozen=True) class Signal: - """Signal that a strategy can create. - It contains both a rating between -1.0 and 1.0 and the type of signal. + """Signal that a strategy can create.It contains both a rating and the type of 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 in cases it can exceed these values. It is up to the used trader to handle these values Examples: ``` @@ -25,19 +27,19 @@ class Signal: Signal("XYZ", 0.5, SignalType.ENTRY) ``` """ - + symbol: str rating: float type: SignalType = SignalType.ENTRY_EXIT @staticmethod - def buy(signal_type=SignalType.ENTRY_EXIT): + def buy(symbol, signal_type=SignalType.ENTRY_EXIT): """Create a BUY signal with a rating of 1.0""" - return Signal(1.0, signal_type) + return Signal(symbol, 1.0, signal_type) @staticmethod - def sell(signal_type=SignalType.ENTRY_EXIT): + def sell(symbol, signal_type=SignalType.ENTRY_EXIT): """Create a SELL signal with a rating of -1.0""" - return Signal(-1.0, signal_type) + return Signal(symbol, -1.0, signal_type) @property def is_buy(self): @@ -54,10 +56,3 @@ def is_entry(self): @property def is_exit(self): return SignalType.EXIT in self.type - - -BUY = Signal.buy(SignalType.ENTRY_EXIT) -"""BUY signal with a rating of 1.0 and valid for both entry and exit signals""" - -SELL = Signal.sell(SignalType.ENTRY_EXIT) -"""SELL signal with a rating of -1.0 and valid for both entry and exit signals""" diff --git a/roboquant/strategies/barstrategy.py b/roboquant/strategies/barstrategy.py index 675b8c1..8955581 100644 --- a/roboquant/strategies/barstrategy.py +++ b/roboquant/strategies/barstrategy.py @@ -19,8 +19,8 @@ def __init__(self, size: int) -> None: self._data: dict[str, OHLCVBuffer] = {} self.size = size - def create_signals(self, event) -> dict[str, Signal]: - signals = {} + def create_signals(self, event): + signals = [] for item in event.items: if isinstance(item, Bar): symbol = item.symbol @@ -31,7 +31,7 @@ def create_signals(self, event) -> dict[str, Signal]: if ohlcv.is_full(): signal = self._create_signal(symbol, ohlcv) if signal is not None: - signals[symbol] = signal + signals.append(signal) return signals @abstractmethod diff --git a/roboquant/strategies/emacrossover.py b/roboquant/strategies/emacrossover.py index 0a9b035..8dc9794 100644 --- a/roboquant/strategies/emacrossover.py +++ b/roboquant/strategies/emacrossover.py @@ -1,5 +1,5 @@ from roboquant.event import Event -from roboquant.signal import Signal, BUY, SELL +from roboquant.signal import Signal from roboquant.strategies.strategy import Strategy @@ -14,8 +14,8 @@ def __init__(self, fast_period=13, slow_period=26, smoothing=2.0, price_type="DE self.price_type = price_type self.min_steps = max(fast_period, slow_period) - def create_signals(self, event: Event) -> dict[str, Signal]: - signals: dict[str, Signal] = {} + def create_signals(self, event: Event): + signals = [] for symbol, price in event.get_prices(self.price_type).items(): if symbol not in self._history: @@ -28,7 +28,8 @@ def create_signals(self, event: Event) -> dict[str, Signal]: if step > self.min_steps: new_rating = calculator.is_above() if old_rating != new_rating: - signals[symbol] = BUY if new_rating else SELL + signal = Signal.buy(symbol) if new_rating else Signal.sell(symbol) + signals.append(signal) return signals diff --git a/roboquant/strategies/multistrategy.py b/roboquant/strategies/multistrategy.py index c608efc..def6836 100644 --- a/roboquant/strategies/multistrategy.py +++ b/roboquant/strategies/multistrategy.py @@ -1,7 +1,6 @@ from typing import Literal from roboquant.event import Event -from roboquant.signal import Signal from roboquant.strategies.strategy import Strategy @@ -13,23 +12,22 @@ class MultiStrategy(Strategy): - last: in case of multiple signals for a symbol, the last strategy wins. This is also the default policy """ - def __init__(self, *strategies: Strategy, policy: Literal["last", "first"] = "last"): + def __init__(self, *strategies: Strategy, policy: Literal["last", "first", "all"] = "last"): self.strategies = list(strategies) self.policy = policy - def create_signals(self, event: Event) -> dict[str, Signal]: - all_signals: list[dict[str, Signal]] = [] + def create_signals(self, event: Event): + signals = [] for strategy in self.strategies: - signals = strategy.create_signals(event) - all_signals.append(signals) + tmp = strategy.create_signals(event) + signals += tmp - result = {} match self.policy: case "last": - for signals in all_signals: - result.update(signals) + s = {s.symbol: s for s in signals} + return list(s.values()) case "first": - for signals in reversed(all_signals): - result.update(signals) - - return result + s = {s.symbol: s for s in reversed(signals)} + return list(s.values()) + case "all": + return signals diff --git a/roboquant/strategies/smacrossover.py b/roboquant/strategies/smacrossover.py index bccb1c4..81adc97 100644 --- a/roboquant/strategies/smacrossover.py +++ b/roboquant/strategies/smacrossover.py @@ -25,13 +25,13 @@ def __get_signal(self, symbol: str) -> None | Signal: if symbol in self._prev_ratings: prev_rating = self._prev_ratings[symbol] if prev_rating != new_rating: - result = Signal.buy() if new_rating else Signal.sell() + result = Signal.buy(symbol) if new_rating else Signal.sell(symbol) self._prev_ratings[symbol] = new_rating return result def create_signals(self, event): - signals: dict[str, Signal] = {} + signals = [] for (symbol, item) in event.price_items.items(): h = self._history.get(symbol) @@ -43,6 +43,6 @@ def create_signals(self, event): h.append(item.price()) if len(h) == h.maxlen: if signal := self.__get_signal(symbol): - signals[symbol] = signal + signals.append(signal) return signals diff --git a/roboquant/strategies/strategy.py b/roboquant/strategies/strategy.py index 1851686..2dc4ab6 100644 --- a/roboquant/strategies/strategy.py +++ b/roboquant/strategies/strategy.py @@ -11,7 +11,7 @@ class Strategy(ABC): """ @abstractmethod - def create_signals(self, event: Event) -> dict[str, Signal]: + def create_signals(self, event: Event) -> list[Signal]: """Create a signal for zero or more symbols. Signals are returned as a dictionary with key being the symbol and the value being the Signal. """ diff --git a/roboquant/traders/flextrader.py b/roboquant/traders/flextrader.py index cdf4362..01f0b17 100644 --- a/roboquant/traders/flextrader.py +++ b/roboquant/traders/flextrader.py @@ -109,14 +109,13 @@ def _get_order_size(self, rating: float, contract_price: float, max_order_value: rounded_size = round(size, self.size_digits) return rounded_size - def create_orders(self, signals: dict[str, Signal], event: Event, account: Account) -> list[Order]: + def create_orders(self, signals: list[Signal], event: Event, account: Account) -> list[Order]: # pylint: disable=too-many-branches,too-many-statements,too-many-locals if not signals: return [] - signals_items = list(signals.items()) if self.shuffle_signals: - random.shuffle(signals_items) + random.shuffle(signals) orders: list[Order] = [] equity = account.equity() @@ -125,7 +124,8 @@ def create_orders(self, signals: dict[str, Signal], event: Event, account: Accou max_pos_value = equity * self.max_position_perc available = account.buying_power - self.safety_margin_perc * equity - for symbol, signal in signals_items: + for signal in signals: + symbol = signal.symbol pos_size = account.get_position_size(symbol) if self.one_order_only and account.has_open_order(symbol): @@ -150,6 +150,9 @@ def create_orders(self, signals: dict[str, Signal], event: Event, account: Accou _log_rule("no exit signal", signal, symbol, pos_size) continue rounded_size = round(pos_size * Decimal(signal.rating), self.size_digits) + if rounded_size.is_zero(): + _log_rule("cannot exit with order size zero", signal, symbol, pos_size) + continue new_orders = self._get_orders(symbol, rounded_size, item, signal, event.time) orders += new_orders else: diff --git a/roboquant/traders/trader.py b/roboquant/traders/trader.py index 585d496..aea560a 100644 --- a/roboquant/traders/trader.py +++ b/roboquant/traders/trader.py @@ -14,7 +14,7 @@ class Trader(ABC): """ @abstractmethod - def create_orders(self, signals: dict[str, Signal], event: Event, account: Account) -> list[Order]: + def create_orders(self, signals: list[Signal], event: Event, account: Account) -> list[Order]: """Create zero or more orders. Arguments diff --git a/tests/common.py b/tests/common.py index d39e28a..7ac248d 100644 --- a/tests/common.py +++ b/tests/common.py @@ -64,7 +64,8 @@ def run_strategy(strategy: Strategy, test_case: TestCase): tot_ratings = 0 while event := channel.get(): signals = strategy.create_signals(event) - for symbol, signal in signals.items(): + for signal in signals: + symbol = signal.symbol test_case.assertEqual(type(signal), Signal) test_case.assertEqual(type(symbol), str) test_case.assertEqual(symbol, symbol.upper()) diff --git a/tests/unit/test_barstrategy.py b/tests/unit/test_barstrategy.py index 9cebb8d..ba07057 100644 --- a/tests/unit/test_barstrategy.py +++ b/tests/unit/test_barstrategy.py @@ -13,9 +13,9 @@ def _create_signal(self, symbol, ohlcv: OHLCVBuffer) -> Signal | None: sma12 = close[-12:].mean() sma26 = close[-26:].mean() if sma12 > sma26: - return Signal.buy() + return Signal.buy(symbol) if sma12 < sma26: - return Signal.sell() + return Signal.sell(symbol) return None diff --git a/tests/unit/test_signal.py b/tests/unit/test_signal.py index 0b06f95..cc497c4 100644 --- a/tests/unit/test_signal.py +++ b/tests/unit/test_signal.py @@ -1,12 +1,12 @@ import unittest -from roboquant import Signal, BUY, SELL, SignalType +from roboquant import Signal, SignalType class TestSignal(unittest.TestCase): def test_signal(self): - s = BUY + s = Signal.buy("XYZ") self.assertEqual(1.0, s.rating) self.assertTrue(s.is_buy) self.assertFalse(s.is_sell) @@ -16,14 +16,12 @@ def test_signal(self): self.assertTrue(SignalType.EXIT in s.type) def test_signal_equal(self): - x = SELL - y = Signal(-1.0, SignalType.ENTRY_EXIT) + x = Signal("XYZ", -1.0, SignalType.ENTRY_EXIT) + y = Signal("XYZ", -1.0, SignalType.ENTRY_EXIT) self.assertEqual(x, y) - self.assertEqual(BUY, BUY) - self.assertNotEqual(BUY, SELL) def test_signal_rating(self): - s = Signal(0.5, SignalType.ENTRY) + s = Signal("XYZ", 0.5, SignalType.ENTRY) self.assertEqual(0.5, s.rating) self.assertTrue(s.is_entry) self.assertFalse(s.is_exit)