/
base.py
1911 lines (1570 loc) · 75.5 KB
/
base.py
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
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright 2017-2024 QuantRocket LLC - All Rights Reserved
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Union, Any, Type
import io
import pandas as pd
import numpy as np
import time
import requests
import json
import math
from moonshot.slippage import Slippage, FixedSlippage
from moonshot.commission import Commission
from moonshot.mixins import WeightAllocationMixin
from moonshot._cache import Cache
from moonshot.exceptions import MoonshotError, MoonshotParameterError
from quantrocket.price import get_prices
from quantrocket.master import list_calendar_statuses, download_master_file
from quantrocket.account import download_account_balances, download_exchange_rates
from quantrocket.blotter import list_positions, download_order_statuses
class Moonshot(
WeightAllocationMixin):
"""
Base class for Moonshot strategies.
To create a strategy, subclass this class. Implement your trading logic in the class
methods, and store your strategy parameters as class attributes.
Class attributes include built-in Moonshot parameters which you can override, as well
as your own custom parameters.
To run a backtest, at minimum you must implement `prices_to_signals`, but in general you will
want to implement the following methods (which are called in the order shown):
`prices_to_signals` -> `signals_to_target_weights` -> `target_weights_to_positions` -> `positions_to_gross_returns`
To trade (i.e. generate orders intended to be placed, but actually placed by other services
than Moonshot), you must also implement `order_stubs_to_orders`. Order generation for trading
follows the path shown below:
`prices_to_signals` -> `signals_to_target_weights` -> `order_stubs_to_orders`
Parameters
----------
CODE : str, required
the strategy code
DB : str or list of str, required
one or more database codes to pull data from
DB_FIELDS : str or list of str, optional
fields to retrieve from db (defaults to ["Open", "Close", "Volume"])
DB_TIMES : str or list of str (HH:MM:SS), optional
for intraday databases, only retrieve these times
DB_DATA_FREQUENCY : str, optional
Only applicable when DB specifies a Zipline bundle. Whether to query minute or
daily data. If omitted, defaults to minute data for minute bundles and to daily
data for daily bundles. This parameter only needs to be set to request daily data
from a minute bundle. Possible choices: daily, minute (or aliases d, m).
SIDS : str or list of str, optional
limit db query to these sids
UNIVERSES : str or list of str, optional
limit db query to these universes
EXCLUDE_SIDS : str or list of str, optional
exclude these sids from db query
EXCLUDE_UNIVERSES : str or list of str, optional
exclude these universes from db query
CONT_FUT : str, optional
pass this cont_fut option to db query (default None). See quantrocket.get_prices
for more info.
LOOKBACK_WINDOW : int, optional
get this many days additional data prior to the backtest start date or
trade date to account for rolling windows. If set to None (the default),
will use the largest value of any attributes ending with `*_WINDOW`, or
252 if no such attributes, and will further pad window based on any
`*_INTERVAL` attributes, which are interpreted as pandas offset aliases
(for example `REBALANCE_INTERVAL = 'Q'`). Set to 0 to disable.
NLV : dict, optional
dict of currency:NLV for each currency represented in the strategy. Can
alternatively be passed directly to backtest method.
COMMISSION_CLASS : Commission class or dict of (sectype,exchange,currency): Commission class, optional
the commission class to use. If strategy includes a mix of security types,
exchanges, or currencies, you can pass a dict mapping tuples of
(sectype,exchange,currency) to the different commission classes. By default
no commission is applied.
SLIPPAGE_CLASSES : iterable of Slippage classes, optional
one or more slippage classes. By default no slippage is applied.
SLIPPAGE_BPS : float, optional
amount on one-slippage to apply to each trade in BPS (for example, enter 5 to deduct
5 BPS)
BENCHMARK : str, optional
the sid of a security in the historical data to use as the benchmark
BENCHMARK_DB : str, optional
the database containing the benchmark, if different from DB. BENCHMARK_DB
should contain end-of-day data, not intraday (but can be used with intraday
backtests).
BENCHMARK_TIME : str (HH:MM:SS), optional
use prices from this time of day as benchmark prices. Only applicable if
benchmark prices originate in DB (not BENCHMARK_DB), DB contains intraday
data, and backtest results are daily.
TIMEZONE : str, optional
convert timestamps to this timezone (if not provided, will be inferred
from securities universe if possible)
CALENDAR : str, optional
use this exchange's trading calendar to determine which date's signals
should be used for live trading. If the exchange is currently open,
today's signals will be used. If currently closed, the signals corresponding
to the last date the exchange was open will be used. If no calendar is specified,
today's signals will be used.
POSITIONS_CLOSED_DAILY : bool
if True, positions in backtests that fall on adjacent days are assumed to
be closed out and reopened each day rather than held continuously; this
impacts commission and slippage calculations (default is False, meaning
adjacent positions are assumed to be held continuously)
ALLOW_REBALANCE : bool or float
in live trading, whether to allow rebalancing of existing positions that
are already on the correct side. If True (the default), allow rebalancing.
If False, no rebalancing. If set to a positive decimal, allow rebalancing
only when the existing position differs from the target position by at least
this percentage. For example 0.5 means don't rebalance a position unless
the position will change by +/-50%.
CONTRACT_VALUE_REFERENCE_FIELD : str, optional
the price field to use for determining contract values for the purpose of
applying commissions and constraining weights in backtests and calculating
order quantities in trading. Defaults to the first available of Close, Open,
MinuteCloseClose, SecondCloseClose, LastPriceClose, BidPriceClose, AskPriceClose,
TimeSalesLastPriceClose, TimeSalesFilteredLastPriceClose, LastPriceMean,
BidPriceMean, AskPriceMean, TimeSalesLastPriceMean, TimeSalesFilteredLastPriceMean,
MinuteOpenOpen, SecondOpenOpen, LastPriceOpen, BidPriceOpen, AskPriceOpen,
TimeSalesLastPriceOpen, TimeSalesFilteredLastPriceOpen.
ACCOUNT_BALANCE_FIELD : str or list of str, optional
the account field to use for calculating order quantities as a percentage of
account equity. Applies to trading only, not backtesting. Default is
NetLiquidation. If a list of fields is provided, the minimum value is used.
For example, ['NetLiquidation', 'PreviousEquity'] means to use the lesser of
NetLiquidation or PreviousEquity to determine order quantities.
Notes
-----
Usage Guide:
* Moonshot: https://qrok.it/dl/ms/moonshot
Examples
--------
Example of a minimal strategy that runs on a history db called "mexi-stk-1d" and buys when
the securities are above their 200-day moving average::
import pandas as pd
MexicoMovingAverage(Moonshot):
CODE = "mexi-ma"
DB = "mexi-stk-1d"
MAVG_WINDOW = 200
def prices_to_signals(self, prices: pd.DataFrame):
closes = prices.loc["Close"]
mavgs = closes.rolling(self.MAVG_WINDOW).mean()
signals = closes > mavgs.shift()
return signals.astype(int)
"""
CODE: str = None
"""the strategy code"""
DB: Union[str, list[str]] = None
"""one or more database codes to pull data from"""
DB_FIELDS: Union[str, list[str]] = ["Open", "Close", "Volume"]
"""fields to retrieve from db (defaults to ["Open", "Close", "Volume"])"""
DB_TIMES: Union[str, list[str]] = None
"""for intraday databases, only retrieve these times"""
DB_DATA_FREQUENCY: str = None
"""Only applicable when DB specifies a Zipline bundle. Whether to query minute or
daily data. If omitted, defaults to minute data for minute bundles and to daily
data for daily bundles. This parameter only needs to be set to request daily data
from a minute bundle. Possible choices: daily, minute (or aliases d, m)."""
SIDS: Union[str, list[str]] = None
"""limit db query to these sids"""
UNIVERSES: Union[str, list[str]] = None
"""limit db query to these universes"""
EXCLUDE_SIDS: Union[str, list[str]] = None
"""exclude these sids from db query"""
EXCLUDE_UNIVERSES: Union[str, list[str]] = None
"""exclude these universes from db query"""
CONT_FUT: str = None
"""pass this cont_fut option to db query (default None). See quantrocket.get_prices
for more info."""
LOOKBACK_WINDOW: int = None
"""get this many days additional data prior to the backtest start date or
trade date to account for rolling windows. If set to None (the default),
will use the largest value of any attributes ending with `*_WINDOW`, or
252 if no such attributes, and will further pad window based on any
`*_INTERVAL` attributes, which are interpreted as pandas offset aliases
(for example `REBALANCE_INTERVAL = 'Q'`). Set to 0 to disable."""
NLV: dict[str, float] = None
"""dict of currency:NLV for each currency represented in the strategy. Can
alternatively be passed directly to backtest method."""
COMMISSION_CLASS: Union[Type[Commission], dict[tuple[str, str, str], Type[Commission]]] = None
"""Commission class or dict of (sectype,exchange,currency): Commission class, optional
the commission class to use. If strategy includes a mix of security types,
exchanges, or currencies, you can pass a dict mapping tuples of
(sectype,exchange,currency) to the different commission classes. By default
no commission is applied."""
SLIPPAGE_CLASSES: tuple[Slippage] = ()
"""one or more slippage classes. By default no slippage is applied."""
SLIPPAGE_BPS: float = 0
"""amount on one-slippage to apply to each trade in BPS (for example, enter 5 to deduct
5 BPS)"""
BENCHMARK: str = None
"""the sid of a security in the historical data to use as the benchmark."""
BENCHMARK_DB: str = None
"""the database containing the benchmark, if different from DB. BENCHMARK_DB
should contain end-of-day data, not intraday (but can be used with intraday
backtests)."""
BENCHMARK_TIME: str = None
"""use prices from this time of day as benchmark prices. Only applicable if
benchmark prices originate in DB (not BENCHMARK_DB), DB contains intraday
data, and backtest results are daily."""
TIMEZONE: str = None
"""convert timestamps to this timezone (if not provided, will be inferred
from securities universe if possible)"""
CALENDAR: str = None
"""use this exchange's trading calendar to determine which date's signals
should be used for live trading. If the exchange is currently open,
today's signals will be used. If currently closed, the signals corresponding
to the last date the exchange was open will be used. If no calendar is specified,
today's signals will be used."""
POSITIONS_CLOSED_DAILY: bool = False
"""if True, positions in backtests that fall on adjacent days are assumed to
be closed out and reopened each day rather than held continuously; this
impacts commission and slippage calculations (default is False, meaning
adjacent positions are assumed to be held continuously)"""
ALLOW_REBALANCE: Union[bool, float] = True
"""in live trading, whether to allow rebalancing of existing positions that
are already on the correct side. If True (the default), allow rebalancing.
If False, no rebalancing. If set to a positive decimal, allow rebalancing
only when the existing position differs from the target position by at least
this percentage. For example 0.5 means don't rebalance a position unless
the position will change by +/-50%."""
CONTRACT_VALUE_REFERENCE_FIELD: str = None
"""the price field to use for determining contract values for the purpose of
applying commissions and constraining weights in backtests and calculating
order quantities in trading. Defaults to the first available of Close, Open,
MinuteCloseClose, SecondCloseClose, LastPriceClose, BidPriceClose, AskPriceClose,
TimeSalesLastPriceClose, TimeSalesFilteredLastPriceClose, LastPriceMean,
BidPriceMean, AskPriceMean, TimeSalesLastPriceMean, TimeSalesFilteredLastPriceMean,
MinuteOpenOpen, SecondOpenOpen, LastPriceOpen, BidPriceOpen, AskPriceOpen,
TimeSalesLastPriceOpen, TimeSalesFilteredLastPriceOpen."""
ACCOUNT_BALANCE_FIELD: Union[str, list[str]] = None
"""the account field to use for calculating order quantities as a percentage of
account equity. Applies to trading only, not backtesting. Default is
NetLiquidation. If a list of fields is provided, the minimum value is used.
For example, ['NetLiquidation', 'PreviousEquity'] means to use the lesser of
NetLiquidation or PreviousEquity to determine order quantities."""
def __init__(self):
self.is_trade: bool = False
self.review_date: str = None # see trade() docstring
self.is_backtest: bool = False
self._securities_master = None
self._backtest_results = {}
self._inferred_timezone = None
self._signal_date = None # set by _weights_to_today_weights
self._signal_time = None # set by _weights_to_today_weights
def prices_to_signals(self, prices: pd.DataFrame) -> pd.DataFrame:
"""
From a DataFrame of prices, return a DataFrame of signals. By convention,
signals should be 1=long, 0=cash, -1=short.
Must be implemented by strategy subclasses.
Parameters
----------
prices : DataFrame, required
multiindex (Field, Date) or (Field, Date, Time) DataFrame of
price/market data
Returns
-------
DataFrame
signals
Notes
-----
Usage Guide:
* Moonshot: https://qrok.it/dl/ms/moonshot
Examples
--------
Buy when the close is above yesterday's 50-day moving average::
import pandas as pd
def prices_to_signals(self, prices: pd.DataFrame):
closes = prices.loc["Close"]
mavgs = closes.rolling(50).mean()
signals = closes > mavgs.shift()
return signals.astype(int)
"""
raise NotImplementedError("strategies must implement prices_to_signals")
def signals_to_target_weights(
self,
signals: pd.DataFrame,
prices: pd.DataFrame
) -> pd.DataFrame:
"""
From a DataFrame of signals, return a DataFrame of target weights.
Whereas signals indicate the direction of the trades, weights
indicate both the direction and size. For example, -0.5 means a short
position equal to 50% of the equity allocated to the strategy.
Weights are used to help create orders in live trading, and to help
simulate executed positions in backtests.
The default implemention of this method evenly divides allocated
capital among the signals each period, but it is intended to be
overridden by strategy subclasses.
A variety of built-in weight allocation algorithms are provided by
and documented under `moonshot.mixins.WeightAllocationMixin`.
Parameters
----------
signals : DataFrame, required
a DataFrame of signals
prices : DataFrame, required
multiindex (Field, Date) or (Field, Date, Time) DataFrame
of price/market data
Returns
-------
DataFrame
weights
Notes
-----
Usage Guide:
* Moonshot: https://qrok.it/dl/ms/moonshot
Examples
--------
The default implementation is shown below::
def signals_to_target_weights(self, signals: pd.DataFrame, prices: pd.DataFrame):
weights = self.allocate_equal_weights(signals) # provided by moonshot.mixins.WeightAllocationMixin
return weights
"""
weights = self.allocate_equal_weights(signals)
return weights
def target_weights_to_positions(
self,
weights: pd.DataFrame,
prices: pd.DataFrame
) -> pd.DataFrame:
"""
From a DataFrame of target weights, return a DataFrame of simulated
positions.
The positions should shift the weights based on when the weights
would be filled in live trading.
By default, assumes the position are taken in the period after the
weights were allocated. Intended to be overridden by strategy
subclasses.
Parameters
----------
weights : DataFrame, required
a DataFrame of weights
prices : DataFrame, required
multiindex (Field, Date) or (Field, Date, Time) DataFrame of
price/market data
Returns
-------
DataFrame
positions
Notes
-----
Usage Guide:
* Moonshot: https://qrok.it/dl/ms/moonshot
Examples
--------
The default implemention is shown below (enter position in the period after
signal generation/weight allocation)::
def target_weights_to_positions(self, weights: pd.DataFrame, prices: pd.DataFrame):
positions = weights.shift()
return positions
"""
positions = weights.shift()
return positions
def positions_to_gross_returns(
self,
positions: pd.DataFrame,
prices: pd.DataFrame
) -> pd.DataFrame:
"""
From a DataFrame of positions, return a DataFrame of returns before
commissions and slippage.
By default, assumes entry on the close on the period the position is
taken and calculates the return through the following period's close.
Intended to be overridden by strategy subclasses.
Parameters
----------
positions : DataFrame, required
a DataFrame of positions
prices : DataFrame, required
multiindex (Field, Date) or (Field, Date, Time) DataFrame of
price/market data
Returns
-------
DataFrame
gross returns
Notes
-----
Usage Guide:
* Moonshot: https://qrok.it/dl/ms/moonshot
Examples
--------
The default implementation is shown below::
def positions_to_gross_returns(self, positions: pd.DataFrame, prices: pd.DataFrame):
closes = prices.loc["Close"]
gross_returns = closes.pct_change() * positions.shift()
return gross_returns
"""
closes = prices.loc["Close"]
gross_returns = closes.pct_change() * positions.shift()
return gross_returns
def order_stubs_to_orders(
self,
orders: pd.DataFrame,
prices: pd.DataFrame
) -> pd.DataFrame:
"""
From a DataFrame of order stubs, creates a DataFrame of fully
specified orders.
Parameters
----------
orders : DataFrame
a DataFrame of order stubs, with columns Sid, Account, Action,
OrderRef, and TotalQuantity
prices : DataFrame
multiindex (Field, Date) or (Field, Date, Time) DataFrame of
price/market data
Returns
-------
DataFrame
a DataFrame of fully specified orders, with (at minimum) columns
Exchange, Tif, OrderType added
Notes
-----
Usage Guide:
* Moonshot live trading: https://qrok.it/dl/ms/moonshot-trade
Examples
--------
The orders DataFrame provided to this method resembles the following:
>>> print(orders)
Sid Account Action OrderRef TotalQuantity
0 12345 U12345 SELL my-strategy 100
1 12345 U55555 SELL my-strategy 50
2 23456 U12345 BUY my-strategy 100
3 23456 U55555 BUY my-strategy 50
4 34567 U12345 BUY my-strategy 200
5 34567 U55555 BUY my-strategy 100
The default implemention creates MKT DAY orders and is
shown below::
def order_stubs_to_orders(self, orders: pd.DataFrame, prices: pd.DataFrame):
orders["OrderType"] = "MKT"
orders["Tif"] = "DAY"
return orders
Set a limit price equal to the prior closing price::
closes = prices.loc["Close"]
prior_closes = closes.shift()
prior_closes = self.reindex_like_orders(prior_closes, orders)
orders["OrderType"] = "LMT"
orders["LmtPrice"] = prior_closes
"""
orders["OrderType"] = "MKT"
orders["Tif"] = "DAY"
return orders
def reindex_like_orders(
self,
df: pd.DataFrame,
orders: pd.DataFrame
) -> 'pd.Series[Any]':
"""
Reindexes a DataFrame (having Sids as columns and dates as index)
to match the shape of the orders DataFrame.
Parameters
----------
df : DataFrame, required
a DataFrame of arbitrary values with Sids as columns and
dates as index
orders : DataFrame, required
an orders DataFrame with a Sid column
Returns
-------
Series
a Series with an index matching orders
Notes
-----
Usage Guide:
* Moonshot live trading: https://qrok.it/dl/ms/moonshot-trade
Examples
--------
Calculate prior closes (assuming daily bars) and reindex like
orders::
closes = prices.loc["Close"]
prior_closes = closes.shift()
prior_closes = self.reindex_like_orders(prior_closes, orders)
Calculate prior closes (assuming 30-min bars) and reindex like
orders::
session_closes = prices.loc["Close"].xs("15:30:00", level="Time")
prior_closes = session_closes.shift()
prior_closes = self.reindex_like_orders(prior_closes, orders)
"""
df = df.loc[self._signal_date]
if "Time" in df.index.names:
if not self._signal_time:
raise MoonshotError(
"cannot reindex DataFrame like orders because DataFrame contains "
"'Time' in index, please take a cross-section first, for example: "
"`my_dataframe.xs('15:45:00', level='Time')`")
df = df.loc[self._signal_time]
df.name = "_MoonshotOther"
df = orders.join(df, on="Sid")._MoonshotOther
df.name = None
return df
def orders_to_child_orders(self, orders: pd.DataFrame) -> pd.DataFrame:
"""
From a DataFrame of orders, returns a DataFrame of child orders
(bracket orders) to be submitted if the parent orders fill.
An OrderId column will be added to the orders DataFrame, and child
orders will be linked to it via a ParentId column. The Action
(BUY/SELL) will be reversed on the child orders but otherwise the
child orders will be identical to the parent orders.
Parameters
----------
orders : DataFrame, required
an orders DataFrame
Returns
-------
DataFrame
a DataFrame of child orders
Notes
-----
Usage Guide:
* Moonshot live trading: https://qrok.it/dl/ms/moonshot-trade
Examples
--------
>>> orders.head()
Sid Action TotalQuantity Exchange OrderType Tif
0 12345 BUY 200 SMART MKT Day
1 23456 BUY 400 SMART MKT Day
>>> child_orders = self.orders_to_child_orders(orders)
>>> child_orders["OrderType"] = "MOC"
>>> orders = pd.concat([orders,child_orders])
>>> orders.head()
Sid Action TotalQuantity Exchange OrderType Tif OrderId ParentId
0 12345 BUY 200 SMART MKT Day 0 NaN
1 23456 BUY 400 SMART MKT Day 1 NaN
0 12345 SELL 200 SMART MOC Day NaN 0
1 23456 SELL 400 SMART MOC Day NaN 1
"""
if "OrderId" not in orders.columns:
orders["OrderId"] = orders.index.astype(str) + ".{0}".format(time.time())
child_orders = orders.copy()
child_orders.rename(columns={"OrderId":"ParentId"}, inplace=True)
child_orders.loc[orders.Action=="BUY", "Action"] = "SELL"
child_orders.loc[orders.Action=="SELL", "Action"] = "BUY"
return child_orders
def _quantities_to_order_stubs(self, quantities):
"""
From a DataFrame of quantities to be ordered (with Sids as index,
Accounts as columns), returns a DataFrame of order stubs.
quantities in:
Account U12345 U55555
Sid
12345 -100 -50
23456 100 50
34567 200 100
order_stubs out:
Sid Account Action OrderRef TotalQuantity
0 12345 U12345 SELL my-strategy 100
1 12345 U55555 SELL my-strategy 50
2 23456 U12345 BUY my-strategy 100
3 23456 U55555 BUY my-strategy 50
4 34567 U12345 BUY my-strategy 200
5 34567 U55555 BUY my-strategy 100
"""
quantities.index.name = "Sid"
quantities.columns.name = "Account"
quantities = quantities.stack()
quantities.name = "Quantity"
order_stubs = quantities.to_frame().reset_index()
order_stubs["Action"] = np.where(order_stubs.Quantity > 0, "BUY", "SELL")
order_stubs = order_stubs.loc[order_stubs.Quantity != 0].copy()
order_stubs["OrderRef"] = self.CODE
order_stubs["TotalQuantity"] = order_stubs.Quantity.abs()
order_stubs = order_stubs.drop("Quantity",axis=1)
return order_stubs
def _get_nlv(self):
"""
Return a dict of currency:NLV for each currency in the strategy. By
default simply returns the NLV class attribute.
"""
return self.NLV
def _positions_to_turnover(self, positions):
"""
Given a dataframe of positions, returns a dataframe of turnover. 0
indicates no turnover; 1 indicates going from 100% short to cash or
cash to 100% long (for example), and vice versa; and 2 indicates
going from 100% short to %100 long (for example).
"""
# Intraday trades are opened and closed each day there's a position,
# so the turnover is twice the positions.
if self.POSITIONS_CLOSED_DAILY:
turnover = positions * 2
else:
turnover = positions.fillna(0).diff()
return turnover.abs()
def _weights_to_today_weights(self, weights, prices):
"""
From a DataFrame of target weights, extract the row that contains the
weights that should be used for today's trading. Returns a Series of
weights by sid:
Sid
12345 -0.2
23456 0
34567 0.1
The date whose weights are selected is usually today, but if CALENDAR
is used and the market is closed it will be the date when the market
closed. Can also be overridden by review_date.
For intraday strategies, the time whose weights are selected is the
latest time that is earlier than the time at which the strategy is
running.
"""
# First, get the signal date
# Use review_date if set
if self.review_date:
dt = pd.Timestamp(self.review_date)
# Else use trading calendar if provided
elif self.CALENDAR:
status = list_calendar_statuses([self.CALENDAR])[self.CALENDAR]
# If the exchange if closed, the signals should correspond to the
# date the exchange was last open
if status["status"] == "closed":
dt = pd.Timestamp(status["since"])
# If the exchange is open, the signals should correspond to
# today's date
else:
dt = pd.Timestamp.now(tz=status["timezone"])
# If no trading calendar, use today's date (in strategy timezone)
else:
tz = self.TIMEZONE or self._inferred_timezone
dt = pd.Timestamp.now(tz=tz)
prices_tz = prices.index.get_level_values("Date").tz
# Keep only the date as the signal_date
self._signal_date = pd.Timestamp(dt.date(), tz=prices_tz)
# extract the current time (or review date time)
trade_time = dt.strftime("%H:%M:%S")
weights_is_intraday = "Time" in weights.index.names
try:
today_weights = weights.loc[self._signal_date]
except KeyError:
if weights_is_intraday:
max_date = weights.index.get_level_values("Date").max()
else:
max_date = weights.index.max()
msg = ("expected signal date {0} not found in target weights DataFrame, "
"is the underlying data up-to-date? (max date is {1})")
if not self.CALENDAR and not weights_is_intraday and self._signal_date.date() - max_date.date() == pd.Timedelta(days=1):
msg += (" If your strategy trades before the open and {0} data "
"is not expected, try setting CALENDAR = <exchange>")
raise MoonshotError(msg.format(
self._signal_date.date().isoformat(),
max_date.date().isoformat()))
if not weights_is_intraday:
print("using target weights for {0} to create orders".format(self._signal_date.date().isoformat()))
return today_weights
# For intraday strategies, select the weights from the latest time
# that is earlier than the trade time. Note that we select the
# expected time from the entire weights DataFrame, which will result
# in a failure if that time is missing for the trade date
unique_times = weights.index.get_level_values("Time").unique()
self._signal_time = unique_times[unique_times < trade_time].max()
if pd.isnull(self._signal_time):
msg = (
"cannot determine which target weights to use for orders because "
"target weights DataFrame contains no times earlier than trade time {0} "
"for signal date {1}".format(
trade_time,
self._signal_date.date().isoformat()))
if self.review_date:
msg += ", please adjust the review_date"
raise MoonshotError(msg)
# get_prices inserts all times into each day's index, thus
# the signal_time will be in the weights DataFrame even if the data
# is stale. Instead, to validate the data, we make sure that there is
# at least one nonnull field in the prices DataFrame at the
# signal_time on the signal_date
today_prices = prices.xs(self._signal_date, level="Date")
notnull_today_prices = today_prices[today_prices.notnull().any(axis=1)]
try:
no_signal_time_prices = notnull_today_prices.xs(self._signal_time, level="Time").empty
except KeyError:
no_signal_time_prices = True
if no_signal_time_prices:
msg = ("no {0} data found in prices DataFrame for signal date {1}, "
"is the underlying data up-to-date? (max time for {1} "
"is {2})")
notnull_max_date = notnull_today_prices.iloc[-1].name[-1]
raise MoonshotError(msg.format(
self._signal_time,
self._signal_date.date().isoformat(),
notnull_max_date))
today_weights = today_weights.loc[self._signal_time]
print("using target weights for {0} at {1} to create orders".format(
self._signal_date.date().isoformat(),
self._signal_time))
return today_weights
def _get_commissions(self, positions, prices):
"""
Returns the commissions to be subtracted from the returns.
"""
if not self.COMMISSION_CLASS:
return pd.DataFrame(0, index=positions.index, columns=positions.columns)
turnover = self._positions_to_turnover(positions)
contract_values = self._get_contract_values(prices)
prices_is_intraday = "Time" in prices.index.names
positions_is_intraday = "Time" in positions.index.names
if prices_is_intraday and not positions_is_intraday:
contract_values = contract_values.groupby(
contract_values.index.get_level_values("Date")).first()
fields = prices.index.get_level_values("Field").unique()
if "Nlv" in self._securities_master.columns:
nlvs = contract_values.apply(lambda x: self._securities_master.Nlv, axis=1)
else:
nlvs = None
# handle the case of only one commission class
if not isinstance(self.COMMISSION_CLASS, dict):
commissions = self.COMMISSION_CLASS.get_commissions(contract_values, turnover=turnover, nlvs=nlvs)
return commissions
# handle multiple commission classes per sectype/exchange/currency
# first, tuple-ize the dict keys in case they are lists
commission_classes = {}
for sec_group, commission_cls in self.COMMISSION_CLASS.items():
commission_classes[tuple(sec_group)] = commission_cls
defined_sec_groups = set([tuple(k) for k in commission_classes.keys()])
# Reindex master fields like contract_values
sec_types = contract_values.apply(lambda x: self._securities_master.SecType, axis=1)
exchanges = contract_values.apply(lambda x: self._securities_master.Exchange, axis=1)
currencies = contract_values.apply(lambda x: self._securities_master.Currency, axis=1)
required_sec_groups = set([
tuple(s.split("|")) for s in (sec_types+"|"+exchanges+"|"+currencies).iloc[-1].unique()])
missing_sec_groups = required_sec_groups - defined_sec_groups
if missing_sec_groups:
raise MoonshotParameterError("expected a commission class for each combination of (sectype,exchange,currency) "
"but none is defined for {0}".format(
", ".join(["({0})".format(",".join(t)) for t in missing_sec_groups])))
all_commissions = pd.DataFrame(None, index=positions.index, columns=positions.columns)
for sec_group in required_sec_groups:
commission_cls = commission_classes[sec_group]
sec_type, exchange, currency = sec_group
sec_group_commissions = commission_cls.get_commissions(
contract_values, turnover=turnover, nlvs=nlvs)
in_sec_group = (sec_types == sec_type) & (exchanges == exchange) & (currencies == currency)
all_commissions = sec_group_commissions.where(in_sec_group, all_commissions)
return all_commissions
def _get_slippage(self, positions, prices):
"""
Returns the slippage to be subtracted from the returns.
"""
turnover = self._positions_to_turnover(positions)
slippage = pd.DataFrame(0, index=turnover.index, columns=turnover.columns)
slippage_classes = self.SLIPPAGE_CLASSES or ()
if not isinstance(slippage_classes, (list, tuple)):
slippage_classes = [slippage_classes]
for slippage_class in slippage_classes:
slippage += slippage_class().get_slippage(turnover, positions, prices)
if self.SLIPPAGE_BPS:
slippage += FixedSlippage(self.SLIPPAGE_BPS/10000.0).get_slippage(turnover, positions, prices)
return slippage.fillna(0)
def _constrain_weights(self, weights, prices):
"""
Constrains the weights by the quantity constraints defined in
limit_position_sizes.
"""
max_quantities_for_longs, max_quantities_for_shorts = self.limit_position_sizes(prices)
if max_quantities_for_longs is None and max_quantities_for_shorts is None:
return weights
if "Nlv" not in self._securities_master.columns:
raise MoonshotParameterError("must provide NLVs if using limit_position_sizes")
contract_values = self._get_contract_values(prices)
contract_values = contract_values.ffill()
nlvs_in_trade_currency = contract_values.apply(lambda x: self._securities_master.Nlv, axis=1)
prices_is_intraday = "Time" in prices.index.names
weights_is_intraday = "Time" in weights.index.names
if prices_is_intraday and not weights_is_intraday:
# we somewhat arbitrarily pick the contract value as of the
# earliest time of day; this contract value might be somewhat
# stale but it avoids the possible lookahead bias of using, say,
# the contract value as of the latest time of day. We could ask
# the user to supply a time but that is rather clunky.
earliest_time = prices.index.get_level_values("Time").unique().min()
contract_values = contract_values.xs(earliest_time, level="Time")
nlvs_in_trade_currency = nlvs_in_trade_currency.xs(earliest_time, level="Time")
# Convert weights to quantities
trade_values_in_trade_currency = weights * nlvs_in_trade_currency
# Note: we take abs() of contract_values because combos can have
# negative prices which would invert the sign of the trade
quantities = trade_values_in_trade_currency / contract_values.where(contract_values != 0).abs()
quantities = quantities.round().fillna(0).astype(int)
# Constrain quantities
if max_quantities_for_longs is not None:
max_quantities_for_longs = max_quantities_for_longs.abs()
quantities = max_quantities_for_longs.where(
quantities > max_quantities_for_longs, quantities)
if max_quantities_for_shorts is not None:
max_quantities_for_shorts = -max_quantities_for_shorts.abs()
quantities = max_quantities_for_shorts.where(
quantities < max_quantities_for_shorts, quantities)
# Convert quantities back to weights
target_trade_values_in_trade_currency = quantities * contract_values
weights = target_trade_values_in_trade_currency / nlvs_in_trade_currency
return weights
def limit_position_sizes(
self,
prices: pd.DataFrame
) -> tuple[pd.DataFrame, pd.DataFrame]:
"""
This method should return a tuple of DataFrames::
return max_quantities_for_longs, max_quantities_for_shorts
where the DataFrames define the maximum number of shares/contracts
that can be held long and short, respectively. Maximum limits might
be based on available liquidity (recent volume), shortable shares
available, etc.
The shape and alignment of the returned DataFrames should match that of the
target_weights returned by `signals_to_target_weights`. Target weights will be
reduced, if necessary, based on max_quantities_for_longs and max_quantities_for_shorts.
Return None for one or both DataFrames to indicate "no limits."
For example to limit shorts but not longs::
return None, max_quantities_for_shorts
Within a DataFrame, any None or NaNs will be treated as "no limit" for that
particular security and date.
Note that max_quantities_for_shorts can equivalently be represented with