roboquant 0.1.6__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- roboquant/__init__.py +33 -0
- roboquant/account.py +127 -0
- roboquant/brokers/__init__.py +2 -0
- roboquant/brokers/broker.py +18 -0
- roboquant/brokers/ibkrbroker.py +211 -0
- roboquant/brokers/simbroker.py +191 -0
- roboquant/config.py +20 -0
- roboquant/event.py +131 -0
- roboquant/feeds/__init__.py +13 -0
- roboquant/feeds/candlefeed.py +60 -0
- roboquant/feeds/csvfeed.py +95 -0
- roboquant/feeds/eventchannel.py +74 -0
- roboquant/feeds/feed.py +14 -0
- roboquant/feeds/feedutil.py +73 -0
- roboquant/feeds/historicfeed.py +58 -0
- roboquant/feeds/randomwalk.py +56 -0
- roboquant/feeds/sqllitefeed.py +103 -0
- roboquant/feeds/tiingohistoricfeed.py +125 -0
- roboquant/feeds/tiingolivefeed.py +126 -0
- roboquant/feeds/yahoofeed.py +51 -0
- roboquant/order.py +107 -0
- roboquant/roboquant.py +67 -0
- roboquant/strategies/__init__.py +7 -0
- roboquant/strategies/buffer.py +80 -0
- roboquant/strategies/candlestrategy.py +40 -0
- roboquant/strategies/emacrossover.py +57 -0
- roboquant/strategies/featureset.py +130 -0
- roboquant/strategies/multistrategy.py +45 -0
- roboquant/strategies/nopstrategy.py +13 -0
- roboquant/strategies/rnnstrategy.py +187 -0
- roboquant/strategies/smacrossover.py +45 -0
- roboquant/strategies/strategy.py +21 -0
- roboquant/timeframe.py +135 -0
- roboquant/trackers/__init__.py +6 -0
- roboquant/trackers/basictracker.py +64 -0
- roboquant/trackers/capmtracker.py +66 -0
- roboquant/trackers/equitytracker.py +18 -0
- roboquant/trackers/standardtracker.py +173 -0
- roboquant/trackers/tensorboardtracker.py +31 -0
- roboquant/trackers/tracker.py +18 -0
- roboquant/traders/__init__.py +2 -0
- roboquant/traders/flextrader.py +128 -0
- roboquant/traders/trader.py +17 -0
- roboquant-0.1.6.dist-info/LICENSE +201 -0
- roboquant-0.1.6.dist-info/METADATA +105 -0
- roboquant-0.1.6.dist-info/RECORD +48 -0
- roboquant-0.1.6.dist-info/WHEEL +5 -0
- roboquant-0.1.6.dist-info/top_level.txt +1 -0
roboquant/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from .account import Account, OptionAccount, Position
|
|
2
|
+
from .roboquant import Roboquant
|
|
3
|
+
|
|
4
|
+
from .event import Event, PriceItem, Candle, Trade, Quote
|
|
5
|
+
from .order import Order, OrderStatus
|
|
6
|
+
from .timeframe import Timeframe
|
|
7
|
+
from .config import Config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
from roboquant.brokers import Broker, SimBroker
|
|
11
|
+
from roboquant.traders import Trader, FlexTrader
|
|
12
|
+
from roboquant.trackers import Tracker, StandardTracker, BasicTracker, CAPMTracker, EquityTracker, TensorboardTracker
|
|
13
|
+
from roboquant.strategies import (
|
|
14
|
+
Strategy,
|
|
15
|
+
EMACrossover,
|
|
16
|
+
SMACrossover,
|
|
17
|
+
CandleStrategy,
|
|
18
|
+
NOPStrategy,
|
|
19
|
+
NumpyBuffer,
|
|
20
|
+
OHLCVBuffer,
|
|
21
|
+
)
|
|
22
|
+
from roboquant.feeds import (
|
|
23
|
+
Feed,
|
|
24
|
+
CSVFeed,
|
|
25
|
+
SQLFeed,
|
|
26
|
+
RandomWalk,
|
|
27
|
+
YahooFeed,
|
|
28
|
+
TiingoLiveFeed,
|
|
29
|
+
TiingoHistoricFeed,
|
|
30
|
+
CandleFeed,
|
|
31
|
+
EventChannel,
|
|
32
|
+
feedutil
|
|
33
|
+
)
|
roboquant/account.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from roboquant.order import Order
|
|
5
|
+
from prettytable import PrettyTable
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True, frozen=True)
|
|
9
|
+
class Position:
|
|
10
|
+
"""Position of a symbol"""
|
|
11
|
+
|
|
12
|
+
size: Decimal
|
|
13
|
+
"""Position size"""
|
|
14
|
+
|
|
15
|
+
avg_price: float
|
|
16
|
+
"""Average price paid denoted in the currency of the symbol"""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Account:
|
|
20
|
+
"""The account maintains the following state during a run:
|
|
21
|
+
|
|
22
|
+
- Available cash for trading (also sometimes referred to as buying power)
|
|
23
|
+
- Open positions
|
|
24
|
+
- Open orders
|
|
25
|
+
- Total equity value of the account
|
|
26
|
+
- Last time the account was updated
|
|
27
|
+
|
|
28
|
+
Only the broker updates the state of the account and does this only during its `sync` method.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self.buying_power: float = 0.0
|
|
33
|
+
self.positions: dict[str, Position] = {}
|
|
34
|
+
self.orders: list[Order] = []
|
|
35
|
+
self.last_update: datetime = datetime.fromisoformat("1900-01-01T00:00:00+00:00")
|
|
36
|
+
self.equity = 0.0
|
|
37
|
+
|
|
38
|
+
def get_value(self, symbol: str, size: Decimal, price: float) -> float:
|
|
39
|
+
"""Return the total value of the provided contract size denoted in the base currency of the account.
|
|
40
|
+
The default implementation returns `size * price`.
|
|
41
|
+
|
|
42
|
+
The bahavior of this method can be changed for symbols denoted in a different currency and/or contract size by providing
|
|
43
|
+
a different value_calculator.
|
|
44
|
+
"""
|
|
45
|
+
return float(size) * price
|
|
46
|
+
|
|
47
|
+
def mkt_value(self, prices: dict[str, float]) -> float:
|
|
48
|
+
"""Return the the market value of all the open positions in the account using the provided prices."""
|
|
49
|
+
return sum([self.get_value(symbol, pos.size, prices[symbol]) for symbol, pos in self.positions.items()], 0.0)
|
|
50
|
+
|
|
51
|
+
def unrealized_pnl(self, prices: dict[str, float]) -> float:
|
|
52
|
+
return sum(
|
|
53
|
+
[
|
|
54
|
+
self.get_value(symbol, pos.size, prices[symbol] - pos.avg_price)
|
|
55
|
+
for symbol, pos in self.positions.items()
|
|
56
|
+
],
|
|
57
|
+
0.0,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
def has_open_order(self, symbol: str) -> bool:
|
|
61
|
+
"""Return True if there an open order for the symbol, False otherwise"""
|
|
62
|
+
|
|
63
|
+
for order in self.orders:
|
|
64
|
+
if order.symbol == symbol and not order.closed:
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
def get_position_size(self, symbol) -> Decimal:
|
|
69
|
+
pos = self.positions.get(symbol)
|
|
70
|
+
return pos.size if pos else Decimal(0)
|
|
71
|
+
|
|
72
|
+
def open_orders(self):
|
|
73
|
+
"""Return a list with the open orders"""
|
|
74
|
+
return [order for order in self.orders if not order.closed]
|
|
75
|
+
|
|
76
|
+
def __repr__(self) -> str:
|
|
77
|
+
p = PrettyTable(["account", "value"], align="r", float_format="12.2")
|
|
78
|
+
p.add_row(["buying power", self.buying_power])
|
|
79
|
+
p.add_row(["equity", self.equity])
|
|
80
|
+
p.add_row(["positions", len(self.positions)])
|
|
81
|
+
p.add_row(["orders", len(self.orders)])
|
|
82
|
+
p.add_row(["last update", self.last_update.strftime("%Y-%m-%d %H:%M:%S")])
|
|
83
|
+
result = p.get_string() + "\n\n"
|
|
84
|
+
|
|
85
|
+
p = PrettyTable(["symbol", "position size", "avg price"], align="r", float_format="12.2")
|
|
86
|
+
for symbol, pos in self.positions.items():
|
|
87
|
+
p.add_row([symbol, pos.size, pos.avg_price])
|
|
88
|
+
result += p.get_string() + "\n\n"
|
|
89
|
+
|
|
90
|
+
p = PrettyTable(
|
|
91
|
+
["symbol", "order size", "order id", "limit", "status", "closed"], align="r", float_format="12.2"
|
|
92
|
+
)
|
|
93
|
+
for order in self.orders:
|
|
94
|
+
p.add_row([order.symbol, order.size, order.id, order.limit, order.status.name, order.closed])
|
|
95
|
+
result += p.get_string() + "\n"
|
|
96
|
+
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class OptionAccount(Account):
|
|
101
|
+
"""
|
|
102
|
+
This account handles common option contracts of size 100 and 10. Serves as an example.
|
|
103
|
+
If no contract size is registered for a symbol, it creates one based on the symbol name.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(self):
|
|
107
|
+
super().__init__()
|
|
108
|
+
self._contract_sizes: dict[str, float] = {}
|
|
109
|
+
|
|
110
|
+
def register(self, symbol: str, contract_size: float = 100.0):
|
|
111
|
+
"""Register a certain contract-size for a symbol"""
|
|
112
|
+
self._contract_sizes[symbol] = contract_size
|
|
113
|
+
|
|
114
|
+
def get_value(self, symbol: str, size: Decimal, price: float) -> float:
|
|
115
|
+
contract_size = self._contract_sizes.get(symbol)
|
|
116
|
+
|
|
117
|
+
# If nithng registered we try to defer the contract size from the symbol
|
|
118
|
+
if contract_size is None:
|
|
119
|
+
if len(symbol) == 21:
|
|
120
|
+
# OCC compliant option symbol
|
|
121
|
+
symbol = symbol[0:6].rstrip()
|
|
122
|
+
contract_size = 10.0 if symbol[-1] == "7" else 100.0
|
|
123
|
+
else:
|
|
124
|
+
# not an option symbol
|
|
125
|
+
contract_size = 1.0
|
|
126
|
+
|
|
127
|
+
return contract_size * float(size) * price
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
from roboquant.account import Account
|
|
4
|
+
from roboquant.event import Event
|
|
5
|
+
from roboquant.order import Order
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Broker(Protocol):
|
|
10
|
+
"""A broker handles the placed orders and communicates its state through the account object"""
|
|
11
|
+
|
|
12
|
+
def place_orders(self, *orders: Order):
|
|
13
|
+
"""Place zero or more orders at this broker."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
def sync(self, event: Event | None = None) -> Account:
|
|
17
|
+
"""Sync the state, and return an updated account to reflect the latest state."""
|
|
18
|
+
...
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
from datetime import datetime, timezone, timedelta
|
|
2
|
+
from decimal import Decimal
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
import threading
|
|
6
|
+
|
|
7
|
+
from roboquant.account import Account, Position
|
|
8
|
+
from roboquant.order import Order, OrderStatus
|
|
9
|
+
from roboquant.event import Event
|
|
10
|
+
from .broker import Broker
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from ibapi.client import EClient
|
|
16
|
+
from ibapi.wrapper import EWrapper
|
|
17
|
+
from ibapi.contract import Contract
|
|
18
|
+
from ibapi.order import Order as IBKROrder
|
|
19
|
+
from ibapi.account_summary_tags import AccountSummaryTags
|
|
20
|
+
from ibapi import VERSION
|
|
21
|
+
assert VERSION["major"] == 10 and VERSION["minor"] == 19, "Wrong version of the IBAPI found"
|
|
22
|
+
except:
|
|
23
|
+
logger.fatal("Couldn't import IBAPI package, you need to manually install this")
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class _IBApi(EWrapper, EClient):
|
|
28
|
+
|
|
29
|
+
def __init__(self):
|
|
30
|
+
EClient.__init__(self, self)
|
|
31
|
+
self.orders: dict[str, Order] = {}
|
|
32
|
+
self.positions: dict[str, Position] = {}
|
|
33
|
+
self.account = {"EquityWithLoanValue": 0.0, "AvailableFunds": 0.0}
|
|
34
|
+
self.__account_end = threading.Condition()
|
|
35
|
+
self.__order_id = 0
|
|
36
|
+
|
|
37
|
+
def nextValidId(self, orderId: int):
|
|
38
|
+
self.__order_id = orderId
|
|
39
|
+
logger.debug("The next valid order id is: %s", orderId)
|
|
40
|
+
|
|
41
|
+
def get_next_order_id(self):
|
|
42
|
+
result = str(self.__order_id)
|
|
43
|
+
self.__order_id += 1
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
def position(self, account: str, contract: Contract, position: Decimal, avgCost: float):
|
|
47
|
+
logger.debug("position=%s symbol=%s avgCost=%s", position, contract.localSymbol, avgCost)
|
|
48
|
+
symbol = contract.localSymbol or contract.symbol
|
|
49
|
+
self.positions[symbol] = Position(position, avgCost)
|
|
50
|
+
|
|
51
|
+
def accountSummary(self, reqId: int, account: str, tag: str, value: str, currency: str):
|
|
52
|
+
logger.debug("account %s=%s", tag, value)
|
|
53
|
+
self.account[tag] = float(value)
|
|
54
|
+
|
|
55
|
+
def accountSummaryEnd(self, reqId: int):
|
|
56
|
+
with self.__account_end:
|
|
57
|
+
self.__account_end.notify_all()
|
|
58
|
+
|
|
59
|
+
def openOrder(self, orderId: int, contract, order: IBKROrder, orderState):
|
|
60
|
+
logger.debug(
|
|
61
|
+
"openOrder orderId=%s status=%s size=%s limit=%s",
|
|
62
|
+
orderId,
|
|
63
|
+
orderState.status,
|
|
64
|
+
order.totalQuantity,
|
|
65
|
+
order.lmtPrice,
|
|
66
|
+
)
|
|
67
|
+
size = order.totalQuantity if order.action == "BUY" else -order.totalQuantity
|
|
68
|
+
symbol = contract.localSymbol
|
|
69
|
+
rq_order = Order(symbol, size) if not order.lmtPrice else Order(symbol, size, order.lmtPrice)
|
|
70
|
+
rq_order.id = str(orderId)
|
|
71
|
+
self.orders[rq_order.id] = rq_order
|
|
72
|
+
|
|
73
|
+
def reqAccountSummary(self):
|
|
74
|
+
buyingpower_tag = AccountSummaryTags.BuyingPower
|
|
75
|
+
equity_tag = AccountSummaryTags.NetLiquidation
|
|
76
|
+
with self.__account_end:
|
|
77
|
+
super().reqAccountSummary(1, "All", f"{buyingpower_tag},{equity_tag}")
|
|
78
|
+
self.__account_end.wait()
|
|
79
|
+
|
|
80
|
+
def get_buying_power(self):
|
|
81
|
+
buyingpower_tag = AccountSummaryTags.BuyingPower
|
|
82
|
+
return self.account[buyingpower_tag] or 0.0
|
|
83
|
+
|
|
84
|
+
def get_equity(self):
|
|
85
|
+
equity_tag = AccountSummaryTags.NetLiquidation
|
|
86
|
+
return self.account[equity_tag] or 0.0
|
|
87
|
+
|
|
88
|
+
def orderStatus(
|
|
89
|
+
self,
|
|
90
|
+
orderId,
|
|
91
|
+
status,
|
|
92
|
+
filled,
|
|
93
|
+
remaining,
|
|
94
|
+
avgFillPrice,
|
|
95
|
+
permId,
|
|
96
|
+
parentId,
|
|
97
|
+
lastFillPrice,
|
|
98
|
+
clientId,
|
|
99
|
+
whyHeld,
|
|
100
|
+
mktCapPrice,
|
|
101
|
+
):
|
|
102
|
+
logger.debug("order status orderId=%s status=%s", orderId, status)
|
|
103
|
+
id = str(orderId)
|
|
104
|
+
if id in self.orders:
|
|
105
|
+
order = self.orders[id]
|
|
106
|
+
match status:
|
|
107
|
+
case "Submitted":
|
|
108
|
+
order.status = OrderStatus.ACTIVE
|
|
109
|
+
case "Cancelled":
|
|
110
|
+
order.status = OrderStatus.CANCELLED
|
|
111
|
+
case "Filled":
|
|
112
|
+
order.status = OrderStatus.FILLED
|
|
113
|
+
else:
|
|
114
|
+
logger.warn(f"recieved status for unknown order id=%s status=%s", orderId, status)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class IBKRBroker(Broker):
|
|
118
|
+
"""
|
|
119
|
+
Atttributes
|
|
120
|
+
==========
|
|
121
|
+
contract_mapping
|
|
122
|
+
store how symbols map to IBKR contracts. If a symbol is not found, the symbol is assumed to represent a US stock
|
|
123
|
+
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, host="127.0.0.1", port=4002, account=None, client_id=123) -> None:
|
|
127
|
+
self.__account = account or Account()
|
|
128
|
+
self.contract_mapping: dict[str, Contract] = {}
|
|
129
|
+
api = _IBApi()
|
|
130
|
+
api.connect(host, port, client_id)
|
|
131
|
+
self.__api = api
|
|
132
|
+
self._has_new_orders_since_sync = False
|
|
133
|
+
|
|
134
|
+
# Start the handling in a thread
|
|
135
|
+
self.__api_thread = threading.Thread(target=api.run, daemon=False)
|
|
136
|
+
self.__api_thread.start()
|
|
137
|
+
time.sleep(3.0)
|
|
138
|
+
|
|
139
|
+
def _should_sync(self, now):
|
|
140
|
+
return self._has_new_orders_since_sync or now - self.__account.last_update > timedelta(seconds=30)
|
|
141
|
+
|
|
142
|
+
def sync(self, event: Event | None = None) -> Account:
|
|
143
|
+
logger.debug("start sync")
|
|
144
|
+
now = datetime.now(timezone.utc)
|
|
145
|
+
|
|
146
|
+
if event:
|
|
147
|
+
# Lets make sure we don't use IBKRBroker by mistake during a back-test.
|
|
148
|
+
if now - event.time > timedelta(minutes=30):
|
|
149
|
+
logger.critical("received event from the past, now=%s event-time=%s", now, event.time)
|
|
150
|
+
raise ValueError(f"received event to far in the past now={now} event-time={event.time}")
|
|
151
|
+
|
|
152
|
+
api = self.__api
|
|
153
|
+
acc = self.__account
|
|
154
|
+
if self._should_sync(now):
|
|
155
|
+
acc.last_update = now
|
|
156
|
+
self._has_new_orders_since_sync = False
|
|
157
|
+
|
|
158
|
+
api.reqPositions()
|
|
159
|
+
api.reqOpenOrders()
|
|
160
|
+
api.reqAccountSummary()
|
|
161
|
+
|
|
162
|
+
acc.positions = {k: v for k, v in api.positions.items() if not v.size.is_zero()}
|
|
163
|
+
acc.orders = [order for order in api.orders.values()]
|
|
164
|
+
acc.buying_power = api.get_buying_power()
|
|
165
|
+
acc.equity = api.get_equity()
|
|
166
|
+
|
|
167
|
+
logger.debug("end sync")
|
|
168
|
+
return acc
|
|
169
|
+
|
|
170
|
+
def place_orders(self, *orders: Order):
|
|
171
|
+
|
|
172
|
+
self._has_new_orders_since_sync = len(orders) > 0
|
|
173
|
+
|
|
174
|
+
for order in orders:
|
|
175
|
+
assert not order.closed, "cannot place a closed order"
|
|
176
|
+
if order.size.is_zero():
|
|
177
|
+
assert order.id is not None
|
|
178
|
+
self.__api.cancelOrder(int(order.id), "")
|
|
179
|
+
else:
|
|
180
|
+
if order.id is None:
|
|
181
|
+
order.id = self.__api.get_next_order_id()
|
|
182
|
+
self.__api.orders[order.id] = order
|
|
183
|
+
ibkrorder = self._get_order(order)
|
|
184
|
+
contract = self.contract_mapping.get(order.symbol) or self._get_default_contract(order.symbol)
|
|
185
|
+
self.__api.placeOrder(int(order.id), contract, ibkrorder)
|
|
186
|
+
|
|
187
|
+
def _get_default_contract(self, symbol: str) -> Contract:
|
|
188
|
+
"""If no contract can be found in the `contract_mapping` dict, this method is called to get a default IBKR contract
|
|
189
|
+
for the provided symbol.
|
|
190
|
+
|
|
191
|
+
The default implementation assumes it is a US stock symbol with SMART exchange routing.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
c = Contract()
|
|
195
|
+
c.symbol = symbol
|
|
196
|
+
c.secType = "STK"
|
|
197
|
+
c.currency = "USD"
|
|
198
|
+
c.exchange = "SMART" # use smart routing by default
|
|
199
|
+
return c
|
|
200
|
+
|
|
201
|
+
def _get_order(self, order: Order):
|
|
202
|
+
o = IBKROrder()
|
|
203
|
+
o.action = "BUY" if order.is_buy else "SELL"
|
|
204
|
+
o.totalQuantity = abs(order.size)
|
|
205
|
+
o.tif = "GTC"
|
|
206
|
+
if order.limit:
|
|
207
|
+
o.orderType = "LMT"
|
|
208
|
+
o.lmtPrice = order.limit
|
|
209
|
+
else:
|
|
210
|
+
o.orderType = "MKT"
|
|
211
|
+
return o
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
|
|
5
|
+
from roboquant.order import Order, OrderStatus
|
|
6
|
+
from ..event import Event
|
|
7
|
+
from ..account import Account, Position
|
|
8
|
+
from .broker import Broker
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(slots=True, frozen=True)
|
|
12
|
+
class _Trx:
|
|
13
|
+
"""transaction for an executed trade"""
|
|
14
|
+
|
|
15
|
+
symbol: str
|
|
16
|
+
size: Decimal
|
|
17
|
+
price: float # is denoted in the currency of the symbol
|
|
18
|
+
fee: float = 0.0 # is denoted in base currency of the account
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class _OrderState:
|
|
23
|
+
order: Order
|
|
24
|
+
accepted: datetime | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SimBroker(Broker):
|
|
28
|
+
"""Implementation of a Broker that simulates order handling and trade execution.
|
|
29
|
+
|
|
30
|
+
This class can be extended to support different types of use-cases, like margin trading.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
__order_id = 0
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self, initial_deposit=1000000.0, account=None, price_type="DEFAULT", slippage=0.001, clean_up_orders=True
|
|
37
|
+
):
|
|
38
|
+
super().__init__()
|
|
39
|
+
self.initial_deposit = initial_deposit
|
|
40
|
+
self._account = account or Account()
|
|
41
|
+
self._modify_orders: list[Order] = []
|
|
42
|
+
self._account.buying_power = initial_deposit
|
|
43
|
+
self.slippage = slippage
|
|
44
|
+
self.price_type = price_type
|
|
45
|
+
self._prices: dict[str, float] = {}
|
|
46
|
+
self._orders: dict[str, _OrderState] = {}
|
|
47
|
+
self.clean_up_orders = clean_up_orders
|
|
48
|
+
|
|
49
|
+
def _update_account(self, trx: _Trx):
|
|
50
|
+
"""Update a position and cash based on a new transaction"""
|
|
51
|
+
acc = self._account
|
|
52
|
+
symbol = trx.symbol
|
|
53
|
+
acc.buying_power -= acc.get_value(symbol, trx.size, trx.price)
|
|
54
|
+
|
|
55
|
+
size = acc.get_position_size(symbol)
|
|
56
|
+
|
|
57
|
+
if size.is_zero():
|
|
58
|
+
# opening of position
|
|
59
|
+
acc.positions[symbol] = Position(trx.size, trx.price)
|
|
60
|
+
else:
|
|
61
|
+
new_size: Decimal = size + trx.size
|
|
62
|
+
if new_size.is_zero():
|
|
63
|
+
# closing of position
|
|
64
|
+
del acc.positions[symbol]
|
|
65
|
+
elif new_size.is_signed() != size.is_signed():
|
|
66
|
+
# reverse of position
|
|
67
|
+
acc.positions[symbol] = Position(new_size, trx.price)
|
|
68
|
+
else:
|
|
69
|
+
# increase of position size
|
|
70
|
+
old_price = acc.positions[symbol].avg_price
|
|
71
|
+
avg_price = (old_price * float(size) + trx.price * float(trx.size)) / (float(size + trx.size))
|
|
72
|
+
acc.positions[symbol] = Position(new_size, avg_price)
|
|
73
|
+
|
|
74
|
+
def get_execution_price(self, order, item) -> float:
|
|
75
|
+
"""Return the execution price to use for an order based on the price item.
|
|
76
|
+
|
|
77
|
+
The default implementation is a fixed slippage percentage based on the configured price_type.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
price = item.get_price(self.price_type)
|
|
81
|
+
correction = self.slippage if order.is_buy else -self.slippage
|
|
82
|
+
return price * (1.0 + correction)
|
|
83
|
+
|
|
84
|
+
def get_fee(self, order) -> float:
|
|
85
|
+
"""Return the fee (or rebate) for the execution of an order. The fee is denoted in the base
|
|
86
|
+
currency of the account.
|
|
87
|
+
|
|
88
|
+
The default implementation returns 0.0, so no fee is used.
|
|
89
|
+
"""
|
|
90
|
+
return 0.0
|
|
91
|
+
|
|
92
|
+
def _simulate_market(self, order: Order, item) -> _Trx | None:
|
|
93
|
+
"""Simulate a market for the three order types"""
|
|
94
|
+
|
|
95
|
+
price = self.get_execution_price(order, item)
|
|
96
|
+
if self.is_executable(order, price):
|
|
97
|
+
fee = self.get_fee(order)
|
|
98
|
+
return _Trx(order.symbol, order.size, price, fee)
|
|
99
|
+
|
|
100
|
+
def next_order_id(self):
|
|
101
|
+
result = str(SimBroker.__order_id)
|
|
102
|
+
SimBroker.__order_id += 1
|
|
103
|
+
return result
|
|
104
|
+
|
|
105
|
+
def _has_expired(self, state: _OrderState) -> bool:
|
|
106
|
+
if state.accepted is None:
|
|
107
|
+
return False
|
|
108
|
+
else:
|
|
109
|
+
return self._account.last_update - state.accepted > timedelta(days=180)
|
|
110
|
+
|
|
111
|
+
def is_executable(self, order, price) -> bool:
|
|
112
|
+
"""Is this order executable given the provided price.
|
|
113
|
+
A market order is always executable, a limit order only when the limit is below the BUY price or
|
|
114
|
+
above the SELL price"""
|
|
115
|
+
if order.limit is None:
|
|
116
|
+
return True
|
|
117
|
+
if order.is_buy and price <= order.limit:
|
|
118
|
+
return True
|
|
119
|
+
if order.is_sell and price >= order.limit:
|
|
120
|
+
return True
|
|
121
|
+
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
def _update_mkt_prices(self, price_items):
|
|
125
|
+
"""track the latest market prices for all open positions"""
|
|
126
|
+
for symbol in self._account.positions.keys():
|
|
127
|
+
if (item := price_items.get(symbol)) is not None:
|
|
128
|
+
self._prices[symbol] = item.get_price(self.price_type)
|
|
129
|
+
|
|
130
|
+
def place_orders(self, *orders: Order):
|
|
131
|
+
"""Place new orders at this broker. The order gets assigned a unique id if it hasn't one already.
|
|
132
|
+
|
|
133
|
+
There is no trading simulation yet performed or account updated. Orders placed at time `t`, will be
|
|
134
|
+
processed during time `t+1`. This protects against future bias.
|
|
135
|
+
"""
|
|
136
|
+
for order in orders:
|
|
137
|
+
assert not order.closed, "cannot place closed orders"
|
|
138
|
+
if order.id is None:
|
|
139
|
+
order.id = self.next_order_id()
|
|
140
|
+
assert order.id not in self._orders
|
|
141
|
+
self._orders[order.id] = _OrderState(order)
|
|
142
|
+
else:
|
|
143
|
+
assert order.id in self._orders, "existing order id not found"
|
|
144
|
+
self._modify_orders.append(order)
|
|
145
|
+
|
|
146
|
+
def _process_modify_order(self):
|
|
147
|
+
for order in self._modify_orders:
|
|
148
|
+
state = self._orders[order.id] # type: ignore
|
|
149
|
+
if state.order.closed:
|
|
150
|
+
continue
|
|
151
|
+
elif order.is_cancellation:
|
|
152
|
+
state.order.status = OrderStatus.CANCELLED
|
|
153
|
+
else:
|
|
154
|
+
state.order.size = order.size or state.order.size
|
|
155
|
+
state.order.limit = order.limit or state.order.limit
|
|
156
|
+
self._modify_orders = []
|
|
157
|
+
|
|
158
|
+
def _process_create_orders(self, prices):
|
|
159
|
+
for state in self._orders.values():
|
|
160
|
+
order = state.order
|
|
161
|
+
if order.closed:
|
|
162
|
+
continue
|
|
163
|
+
if self._has_expired(state):
|
|
164
|
+
order.status = OrderStatus.EXPIRED
|
|
165
|
+
else:
|
|
166
|
+
if (item := prices.get(order.symbol)) is not None:
|
|
167
|
+
state.accepted = state.accepted or self._account.last_update
|
|
168
|
+
trx = self._simulate_market(order, item)
|
|
169
|
+
if trx is not None:
|
|
170
|
+
self._update_account(trx)
|
|
171
|
+
order.status = OrderStatus.FILLED
|
|
172
|
+
|
|
173
|
+
def sync(self, event: Event | None = None) -> Account:
|
|
174
|
+
"""This will perform the trading simulation for open orders and update the account accordingly."""
|
|
175
|
+
|
|
176
|
+
acc = self._account
|
|
177
|
+
if event:
|
|
178
|
+
acc.last_update = event.time
|
|
179
|
+
|
|
180
|
+
prices = event.price_items if event else {}
|
|
181
|
+
|
|
182
|
+
if self.clean_up_orders:
|
|
183
|
+
self._orders = {id: state for id, state in self._orders.items() if not state.order.closed}
|
|
184
|
+
|
|
185
|
+
self._process_modify_order()
|
|
186
|
+
self._process_create_orders(prices)
|
|
187
|
+
self._update_mkt_prices(prices)
|
|
188
|
+
|
|
189
|
+
acc.equity = acc.mkt_value(self._prices) + acc.buying_power
|
|
190
|
+
acc.orders = [state.order for state in self._orders.values()]
|
|
191
|
+
return acc
|
roboquant/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from configparser import ConfigParser
|
|
2
|
+
import os.path
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Config:
|
|
7
|
+
"""Access to roboquant configuration file"""
|
|
8
|
+
|
|
9
|
+
def __init__(self, path=None):
|
|
10
|
+
path = path or os.path.expanduser("~/.roboquant/.env")
|
|
11
|
+
with open(path, "r") as f:
|
|
12
|
+
config_string = "[default]\n" + f.read()
|
|
13
|
+
self.config = ConfigParser()
|
|
14
|
+
self.config.read_string(config_string)
|
|
15
|
+
|
|
16
|
+
def get(self, key):
|
|
17
|
+
for key2, value in os.environ.items():
|
|
18
|
+
final_key = key2.lower().replace("_", ".")
|
|
19
|
+
if final_key == key: return value
|
|
20
|
+
return self.config.get("default", key)
|