-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathagent.py
More file actions
executable file
·428 lines (330 loc) · 13 KB
/
Copy pathagent.py
File metadata and controls
executable file
·428 lines (330 loc) · 13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
# !/usr/bin/env python3
# -*- coding: utf-8 -*-
from backtest import Backtest # use timestamp_global
from market import Market, Order, Trade
import abc
import pandas as pd
import textwrap
class BaseAgent(abc.ABC):
def __init__(self, name):
"""
Trading agent base class. Subclass BaseAgent to define how a concrete
Agent should act given different market situations.
:param name:
str, agent name
"""
self.name = name
# containers for related class instances
self.markets = Market.instances
self.orders = Order.history
self.trades = Trade.history
# settings
self.exposure_limit = 1e6 # ...
self.latency = 0 # in milliseconds, used only in submit method
self.transaction_cost_factor = 1e-3 # 10 bps
# event management ---
@abc.abstractmethod
def on_quote(self, market_id:str, book_state:pd.Series):
"""
This method is called after a new quote.
:param market_id:
str, market identifier
:param book_state:
pd.Series, including timestamp, bid/ask price/quantity for ten levels
"""
raise NotImplementedError("To be implemented in subclass.")
@abc.abstractmethod
def on_trade(self, market_id:str, trade_state:pd.Series):
"""
This method is called after a new trade.
:param market_id:
str, market identifier
:param trade_state:
pd.Series, including timestamp, price, quantity
"""
raise NotImplementedError("To be implemented in subclass.")
@abc.abstractmethod
def on_news(self, market_id:str, news_state:pd.Series):
"""
This method is called after a news message.
:param market_id:
str, market identifier
:param news_state:
pd.Series, including timestamp, message
"""
raise NotImplementedError("To be implemented in subclass.")
@abc.abstractmethod
def on_time(self, timestamp:pd.Timestamp, timestamp_next:pd.Timestamp):
"""
This method is called with every iteration and provides the timestamps
for both current and next iteration. The given interval may be used to
submit orders before a specific point in time.
:param timestamp:
pd.Timestamp, timestamp recorded in this iteration
:param timestamp_next:
pd.Timestamp, timestamp recorded in next iteration
"""
raise NotImplementedError("To be implemented in subclass.")
# order management ---
def _assert_exposure(self, market_id, side, quantity, limit):
"""
Assert agent exposure. Note that program execution is supposed to
continue.
"""
# first, assert that market exists
assert market_id in self.markets, \
"market_id '{market_id}' does not exist".format(
market_id=market_id,
)
# assert that exposure_left is not exceeded
if limit:
position_value = quantity * limit
else:
position_value = quantity * self.markets[market_id].mid_point
assert self.exposure_left >= position_value, \
"{position_value} exceeds exposure_left {exposure_left}".format(
position_value=position_value,
exposure_left=self.exposure_left,
)
def submit_order(self, market_id, side, quantity, limit=None):
"""
Submit market order, limit order if limit is specified.
Note that, for convenience, this method also returns the order
instance that can be used for cancellation.
:param market_id:
str, market identifier
:param side:
str, either 'buy' or 'sell'
:param quantity:
int, number of shares ordered
:param limit:
float, limit price to consider, optional
:return order:
Order, order instance
"""
# assert agent exposure
try:
self._assert_exposure(market_id, side, quantity, limit)
# block order if exposure_left is exceeded, return None
except Exception as error:
print("(INFO) order was blocked: {error}".format(
error=error,
))
return None
# submit order
order = Order(
timestamp=Backtest.timestamp_global + pd.Timedelta(self.latency, "L"),
market_id=market_id,
side=side,
quantity=quantity,
limit=limit,
)
return order
def cancel_order(self, order):
"""
Cancel an active order.
:param order:
Order, order instance
"""
# cancel order
order.cancel()
# string representation ---
def __str__(self):
"""
String representation.
"""
# read global timestamp from Backtest class attribute
timestamp_global = Backtest.timestamp_global
# string representation
string = f"""
---
name: {self.name}
timestamp: {timestamp_global} (+{self.latency} ms)
---
exposure: {self.exposure_total}
pnl_realized: {self.pnl_realized_total}
pnl_unrealized: {self.pnl_unrealized_total}
---
"""
return textwrap.dedent(string)
# filtered orders, trades ---
def get_filtered_orders(self, market_id=None, side=None, status=None):
"""
Filter Order.history based on market_id, side and status.
:param market_id:
str, market identifier, optional
:param side:
str, either 'buy' or 'sell', optional
:param status:
str, either 'ACTIVE', 'FILLED', 'CANCELLED' or 'REJECTED', optional
:return orders:
list, filtered Order instances
"""
orders = self.orders
# orders must have requested market_id
if market_id:
orders = filter(lambda order: order.market_id == market_id, orders)
# orders must have requested side
if side:
orders = filter(lambda order: order.side == side, orders)
# orders must have requested status
if status:
orders = filter(lambda order: order.status == status, orders)
return list(orders)
def get_filtered_trades(self, market_id=None, side=None):
"""
Filter Trade.history based on market_id and side.
:param market_id:
str, market identifier, optional
:param side:
str, either 'buy' or 'sell', optional
:return trades:
list, filtered Trade instances
"""
trades = self.trades
# trades must have requested market_id
if market_id:
trades = filter(lambda trade: trade.market_id == market_id, trades)
# trades must have requested side
if side:
trades = filter(lambda trade: trade.side == side, trades)
return list(trades)
# symbol, agent statistics ---
@property
def exposure(self, result={}):
"""
Current net exposure that the agent has per market, based statically
on the entry value of the remaining positions.
Note that a positive and a negative value indicate a long and a short
position, respectively.
:return exposure:
dict, {<market_id>: <exposure>, *}
"""
# ...
for market_id, _ in self.markets.items():
trades_buy = self.get_filtered_trades(market_id, side="buy")
quantity_buy = sum(t.quantity for t in trades_buy)
trades_sell = self.get_filtered_trades(market_id, side="sell")
quantity_sell = sum(t.quantity for t in trades_sell)
quantity_unreal = quantity_buy - quantity_sell
# case 1: buy side surplus
if quantity_unreal > 0:
vwap_buy = sum(t.quantity * t.price for t in trades_buy) / quantity_buy
result_market = quantity_unreal * vwap_buy
# case 2: sell side surplus
elif quantity_unreal < 0:
vwap_sell = sum(t.quantity * t.price for t in trades_sell) / quantity_sell
result_market = quantity_unreal * vwap_sell
# case 3: all quantity is realized
else:
result_market = 0
result[market_id] = round(result_market, 3)
return result
@property
def exposure_total(self):
"""
Current net exposure that the agent has across all markets, based on
the net exposure that the agent has per market.
Note that we use the absolute value for both long and short positions.
:return exposure_total:
float, total exposure across all markets
"""
result = sum(abs(exposure) for _, exposure in self.exposure.items())
result = round(result, 3)
return result
@property
def pnl_realized(self, result={}):
"""
Current realized PnL that the agent has per market.
:return pnl_realized:
dict, {<market_id>: <pnl_realized>, *}
"""
# ...
for market_id, _ in self.markets.items():
trades_buy = self.get_filtered_trades(market_id, side="buy")
quantity_buy = sum(trade.quantity for trade in trades_buy)
trades_sell = self.get_filtered_trades(market_id, side="sell")
quantity_sell = sum(trade.quantity for trade in trades_sell)
quantity_real = min(quantity_buy, quantity_sell)
# case 1: quantity_real is 0
if not quantity_real:
result_market = 0
# case 2: quantity_real > 0
else:
vwap_buy = sum(t.quantity * t.price for t in trades_buy) / quantity_buy
vwap_sell = sum(t.quantity * t.price for t in trades_sell) / quantity_sell
result_market = quantity_real * (vwap_sell - vwap_buy)
result[market_id] = round(result_market, 3)
return result
@property
def pnl_realized_total(self):
"""
Current realized pnl that the agent has across all markets, based on
the realized pnl that the agent has per market.
:return pnl_realized_total:
float, total realized pnl across all markets
"""
result = sum(pnl for _, pnl in self.pnl_realized.items())
result = round(result, 3)
return result
@property
def pnl_unrealized(self, result={}):
"""
This method returns the unrealized PnL that the agent has per market.
:return pnl_unrealized:
dict, {<market_id>: <pnl_unrealized>, *}
"""
# ...
for market_id, market in self.markets.items():
trades_buy = self.get_filtered_trades(market_id, side="buy")
quantity_buy = sum(t.quantity for t in trades_buy)
trades_sell = self.get_filtered_trades(market_id, side="sell")
quantity_sell = sum(t.quantity for t in trades_sell)
quantity_unreal = quantity_buy - quantity_sell
# case 1: buy side surplus
if quantity_unreal > 0:
vwap_buy = sum(t.quantity * t.price for t in trades_buy) / quantity_buy
result_market = abs(quantity_unreal) * (market.best_bid - vwap_buy)
# case 2: sell side surplus
elif quantity_unreal < 0:
vwap_sell = sum(t.quantity * t.price for t in trades_sell) / quantity_sell
result_market = abs(quantity_unreal) * (vwap_sell - market.best_ask)
# case 3: all quantity is realized
else:
result_market = 0
result[market_id] = round(result_market, 3)
return result
@property
def pnl_unrealized_total(self):
"""
Current unrealized pnl that the agent has across all markets, based on
the unrealized pnl that the agent has per market.
:return pnl_unrealized_total:
float, total unrealized pnl across all markets
"""
result = sum(pnl for _, pnl in self.pnl_unrealized.items())
result = round(result, 3)
return result
@property
def exposure_left(self):
"""
Current net exposure left before agent exceeds exposure_limit.
:return exposure_left:
float, remaining exposure
"""
# TODO: include self.pnl_realized_total?
result = self.exposure_limit - self.exposure_total
result = round(result, 3)
return result
@property
def transaction_cost(self):
"""
Current trading cost based on trade history, accumulated throughout
the entire backtest.
:transaction_cost:
float, accumulated transaction cost
"""
result = sum(t.price * t.quantity for t in self.trades)
result = result * self.transaction_cost_factor
result = round(result, 3)
return result