Compare commits
1852 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bb63ba33d | ||
|
|
46a062d5fb | ||
|
|
8b7183cdbc | ||
|
|
beb15532f7 | ||
|
|
107f3ed35b | ||
|
|
f82b809fcf | ||
|
|
9bad75f37d | ||
|
|
864bbc441a | ||
|
|
e2df908304 | ||
|
|
4ea5fcc661 | ||
|
|
9c66c25890 | ||
|
|
925b9b0c19 | ||
|
|
e25d8f9435 | ||
|
|
0b3e4f6bcd | ||
|
|
295dfe2652 | ||
|
|
df9015a7f1 | ||
|
|
90caa09ae0 | ||
|
|
909fd39b80 | ||
|
|
d23cd73ba8 | ||
|
|
fc219b4e94 | ||
|
|
818a6b12ed | ||
|
|
4bd61df3a7 | ||
|
|
c40e6a12d1 | ||
|
|
3360bf4001 | ||
|
|
168ed91fe1 | ||
|
|
9a07d57ed7 | ||
|
|
2be7b3d9eb | ||
|
|
e2a2a0be9b | ||
|
|
f7e5d2c3a5 | ||
|
|
cbfee51f32 | ||
|
|
8a44dff595 | ||
|
|
c73b9f5c77 | ||
|
|
c413e94f83 | ||
|
|
98cd8970f9 | ||
|
|
5fcdd3831c | ||
|
|
7f927b4d7a | ||
|
|
99e3c6e526 | ||
|
|
c7976f51e2 | ||
|
|
2c43590268 | ||
|
|
36cfea3d0f | ||
|
|
a493a2ceef | ||
|
|
96b7273b8f | ||
|
|
e66b861c9e | ||
|
|
e0db31e9db | ||
|
|
a7be15d72f | ||
|
|
f7b46d5404 | ||
|
|
488f1717a1 | ||
|
|
2b0ef54595 | ||
|
|
896afe7118 | ||
|
|
ef53134499 | ||
|
|
c31519fdb2 | ||
|
|
162f948729 | ||
|
|
ae4c4e77bf | ||
|
|
695beecf14 | ||
|
|
cb015dec7b | ||
|
|
9bc8331667 | ||
|
|
520c7feeab | ||
|
|
1e3d722bc2 | ||
|
|
c9f8dfc6c5 | ||
|
|
d156de39f1 | ||
|
|
2b099a89e4 | ||
|
|
6e6ec969eb | ||
|
|
e194af8d25 | ||
|
|
ace5198475 | ||
|
|
52d36c33cf | ||
|
|
251f7db3ca | ||
|
|
c83e8b7cb5 | ||
|
|
64e09f74a1 | ||
|
|
63b568989a | ||
|
|
975b42caa3 | ||
|
|
75d02df60d | ||
|
|
082b6077e9 | ||
|
|
e8ab76f55b | ||
|
|
495f15f13c | ||
|
|
68f6423d39 | ||
|
|
67d345bc08 | ||
|
|
a159db6863 | ||
|
|
dea26fadfe | ||
|
|
21edcbdc27 | ||
|
|
e3c91df081 | ||
|
|
c608f1e21e | ||
|
|
fef267a0dc | ||
|
|
5ce2071279 | ||
|
|
ad0549414b | ||
|
|
c6cc9ae29d | ||
|
|
ae94ab17f4 | ||
|
|
eb909068c5 | ||
|
|
90a7fb603d | ||
|
|
7cfd99d17f | ||
|
|
972736f0ab | ||
|
|
934974a547 | ||
|
|
17801871b1 | ||
|
|
7564f7e526 | ||
|
|
fa00157d12 | ||
|
|
a5511e2e30 | ||
|
|
0347ce21fd | ||
|
|
7e2e7946c5 | ||
|
|
0c85febe76 | ||
|
|
c1f8f641e6 | ||
|
|
af16830a38 | ||
|
|
a8d25266f9 | ||
|
|
b78b9dccc8 | ||
|
|
e8fd11d6ce | ||
|
|
1e208e39b0 | ||
|
|
5c3e37412e | ||
|
|
c731f7dd29 | ||
|
|
ea805a8fb7 | ||
|
|
c0289ad844 | ||
|
|
e600be4f56 | ||
|
|
d7e7ef11f9 | ||
|
|
d684ff5715 | ||
|
|
6edb25f5c2 | ||
|
|
e6e5c5daf0 | ||
|
|
61f92b7460 | ||
|
|
2b74982a1d | ||
|
|
46080f5168 | ||
|
|
1dcc2de776 | ||
|
|
875408215b | ||
|
|
038acd3f5e | ||
|
|
f404e0f5b3 | ||
|
|
92b0cbdc19 | ||
|
|
e14c9e2090 | ||
|
|
83eb7a0a9d | ||
|
|
6c1bb7983b | ||
|
|
34e10a145c | ||
|
|
3787dad212 | ||
|
|
4048859912 | ||
|
|
cddb062db5 | ||
|
|
13ba68acc6 | ||
|
|
e22da45474 | ||
|
|
6357812743 | ||
|
|
6e68c3b230 | ||
|
|
0f117d480e | ||
|
|
8d8e6dcffc | ||
|
|
e3ced7c15e | ||
|
|
182f4c603b | ||
|
|
1f6b9c332b | ||
|
|
bfde33c945 | ||
|
|
bd6ed3ada4 | ||
|
|
aa6e276cf9 | ||
|
|
3694499a6a | ||
|
|
06b71d713c | ||
|
|
7141060a2d | ||
|
|
59a4dffc56 | ||
|
|
708320318c | ||
|
|
40746c3fcb | ||
|
|
a0f735d4f2 | ||
|
|
335d1fbbbc | ||
|
|
90025d0ac4 | ||
|
|
ce663f6af5 | ||
|
|
3676015184 | ||
|
|
3aff67605e | ||
|
|
7801688c6e | ||
|
|
17f3b217de | ||
|
|
d02af07d35 | ||
|
|
c46e50864b | ||
|
|
6c361c190b | ||
|
|
12e455cbf5 | ||
|
|
a9f3744f1b | ||
|
|
53e1b8c0d5 | ||
|
|
2ba363684d | ||
|
|
9cc087c788 | ||
|
|
4710210cff | ||
|
|
27ee8f7360 | ||
|
|
1cd7ac55a8 | ||
|
|
b81588307f | ||
|
|
31025216f9 | ||
|
|
aff1ede46b | ||
|
|
322a528c12 | ||
|
|
17c0ceec04 | ||
|
|
c9476fade8 | ||
|
|
7b5a2946e5 | ||
|
|
9c57d3aa8b | ||
|
|
c1b2e06eda | ||
|
|
3094acc7fb | ||
|
|
24a875ed46 | ||
|
|
5623ea3ac6 | ||
|
|
655155bbab | ||
|
|
28e8840456 | ||
|
|
8c73fd6e59 | ||
|
|
eb58e7cb82 | ||
|
|
8db3dfa8c6 | ||
|
|
efd69b2cd5 | ||
|
|
38c32f0e10 | ||
|
|
62b4efb881 | ||
|
|
b5c200f6c4 | ||
|
|
18e3090379 | ||
|
|
1e1be6bc3f | ||
|
|
f0456bb802 | ||
|
|
61da7f63b2 | ||
|
|
0b5d21f32a | ||
|
|
74db82d759 | ||
|
|
5851cc70a7 | ||
|
|
faeda0e70c | ||
|
|
73c5f0ec90 | ||
|
|
66f6e71e7e | ||
|
|
cc4b2eef13 | ||
|
|
8effc5f929 | ||
|
|
5f93c5e789 | ||
|
|
980172a55a | ||
|
|
760e878dd8 | ||
|
|
4dbc7abd0f | ||
|
|
867faf1c30 | ||
|
|
43d19790ae | ||
|
|
0bc86e72b3 | ||
|
|
5ca84acb6d | ||
|
|
c4af66e312 | ||
|
|
c37792dbc4 | ||
|
|
50852136ef | ||
|
|
20082f52a2 | ||
|
|
5ec3eb76eb | ||
|
|
dfbc94c05b | ||
|
|
81ce7d720d | ||
|
|
1db0f2bd55 | ||
|
|
9292eb664a | ||
|
|
8f91eeb195 | ||
|
|
10e12ec1b9 | ||
|
|
61b2373dd1 | ||
|
|
7f881cce85 | ||
|
|
bea9a3304e | ||
|
|
95d6c9c678 | ||
|
|
a2a1a517da | ||
|
|
27f83b511f | ||
|
|
b1b87731b1 | ||
|
|
b4138f29c8 | ||
|
|
dd3a53fb5f | ||
|
|
d23bcc435a | ||
|
|
45eb1b4f0a | ||
|
|
d41f71bc34 | ||
|
|
f5fe9a4b1c | ||
|
|
0e699b87af | ||
|
|
3f5efef6e5 | ||
|
|
d4f8704a4c | ||
|
|
526cb1ea20 | ||
|
|
f5b47fbd86 | ||
|
|
3cee04fb8c | ||
|
|
ac602ed5a9 | ||
|
|
ad510b8b5f | ||
|
|
3436af3931 | ||
|
|
01675f50bf | ||
|
|
17742df591 | ||
|
|
5b1ff6675f | ||
|
|
7bcac064c0 | ||
|
|
867145cd09 | ||
|
|
959a03a6b0 | ||
|
|
b4ae5a36a8 | ||
|
|
7f8e0ba25f | ||
|
|
c75b70463b | ||
|
|
f9788afbfb | ||
|
|
7b0a5644a3 | ||
|
|
34b5203760 | ||
|
|
a2fd70417c | ||
|
|
c3d0980763 | ||
|
|
4ee5271de7 | ||
|
|
f6ef466876 | ||
|
|
00b646158c | ||
|
|
c8a43bad67 | ||
|
|
a29ac44d64 | ||
|
|
e2aa78c11b | ||
|
|
58a6f21705 | ||
|
|
8583e89550 | ||
|
|
e8ab754646 | ||
|
|
5c1ee52815 | ||
|
|
02671a7e10 | ||
|
|
2ba5e2053a | ||
|
|
7714490530 | ||
|
|
4a17671f45 | ||
|
|
a901f21bcd | ||
|
|
e690003621 | ||
|
|
fb49d706d0 | ||
|
|
cac6e0d715 | ||
|
|
f37c5b70ba | ||
|
|
270ccbb0da | ||
|
|
7a34578b4d | ||
|
|
12d8a8b1a3 | ||
|
|
7d3eefa97a | ||
|
|
608fc170d9 | ||
|
|
456d0a050f | ||
|
|
399dd7df95 | ||
|
|
7cc36eee0f | ||
|
|
5024cd52af | ||
|
|
c29c13dfd7 | ||
|
|
947462e134 | ||
|
|
3778bcda24 | ||
|
|
1b071b1f4a | ||
|
|
8edcef6d32 | ||
|
|
662436acd2 | ||
|
|
e16fb45d84 | ||
|
|
1c75bfdddd | ||
|
|
64504e6777 | ||
|
|
af76d5f0e0 | ||
|
|
5683f9e10e | ||
|
|
15fb81da92 | ||
|
|
e52ec14588 | ||
|
|
b13658b319 | ||
|
|
a44978a068 | ||
|
|
bee2541bd8 | ||
|
|
7c8bf95f8f | ||
|
|
7df77b1b28 | ||
|
|
b995e04daa | ||
|
|
0f3dc821f2 | ||
|
|
5ff405b0b0 | ||
|
|
86ae9d25f0 | ||
|
|
3321e4cafd | ||
|
|
639b6bc4f6 | ||
|
|
af1ba1e191 | ||
|
|
5c7899ae98 | ||
|
|
d4b431a335 | ||
|
|
6891054b84 | ||
|
|
e10279b7b4 | ||
|
|
a0c79bd727 | ||
|
|
4b8f382cfd | ||
|
|
eeda93a359 | ||
|
|
7b79ca3e8f | ||
|
|
3030bf9778 | ||
|
|
87f750da35 | ||
|
|
daa9c0c026 | ||
|
|
5ef2654eb4 | ||
|
|
26120ff675 | ||
|
|
e453dab4a3 | ||
|
|
482d063638 | ||
|
|
2f3b0cd422 | ||
|
|
e3227a741c | ||
|
|
4eb8295955 | ||
|
|
bdb25bbcbc | ||
|
|
43696eff5c | ||
|
|
c6b93f8fe5 | ||
|
|
d3d62e90d3 | ||
|
|
20815771ab | ||
|
|
b6754601ef | ||
|
|
0f352a4b5c | ||
|
|
7d6b11cb10 | ||
|
|
3a158faa30 | ||
|
|
fff7ec1dab | ||
|
|
50fc5f91ca | ||
|
|
ec7c11513e | ||
|
|
cfb06ceb58 | ||
|
|
e8a59f4c20 | ||
|
|
638d98735f | ||
|
|
c9e49ed7b4 | ||
|
|
acbfe91f13 | ||
|
|
7edafbb772 | ||
|
|
a657e3d24a | ||
|
|
2cd8782a88 | ||
|
|
fe8ff1b929 | ||
|
|
127cf5d619 | ||
|
|
5e99df1759 | ||
|
|
94e586c049 | ||
|
|
dc65753a64 | ||
|
|
43ba02afc6 | ||
|
|
9537f17dd4 | ||
|
|
2791d543ea | ||
|
|
9dbe5fdb85 | ||
|
|
6ca375a397 | ||
|
|
81bb128cf7 | ||
|
|
a8bf5092e8 | ||
|
|
f88729f0e8 | ||
|
|
3447e4bb97 | ||
|
|
884395415f | ||
|
|
0007002c80 | ||
|
|
0a595190a3 | ||
|
|
32300f6d5f | ||
|
|
d9e951447f | ||
|
|
6fc21e30e5 | ||
|
|
6106822d10 | ||
|
|
4a322abd4d | ||
|
|
52309cc292 | ||
|
|
b5c41ca0fc | ||
|
|
a82a31341b | ||
|
|
0980e7e82d | ||
|
|
41efe99770 | ||
|
|
792dd556a1 | ||
|
|
b731a65c75 | ||
|
|
e28973c50a | ||
|
|
633620a5e9 | ||
|
|
41a47df93f | ||
|
|
3fb1dd02f1 | ||
|
|
cf34b84cf1 | ||
|
|
f4f821e88e | ||
|
|
c0cef7250d | ||
|
|
2976a50c58 | ||
|
|
69006b8fe8 | ||
|
|
4eb55acdbc | ||
|
|
1352f135d0 | ||
|
|
0d251cbfdd | ||
|
|
4733aad7ff | ||
|
|
48516e6e1e | ||
|
|
45909af7e0 | ||
|
|
88755fcded | ||
|
|
0d6dffdc7e | ||
|
|
9aa468adda | ||
|
|
52386d8153 | ||
|
|
b7e0466d7c | ||
|
|
f91de3c10e | ||
|
|
4329c15a9b | ||
|
|
963d2a8368 | ||
|
|
d9eddfb1ee | ||
|
|
f59f534c64 | ||
|
|
5a4eb2cbf2 | ||
|
|
c471ccb2db | ||
|
|
656be523bc | ||
|
|
9cd7749867 | ||
|
|
1845e5d7ca | ||
|
|
9639a3805d | ||
|
|
bc88fbf948 | ||
|
|
94c1a6f2a6 | ||
|
|
280e8b3208 | ||
|
|
607c895065 | ||
|
|
a98fcee4f9 | ||
|
|
1ba5c5d9c6 | ||
|
|
3427c7eb54 | ||
|
|
cf5d691950 | ||
|
|
4e0b095f2b | ||
|
|
0837f3f9f3 | ||
|
|
bad5d57d71 | ||
|
|
620c7e8312 | ||
|
|
af0b1e806f | ||
|
|
cf522d1df2 | ||
|
|
318c973461 | ||
|
|
34e78a7400 | ||
|
|
9be98cd8f7 | ||
|
|
e267b84510 | ||
|
|
c7ef69f4eb | ||
|
|
8c22cfce37 | ||
|
|
e1cb0dbf28 | ||
|
|
e2efd7c6ec | ||
|
|
56e697acf5 | ||
|
|
13d6297b9f | ||
|
|
65c069dd9f | ||
|
|
b0536dba0b | ||
|
|
0c051b1b7a | ||
|
|
16eb793081 | ||
|
|
1cc132afe2 | ||
|
|
d985405fe7 | ||
|
|
e88fabe1d6 | ||
|
|
7f1f1ec1ad | ||
|
|
8094f84efe | ||
|
|
ef78f2f03a | ||
|
|
a1fa688da0 | ||
|
|
263bf918b1 | ||
|
|
58a2af8d80 | ||
|
|
594b541f34 | ||
|
|
cc3e4e9aa7 | ||
|
|
d74a0f0526 | ||
|
|
d112d90e8e | ||
|
|
2383a83c2d | ||
|
|
c2245362da | ||
|
|
c96f912043 | ||
|
|
f175f48418 | ||
|
|
6cc8017943 | ||
|
|
e0bd45efab | ||
|
|
f80864b5bc | ||
|
|
9a09e6b815 | ||
|
|
91f90920c2 | ||
|
|
8549201502 | ||
|
|
0665a23b0f | ||
|
|
b1c53ec656 | ||
|
|
790f35a5c8 | ||
|
|
3246c60472 | ||
|
|
57fc9df5f3 | ||
|
|
144be37a9a | ||
|
|
9b8f90dc9f | ||
|
|
d07491ceb2 | ||
|
|
14c140d242 | ||
|
|
263d34ae82 | ||
|
|
8f17b11610 | ||
|
|
177962fa05 | ||
|
|
d51ac94662 | ||
|
|
40dfe4b3a9 | ||
|
|
8b098859f4 | ||
|
|
72a2c37769 | ||
|
|
bc25007fef | ||
|
|
1e119013c8 | ||
|
|
189873f9d4 | ||
|
|
5b25ed99ac | ||
|
|
edd840ac35 | ||
|
|
58425993da | ||
|
|
e3ae1c6c2f | ||
|
|
40c581e5a8 | ||
|
|
631081a2b2 | ||
|
|
8e3ff8235f | ||
|
|
ada98abfee | ||
|
|
49266fc4b8 | ||
|
|
f5ff6ceead | ||
|
|
81ee6f8265 | ||
|
|
ab4e2bd5a9 | ||
|
|
01b6a0eb53 | ||
|
|
e1322b75a9 | ||
|
|
4ce927d455 | ||
|
|
20ebd744c3 | ||
|
|
b55822ad30 | ||
|
|
7552c912a2 | ||
|
|
89180adb35 | ||
|
|
6b008d2237 | ||
|
|
33ce904f41 | ||
|
|
1dbdb880e6 | ||
|
|
ed34c4f199 | ||
|
|
394ef35a45 | ||
|
|
ccf1c894b4 | ||
|
|
490cbde652 | ||
|
|
d3fb2e4516 | ||
|
|
2c49231fcd | ||
|
|
3d4019d8b7 | ||
|
|
932b65da27 | ||
|
|
2bfce64e6a | ||
|
|
6d2afdb146 | ||
|
|
43fd9b37df | ||
|
|
ceeb98dda9 | ||
|
|
a5c1547251 | ||
|
|
306885e174 | ||
|
|
90a107393a | ||
|
|
c72d4665a1 | ||
|
|
a76ed88496 | ||
|
|
bddf009a2b | ||
|
|
bc13b7901f | ||
|
|
24ab1b5be5 | ||
|
|
842b0c2270 | ||
|
|
743a1f1604 | ||
|
|
cec58323d4 | ||
|
|
a127e1db07 | ||
|
|
2267a420a4 | ||
|
|
82ea56c8fd | ||
|
|
ecaf6b763c | ||
|
|
0987af910e | ||
|
|
2e1124af1a | ||
|
|
2fe7812e20 | ||
|
|
8bd9ed1543 | ||
|
|
72c17e29c0 | ||
|
|
483415cd65 | ||
|
|
98669a3d62 | ||
|
|
9c2115c917 | ||
|
|
f6ecd8e514 | ||
|
|
9cbd0df644 | ||
|
|
6adab0cf6b | ||
|
|
6675120324 | ||
|
|
ab6589d573 | ||
|
|
9e94778fd7 | ||
|
|
2968347062 | ||
|
|
9450b76414 | ||
|
|
d2608cbf13 | ||
|
|
f580fbb91d | ||
|
|
9b0fbbdc14 | ||
|
|
aa213a3640 | ||
|
|
baeeaa777d | ||
|
|
20af4bae7c | ||
|
|
5baab91bb5 | ||
|
|
4c49229b77 | ||
|
|
93a7c46977 | ||
|
|
bc2bd7fe1e | ||
|
|
a70958da41 | ||
|
|
9f1544978d | ||
|
|
aa104f86e8 | ||
|
|
f838ba2a9b | ||
|
|
53e76a89ac | ||
|
|
de8db9293c | ||
|
|
fded8e5117 | ||
|
|
710c7daec5 | ||
|
|
be95d699d2 | ||
|
|
c43ceb2045 | ||
|
|
9ab4953472 | ||
|
|
bbe3bc4423 | ||
|
|
acb1b50924 | ||
|
|
a140748b5a | ||
|
|
573b6b8e15 | ||
|
|
23e989d31f | ||
|
|
990f8a996b | ||
|
|
f4077a51c1 | ||
|
|
403f59ef45 | ||
|
|
001d7443da | ||
|
|
4eb66aa9ce | ||
|
|
ce90ee4ac2 | ||
|
|
06d230279c | ||
|
|
47748bc6f7 | ||
|
|
a620aa8352 | ||
|
|
09fb4ea584 | ||
|
|
3997b6038d | ||
|
|
954c6e8c15 | ||
|
|
6d327658ea | ||
|
|
7f4c70827a | ||
|
|
f69e8458f4 | ||
|
|
02f0f22621 | ||
|
|
1d43dc229b | ||
|
|
c7d1a767f7 | ||
|
|
11d8f7d522 | ||
|
|
1332ab397f | ||
|
|
27003c447d | ||
|
|
bb07ad38d3 | ||
|
|
78bafee39d | ||
|
|
66866ff260 | ||
|
|
1dcd7e747e | ||
|
|
42c0d7c7c3 | ||
|
|
49f2c24698 | ||
|
|
0fab7f0880 | ||
|
|
81020b3612 | ||
|
|
4b78bedddd | ||
|
|
488210915a | ||
|
|
f1d406b1e6 | ||
|
|
89ed2e0127 | ||
|
|
53b1f8d3a4 | ||
|
|
cc5991d269 | ||
|
|
b8184e4fdd | ||
|
|
37dee02e1c | ||
|
|
2765a065a7 | ||
|
|
bb7b2cdfd5 | ||
|
|
94287d66a8 | ||
|
|
1cfa0a3c0e | ||
|
|
1678518cd4 | ||
|
|
838bd5824e | ||
|
|
a650072fe0 | ||
|
|
6115fb08c0 | ||
|
|
91b2092d55 | ||
|
|
cba8745164 | ||
|
|
c3d00a8825 | ||
|
|
261522446e | ||
|
|
a86104d0fe | ||
|
|
4ac2afacfa | ||
|
|
07c655cf41 | ||
|
|
a9ba0981c7 | ||
|
|
7a074f21bd | ||
|
|
fef8a4c978 | ||
|
|
0c8ecf2b1f | ||
|
|
5fc8250ee4 | ||
|
|
e42403fecc | ||
|
|
12a84cc30b | ||
|
|
0ae5b75f33 | ||
|
|
4810d87044 | ||
|
|
0b71f7186c | ||
|
|
eac3c4b72c | ||
|
|
d03f58417b | ||
|
|
7123985325 | ||
|
|
ce142496b1 | ||
|
|
53690c5ece | ||
|
|
7eb5138276 | ||
|
|
d50445108e | ||
|
|
65c5a0b308 | ||
|
|
bfe1eaadcf | ||
|
|
ce3603f84f | ||
|
|
a26cdceb4b | ||
|
|
21468d72d3 | ||
|
|
4f4cb3698e | ||
|
|
21c5282eb1 | ||
|
|
db46ad6502 | ||
|
|
616006caf8 | ||
|
|
cbc0b81d2e | ||
|
|
c1c6ed6ed7 | ||
|
|
248ff3349b | ||
|
|
55dc699d45 | ||
|
|
1f75636e56 | ||
|
|
f3847a3a9a | ||
|
|
0203a48f3e | ||
|
|
9019f6492f | ||
|
|
5420bb9f6d | ||
|
|
4ac591b076 | ||
|
|
18f8686cdb | ||
|
|
2f40e23dcc | ||
|
|
8a83e050d0 | ||
|
|
9cb5591007 | ||
|
|
eac89c244d | ||
|
|
a972b8768d | ||
|
|
a4906c477e | ||
|
|
84bbe7728d | ||
|
|
7cafd1f17e | ||
|
|
5bd79546ab | ||
|
|
3d2c6a22a3 | ||
|
|
052404ffbd | ||
|
|
96b2210c0f | ||
|
|
2efc0113fe | ||
|
|
24aa6a1679 | ||
|
|
3775fdf9c7 | ||
|
|
fee8d0a2e1 | ||
|
|
702402e1fe | ||
|
|
4f2d3dbb41 | ||
|
|
02aacdd0c8 | ||
|
|
131dfaf263 | ||
|
|
004e0bb9a3 | ||
|
|
06276e1d24 | ||
|
|
ba5cbcbb3f | ||
|
|
872bbadded | ||
|
|
6a12591248 | ||
|
|
e7399b5046 | ||
|
|
df57c32076 | ||
|
|
f78044da6d | ||
|
|
157f7da8ce | ||
|
|
a356edb117 | ||
|
|
5fb6fa38aa | ||
|
|
99e890bc99 | ||
|
|
280886104c | ||
|
|
1cec06f808 | ||
|
|
85a81b18a3 | ||
|
|
756bd63e1d | ||
|
|
a182cab27f | ||
|
|
9d2b7c1fc0 | ||
|
|
b254ff9b41 | ||
|
|
0a32d38ad9 | ||
|
|
3069a422e9 | ||
|
|
1b4c1980c2 | ||
|
|
aba09b8107 | ||
|
|
586f49cafd | ||
|
|
611bb52d1f | ||
|
|
f374a062e1 | ||
|
|
fa7f74b4bc | ||
|
|
3f8d7dae39 | ||
|
|
7edbae893d | ||
|
|
7fe0ec5407 | ||
|
|
6b47c39103 | ||
|
|
bd2a6467fe | ||
|
|
4fac61387f | ||
|
|
3cee94226f | ||
|
|
a38c2121cc | ||
|
|
b4d2a3f495 | ||
|
|
6e5c14a95b | ||
|
|
ca9c5edd39 | ||
|
|
f51ef1a791 | ||
|
|
016232a8e9 | ||
|
|
dbb0a6261f | ||
|
|
b07ee26e08 | ||
|
|
a2c3df3ac5 | ||
|
|
ae803474f9 | ||
|
|
0a068db285 | ||
|
|
d393aa0f87 | ||
|
|
3f4261ad1e | ||
|
|
4c97ee45dd | ||
|
|
9d443b8bd8 | ||
|
|
32222ae6ef | ||
|
|
71025fd374 | ||
|
|
82a2144296 | ||
|
|
22ef860312 | ||
|
|
a6587b209f | ||
|
|
4dc1d7538e | ||
|
|
609c1eee55 | ||
|
|
ab6e32f6bb | ||
|
|
85af68d807 | ||
|
|
0893431fde | ||
|
|
e5abc15c53 | ||
|
|
8d65452631 | ||
|
|
eb4ac73b78 | ||
|
|
b8f322d8f6 | ||
|
|
d20e3f79be | ||
|
|
9df5e09a82 | ||
|
|
9559f50eec | ||
|
|
40a0689183 | ||
|
|
14d16d573c | ||
|
|
556533f160 | ||
|
|
62a3366fbf | ||
|
|
04c6474dd0 | ||
|
|
3553686e50 | ||
|
|
bc554faffb | ||
|
|
a5c62b5c10 | ||
|
|
f6df7df9bf | ||
|
|
33ddc540cf | ||
|
|
7078bc00bd | ||
|
|
d2aea7bdc1 | ||
|
|
d8689e5045 | ||
|
|
5327533188 | ||
|
|
5532cedcdd | ||
|
|
ed71340a90 | ||
|
|
1074415d30 | ||
|
|
90be78b283 | ||
|
|
2de63133ae | ||
|
|
31e2aa0f38 | ||
|
|
cae7be4447 | ||
|
|
a6a38735b1 | ||
|
|
93931eb32b | ||
|
|
967bf417df | ||
|
|
b67257db35 | ||
|
|
94caf82ab2 | ||
|
|
eb8503c547 | ||
|
|
89e8286cbc | ||
|
|
ebe1d3647f | ||
|
|
5ed6f70010 | ||
|
|
a99c8c4046 | ||
|
|
fd44c0e59e | ||
|
|
e6732e01e1 | ||
|
|
e907c48438 | ||
|
|
480d3876b8 | ||
|
|
ab93a61066 | ||
|
|
5f68a445cf | ||
|
|
de454924a3 | ||
|
|
4be75d862f | ||
|
|
61d5e265f5 | ||
|
|
e172bc134b | ||
|
|
0dbc0ffb6b | ||
|
|
215dea0411 | ||
|
|
4cfa3be69e | ||
|
|
d081f6afe7 | ||
|
|
adf6244eda | ||
|
|
1bdbe09b6b | ||
|
|
a10cd23990 | ||
|
|
7c393080ff | ||
|
|
d1dbefa376 | ||
|
|
c94f55807b | ||
|
|
f8e81dde9e | ||
|
|
173b640b34 | ||
|
|
0bb7cc8ab5 | ||
|
|
a8fd7a69ab | ||
|
|
b986ed5613 | ||
|
|
96ad74cd51 | ||
|
|
ea7b25766b | ||
|
|
1d43e04725 | ||
|
|
ba664c4341 | ||
|
|
aa22585d40 | ||
|
|
cf78da5fae | ||
|
|
152c4483c8 | ||
|
|
45341bb246 | ||
|
|
9ae2491b1e | ||
|
|
d685646446 | ||
|
|
de468c6fc8 | ||
|
|
6f3949bb6d | ||
|
|
25d0e5f942 | ||
|
|
4abb7e22ac | ||
|
|
f8781bc193 | ||
|
|
d7e9d8c6cc | ||
|
|
6fcc173489 | ||
|
|
bb1e38f584 | ||
|
|
c52e688979 | ||
|
|
2001c20426 | ||
|
|
5a6f6c7138 | ||
|
|
722ed48d9d | ||
|
|
38510d4b03 | ||
|
|
96a343fb29 | ||
|
|
84759073d9 | ||
|
|
0632cf0f44 | ||
|
|
bbb1a31fda | ||
|
|
6158de3729 | ||
|
|
3ba365ceb2 | ||
|
|
5b314e2f7a | ||
|
|
390501bac0 | ||
|
|
d274f13480 | ||
|
|
6148f98980 | ||
|
|
8bd0f4d0d7 | ||
|
|
bc8ca491cd | ||
|
|
6ef7b7d93d | ||
|
|
f4ec073099 | ||
|
|
766ec5ad0f | ||
|
|
383fb6d20e | ||
|
|
1d251d6151 | ||
|
|
db67b10605 | ||
|
|
4da033c7a2 | ||
|
|
a8b8ab20b7 | ||
|
|
e025dc0dba | ||
|
|
89e3729955 | ||
|
|
3b9e828fa4 | ||
|
|
cf753d5c40 | ||
|
|
314ab0a84f | ||
|
|
b799445b1a | ||
|
|
69eddbbc76 | ||
|
|
4dca84817e | ||
|
|
bf54692efb | ||
|
|
76c5cdc6e3 | ||
|
|
3e89b9685d | ||
|
|
646d1f7316 | ||
|
|
67ad9e9351 | ||
|
|
160af91f9a | ||
|
|
5e73f3431c | ||
|
|
cd28693726 | ||
|
|
ebad2b7542 | ||
|
|
293dc4da80 | ||
|
|
df13a6f333 | ||
|
|
e58cafed6f | ||
|
|
072f0b07d4 | ||
|
|
35c51c73f7 | ||
|
|
fac122891f | ||
|
|
8eed9c08a6 | ||
|
|
1911143a75 | ||
|
|
19616eba35 | ||
|
|
e0153d8203 | ||
|
|
d1bdbcd273 | ||
|
|
bf72b5bc37 | ||
|
|
ec8bf82695 | ||
|
|
f64c8cc9ce | ||
|
|
028700d86f | ||
|
|
d13d6736b9 | ||
|
|
a1ba57186b | ||
|
|
459611516c | ||
|
|
340ab0214b | ||
|
|
178d1ed423 | ||
|
|
cf013140a6 | ||
|
|
e3d222912d | ||
|
|
1134c81aad | ||
|
|
3e07d41fa9 | ||
|
|
b1230b27b7 | ||
|
|
1eecf28a8b | ||
|
|
fe28addb51 | ||
|
|
9bcdc8e14b | ||
|
|
2ce03ab1b5 | ||
|
|
5190cd507e | ||
|
|
2dd2f31431 | ||
|
|
dc105d5eae | ||
|
|
c62356438a | ||
|
|
d74543ac32 | ||
|
|
55a1f604d6 | ||
|
|
f14d6249e0 | ||
|
|
12a19e400f | ||
|
|
53b1f7ac4d | ||
|
|
6f80aff3e2 | ||
|
|
aa7aeb046e | ||
|
|
bf46f2e50d | ||
|
|
4760dd699d | ||
|
|
22c48d5cef | ||
|
|
0454b4c8d5 | ||
|
|
5c02f0983d | ||
|
|
a28ffcbcf7 | ||
|
|
c5400b6c37 | ||
|
|
a071571eac | ||
|
|
5cf2dd79f2 | ||
|
|
cf7c6d2e9c | ||
|
|
8c7b29734e | ||
|
|
192521523f | ||
|
|
2765ee5a85 | ||
|
|
585c2e31c6 | ||
|
|
fecd9f830e | ||
|
|
6efd744497 | ||
|
|
2b6a62faa1 | ||
|
|
4b62f84cc7 | ||
|
|
3fb3d30365 | ||
|
|
2c16ba18a4 | ||
|
|
f45c64d61b | ||
|
|
7bf88333dd | ||
|
|
e1a033672f | ||
|
|
4dbc4cb652 | ||
|
|
d24cd89304 | ||
|
|
0f041b424d | ||
|
|
7688f18a25 | ||
|
|
d5435a9962 | ||
|
|
9f6aedea47 | ||
|
|
45975c9677 | ||
|
|
0a42a0e814 | ||
|
|
5855f0cdfc | ||
|
|
5b71d5f3a1 | ||
|
|
613ad4c5d6 | ||
|
|
e6d6918ed8 | ||
|
|
07b7828f39 | ||
|
|
8ba08af539 | ||
|
|
3aa77360f0 | ||
|
|
c9f97149e1 | ||
|
|
529e4d0131 | ||
|
|
dc322f0423 | ||
|
|
6adeb97b19 | ||
|
|
d53d4b808b | ||
|
|
d313eb812d | ||
|
|
cac2f2b58b | ||
|
|
321e3ede30 | ||
|
|
524290d678 | ||
|
|
5f86c389b0 | ||
|
|
990a609afd | ||
|
|
271e11e065 | ||
|
|
9df2ccbceb | ||
|
|
ac006e0d52 | ||
|
|
0bf56f249a | ||
|
|
b6c6f42d40 | ||
|
|
0d04da3158 | ||
|
|
94172091ae | ||
|
|
e6c215104f | ||
|
|
7a3eb40697 | ||
|
|
7321836bfb | ||
|
|
96c54716d7 | ||
|
|
f69adc1894 | ||
|
|
21b142df40 | ||
|
|
a5155b3b20 | ||
|
|
807c067701 | ||
|
|
b8af493b56 | ||
|
|
e438422a22 | ||
|
|
91ed349e11 | ||
|
|
b8f2341998 | ||
|
|
4799e1ed44 | ||
|
|
e3b295cecc | ||
|
|
2a37034787 | ||
|
|
aae8044150 | ||
|
|
20af5049af | ||
|
|
3e777a9d87 | ||
|
|
36fa5b827d | ||
|
|
7ab2498544 | ||
|
|
df453803ce | ||
|
|
fd9c62d1c4 | ||
|
|
25ab08f422 | ||
|
|
a0dea5a51f | ||
|
|
cec8ef3599 | ||
|
|
d85b56a2bd | ||
|
|
2bccaa31c9 | ||
|
|
45a34be2ac | ||
|
|
9f8539f13e | ||
|
|
33c6ef28f8 | ||
|
|
fe730a3db0 | ||
|
|
f66958c34f | ||
|
|
b44adaa5ab | ||
|
|
3a905e3d59 | ||
|
|
cf4d25d547 | ||
|
|
3b11459a38 | ||
|
|
02079771ef | ||
|
|
7d29df3783 | ||
|
|
9b8cb05037 | ||
|
|
3593626a8e | ||
|
|
45239724c6 | ||
|
|
bb470d0aea | ||
|
|
ffb60fe8b9 | ||
|
|
40a78970e1 | ||
|
|
81ed7627bf | ||
|
|
8be94c4af4 | ||
|
|
9090715ae5 | ||
|
|
a6a479f7aa | ||
|
|
dde0695909 | ||
|
|
d824816880 | ||
|
|
776dd4a0d5 | ||
|
|
f33bc93639 | ||
|
|
67c6c380e1 | ||
|
|
022fedb5d2 | ||
|
|
50402a7805 | ||
|
|
67ddb2e7f8 | ||
|
|
432735773a | ||
|
|
781b9b6dd4 | ||
|
|
c85f498bc7 | ||
|
|
67995a2f49 | ||
|
|
1eebbebed1 | ||
|
|
a5690e707d | ||
|
|
0ff56c6e8d | ||
|
|
b547893fbf | ||
|
|
e14007ced4 | ||
|
|
42919e8864 | ||
|
|
5505845c6f | ||
|
|
95ab7c84bc | ||
|
|
f33923c784 | ||
|
|
a7a7c37121 | ||
|
|
e08003b336 | ||
|
|
29c84bf622 | ||
|
|
b7e297ebda | ||
|
|
fe2f779c47 | ||
|
|
90aae6c3a8 | ||
|
|
0baffd94a4 | ||
|
|
4fe6ae0bae | ||
|
|
477acdd635 | ||
|
|
3da12014b8 | ||
|
|
58d07eeb87 | ||
|
|
42087c9bfe | ||
|
|
5007165908 | ||
|
|
0b24fb50c0 | ||
|
|
7dc63c06e7 | ||
|
|
5819ba9a9c | ||
|
|
4e9e97ddbb | ||
|
|
30ca078cec | ||
|
|
a14d9d35c7 | ||
|
|
c968b904de | ||
|
|
ba65e12c33 | ||
|
|
c83ac5271d | ||
|
|
38101d433b | ||
|
|
30abebfe65 | ||
|
|
c400d15ed1 | ||
|
|
a6cbc1ba16 | ||
|
|
b11fe2f814 | ||
|
|
c593e909aa | ||
|
|
f4298a7323 | ||
|
|
93bd63cfbe | ||
|
|
e220ad5389 | ||
|
|
5c499d16a5 | ||
|
|
6d8252e2b6 | ||
|
|
fcb29c6da5 | ||
|
|
00f1c57279 | ||
|
|
41aa8f18fb | ||
|
|
5eb7aa07a1 | ||
|
|
1792aebaf6 | ||
|
|
eac6e05392 | ||
|
|
04010548f8 | ||
|
|
3e8088d99c | ||
|
|
1c7da95fed | ||
|
|
baae374899 | ||
|
|
a5853681e3 | ||
|
|
be75522507 | ||
|
|
dfd61bbf1d | ||
|
|
c46d78b4b9 | ||
|
|
f7e979f3ba | ||
|
|
fd8e7c2623 | ||
|
|
757a46ab12 | ||
|
|
bce6a7be61 | ||
|
|
6abbf45042 | ||
|
|
ddd62277c2 | ||
|
|
bd356f3eb4 | ||
|
|
aec481b6b3 | ||
|
|
28b1ecb109 | ||
|
|
408f120612 | ||
|
|
c0d3ac5534 | ||
|
|
960d088deb | ||
|
|
19ef682250 | ||
|
|
6171be4f46 | ||
|
|
f6df701b84 | ||
|
|
ad2a5f1717 | ||
|
|
3b6b2aa5fe | ||
|
|
998081785e | ||
|
|
e94e6292e9 | ||
|
|
d2371b5bac | ||
|
|
f40d9dbb05 | ||
|
|
f1efaffe81 | ||
|
|
36797cda30 | ||
|
|
52d881e3f9 | ||
|
|
081d3932b6 | ||
|
|
a7e561b55f | ||
|
|
cf266a67ad | ||
|
|
8bbe8a7f95 | ||
|
|
a3f84d9f21 | ||
|
|
fb110ccfd2 | ||
|
|
99de17da82 | ||
|
|
e3088647fc | ||
|
|
9d75b63a6e | ||
|
|
4a9e1cb345 | ||
|
|
a4b8db38ca | ||
|
|
ddc1b7cd49 | ||
|
|
861e065d08 | ||
|
|
14d16f2574 | ||
|
|
57757d22f9 | ||
|
|
98f808326f | ||
|
|
9a48e3b867 | ||
|
|
6cafa9120c | ||
|
|
4658b554ce | ||
|
|
4a3144ae43 | ||
|
|
fb34fe8c9a | ||
|
|
c9e1fd3fc4 | ||
|
|
423b251467 | ||
|
|
f48b493620 | ||
|
|
5e75f1d8cd | ||
|
|
b34621fadf | ||
|
|
42a135fbd9 | ||
|
|
8e5de365a5 | ||
|
|
658d16c2cd | ||
|
|
3a4ff4c76c | ||
|
|
7b292d5ca3 | ||
|
|
2509ce030d | ||
|
|
15189c28ed | ||
|
|
a0df566b2b | ||
|
|
e2e2005567 | ||
|
|
a799b7d56d | ||
|
|
0d709847ee | ||
|
|
58bcb9dfc8 | ||
|
|
04be438b35 | ||
|
|
fa3b96eb4a | ||
|
|
5723039637 | ||
|
|
6dd48fb820 | ||
|
|
12ffbf5047 | ||
|
|
c670ccfd37 | ||
|
|
8896b39231 | ||
|
|
ce963aae58 | ||
|
|
dc01807b3c | ||
|
|
fadac5fe4a | ||
|
|
99260735ae | ||
|
|
3e1a70bbb2 | ||
|
|
fd3568d48f | ||
|
|
501be8a3bc | ||
|
|
38fe7ec7cd | ||
|
|
01e10014bb | ||
|
|
0e58ab7e01 | ||
|
|
bcabb90f5a | ||
|
|
86b11a9365 | ||
|
|
71bb348698 | ||
|
|
5a82d99482 | ||
|
|
50462fdb00 | ||
|
|
354dcaac58 | ||
|
|
5db04b15e7 | ||
|
|
dd9ab5264d | ||
|
|
5a50b88f52 | ||
|
|
dce554af53 | ||
|
|
130867a6c2 | ||
|
|
b5cd9dab26 | ||
|
|
ec7bfba8df | ||
|
|
f1e176d35c | ||
|
|
92241baade | ||
|
|
f61012097c | ||
|
|
fe26ff763e | ||
|
|
6aa812aa0c | ||
|
|
344843d802 | ||
|
|
3277e491f1 | ||
|
|
80e7f37f50 | ||
|
|
61c4624f5f | ||
|
|
fc2e8b321f | ||
|
|
e5b27baa59 | ||
|
|
a62a5f814a | ||
|
|
53447e7ef5 | ||
|
|
f7a44d1cec | ||
|
|
c34a61dd55 | ||
|
|
e834a4e4f5 | ||
|
|
0328caffe4 | ||
|
|
260bb2f558 | ||
|
|
70f2aed0a7 | ||
|
|
46dc9985fc | ||
|
|
3087ca0823 | ||
|
|
372dc5b49a | ||
|
|
030aedc7d4 | ||
|
|
25e021d4b4 | ||
|
|
d48d2d08df | ||
|
|
524899ccbf | ||
|
|
d4008374f6 | ||
|
|
48432abff1 | ||
|
|
167483f777 | ||
|
|
4eca4abb21 | ||
|
|
e99286f871 | ||
|
|
d8d46890b3 | ||
|
|
98cf986934 | ||
|
|
829da096e2 | ||
|
|
a26cb4bc6b | ||
|
|
1fe86656e1 | ||
|
|
39c6e5263a | ||
|
|
46a1a2de10 | ||
|
|
05f5a1b0ee | ||
|
|
153e11f045 | ||
|
|
4781a23809 | ||
|
|
ed47ee4e29 | ||
|
|
27769f0301 | ||
|
|
feb5da0c35 | ||
|
|
3a902289f1 | ||
|
|
3ac3ead2cf | ||
|
|
0d0737d1f6 | ||
|
|
27fcf62011 | ||
|
|
bb91fdbaf9 | ||
|
|
11cbb9188b | ||
|
|
c11102cf4a | ||
|
|
02fcbbb6d2 | ||
|
|
0d6051e6f9 | ||
|
|
6a433282dc | ||
|
|
8fb404b0f8 | ||
|
|
64530c6196 | ||
|
|
86db6c9084 | ||
|
|
0abc30401c | ||
|
|
1b6b0ad9d2 | ||
|
|
7cdbd550c8 | ||
|
|
94883202b8 | ||
|
|
b0f3fd7ffb | ||
|
|
feca87345f | ||
|
|
f848a5c87d | ||
|
|
0cb57bee0e | ||
|
|
f8cc08e2a1 | ||
|
|
ad2328bbd8 | ||
|
|
d5ca77da97 | ||
|
|
69f68c428e | ||
|
|
abcdbcfd39 | ||
|
|
e67c652988 | ||
|
|
ddc711ec93 | ||
|
|
b9bf5c1118 | ||
|
|
9840e0b5b8 | ||
|
|
ffae0b2cd5 | ||
|
|
fe2b0c2862 | ||
|
|
1647e7a0c1 | ||
|
|
b35fa4c9f6 | ||
|
|
a48840509b | ||
|
|
ca8cab0ce9 | ||
|
|
bbcf6943ce | ||
|
|
fbf9bfe897 | ||
|
|
e46fcf0e02 | ||
|
|
732281bca0 | ||
|
|
f7dd5e6396 | ||
|
|
dd2ccea6e5 | ||
|
|
3d13eb2dc2 | ||
|
|
26b8661325 | ||
|
|
fa97a82568 | ||
|
|
1ae73d7da2 | ||
|
|
d8e692c9a3 | ||
|
|
ca05d1f79e | ||
|
|
9dd38aebe0 | ||
|
|
ceded8a20a | ||
|
|
9c21077dc1 | ||
|
|
fca6a09a41 | ||
|
|
bba711c89a | ||
|
|
5fbaa6d4cf | ||
|
|
5b1f84f816 | ||
|
|
65127533ef | ||
|
|
05ca00b623 | ||
|
|
4b6d855e63 | ||
|
|
7d7752efbf | ||
|
|
ce6f6ab9fe | ||
|
|
3a0569cfd3 | ||
|
|
7d21015b52 | ||
|
|
a57707071c | ||
|
|
2a347e4027 | ||
|
|
4c8ae3a7af | ||
|
|
2773ce7ebf | ||
|
|
f4e4104d14 | ||
|
|
b722a89276 | ||
|
|
4bf6711dbb | ||
|
|
5be733a174 | ||
|
|
c3cae5dfc4 | ||
|
|
0c9d862a49 | ||
|
|
975a785e68 | ||
|
|
6be607e528 | ||
|
|
80c4dea875 | ||
|
|
9e7a4c3717 | ||
|
|
c72e9c3cef | ||
|
|
8175eaa48a | ||
|
|
890083ce7f | ||
|
|
454cd16df4 | ||
|
|
7e233b536c | ||
|
|
ae19ab3dd3 | ||
|
|
bf4b2dc05e | ||
|
|
571ea6a2bc | ||
|
|
b3ea0f4ec5 | ||
|
|
d4c8ad5ba7 | ||
|
|
2432c9f290 | ||
|
|
7f7d53adb7 | ||
|
|
60ed4b9d1e | ||
|
|
83a999d16e | ||
|
|
a29f3de025 | ||
|
|
6ab0ec6aac | ||
|
|
984204e380 | ||
|
|
297166fcb9 | ||
|
|
e6e57e47cf | ||
|
|
bcde377019 | ||
|
|
2d39759d34 | ||
|
|
e4500af736 | ||
|
|
41933c31ca | ||
|
|
47675943ee | ||
|
|
74a708b794 | ||
|
|
833c7f21af | ||
|
|
f8eedc69dd | ||
|
|
797324c35e | ||
|
|
ae967a4f40 | ||
|
|
188fc69e56 | ||
|
|
be8506b45e | ||
|
|
79fcd0b06c | ||
|
|
421ccb23d3 | ||
|
|
f1969175cd | ||
|
|
7fd6d089c0 | ||
|
|
552fba773d | ||
|
|
8e272cfd53 | ||
|
|
36fbe54634 | ||
|
|
90017998fc | ||
|
|
ebe95ba1e1 | ||
|
|
c803762704 | ||
|
|
f8d8f3347a | ||
|
|
d4fcc38a57 | ||
|
|
c60ef181dc | ||
|
|
db4ad2f6f9 | ||
|
|
b5d2cfecc7 | ||
|
|
75955fcc04 | ||
|
|
050e73d960 | ||
|
|
0f2d3adbbc | ||
|
|
ea6a1c629d | ||
|
|
eb53a796e2 | ||
|
|
2d273a8509 | ||
|
|
7169ad557f | ||
|
|
b4d6250d55 | ||
|
|
45f2d01895 | ||
|
|
c176ace889 | ||
|
|
1ce4613aad | ||
|
|
eb473842b8 | ||
|
|
407eaa0870 | ||
|
|
9b09b5aa29 | ||
|
|
70d1511f73 | ||
|
|
4a717f3df8 | ||
|
|
cb7c36a512 | ||
|
|
f37c495b90 | ||
|
|
284c6c4223 | ||
|
|
fd5497cfc7 | ||
|
|
208d3770da | ||
|
|
01b49dc502 | ||
|
|
fbb19e451d | ||
|
|
a1ffa4497d | ||
|
|
e69f9dd029 | ||
|
|
fed3024302 | ||
|
|
dc2f048c98 | ||
|
|
f4ccd4609b | ||
|
|
1e3a29c049 | ||
|
|
82e9ed2ac2 | ||
|
|
ae52880f81 | ||
|
|
90236fb537 | ||
|
|
55d0d27756 | ||
|
|
d849694a70 | ||
|
|
29987c3ff6 | ||
|
|
5f696a0cce | ||
|
|
90d3c09536 | ||
|
|
3f65fc014e | ||
|
|
5344b711ea | ||
|
|
a3e827c144 | ||
|
|
52e267e864 | ||
|
|
165781a545 | ||
|
|
e10a3d1f9d | ||
|
|
0c11d4443f | ||
|
|
50be2fabbf | ||
|
|
7a2e9ef535 | ||
|
|
079f2e3609 | ||
|
|
0e0d613191 | ||
|
|
de68209f3b | ||
|
|
59546b623e | ||
|
|
0a5463fee8 | ||
|
|
cdfb18e9b4 | ||
|
|
1f635d3793 | ||
|
|
714d77dbd8 | ||
|
|
9803130848 | ||
|
|
ad44d8d42a | ||
|
|
68f81b2abb | ||
|
|
4945331093 | ||
|
|
8411844d7e | ||
|
|
00415d66a2 | ||
|
|
f7398e615a | ||
|
|
e81a9cbb17 | ||
|
|
c8c8c626b0 | ||
|
|
9f5f0ddaaa | ||
|
|
80e1e64eae | ||
|
|
37613fc056 | ||
|
|
57c6aefe38 | ||
|
|
133c467cf4 | ||
|
|
900cab4b42 | ||
|
|
f9cc556971 | ||
|
|
f2ce367cec | ||
|
|
fba9cbcff6 | ||
|
|
3e0458da7d | ||
|
|
0d605d2396 | ||
|
|
145583f0b7 | ||
|
|
847dde0d65 | ||
|
|
ab112581a7 | ||
|
|
f48f5d0f31 | ||
|
|
0abf0b0e39 | ||
|
|
965616b214 | ||
|
|
a36fd00f6a | ||
|
|
7f44ba6df4 | ||
|
|
7b0beb0afa | ||
|
|
ae0a1436e2 | ||
|
|
8537e9f40f | ||
|
|
d61d88559c | ||
|
|
9b4c0f01f2 | ||
|
|
6c8253a4f5 | ||
|
|
6464373636 | ||
|
|
dcd0a0ec61 | ||
|
|
ff6b0fc1c9 | ||
|
|
a514b92dcf | ||
|
|
de33d69eed | ||
|
|
9959d53f5e | ||
|
|
6768658300 | ||
|
|
433bf409f4 | ||
|
|
353b0d2d34 | ||
|
|
e644d57dbe | ||
|
|
50e7cef5f3 | ||
|
|
1058820e1b | ||
|
|
24bc3a8390 | ||
|
|
5309ea3820 | ||
|
|
a063680d32 | ||
|
|
10cf2ce853 | ||
|
|
871357a2e3 | ||
|
|
efe0d77dbb | ||
|
|
8d93363655 | ||
|
|
b6dd9dd227 | ||
|
|
95c6ada2ad | ||
|
|
11585f9581 | ||
|
|
8085a7b237 | ||
|
|
c99e2c12ba | ||
|
|
44a4ff0cb2 | ||
|
|
f300af0fe2 | ||
|
|
ff186c7f65 | ||
|
|
41e22657e4 | ||
|
|
974815cb14 | ||
|
|
33beab9c47 | ||
|
|
d258118b0a | ||
|
|
4dab39ed9e | ||
|
|
33293d5cdd | ||
|
|
285308dcbe | ||
|
|
c8fb6c4661 | ||
|
|
1a556198b2 | ||
|
|
98650acca0 | ||
|
|
123f2781a1 | ||
|
|
92f6db5bd7 | ||
|
|
e5f8c1e75d | ||
|
|
4c0a316e3e | ||
|
|
d613d63fdc | ||
|
|
e3941cde7e | ||
|
|
642422d5c4 | ||
|
|
117ec4e64d | ||
|
|
0219584bfe | ||
|
|
d3947fc893 | ||
|
|
fe0c26f536 | ||
|
|
e83e4909a0 | ||
|
|
ed05a1db9d | ||
|
|
21a11f5589 | ||
|
|
6288adfefd | ||
|
|
6a1caafb7a | ||
|
|
ce51749177 | ||
|
|
a68ca31684 | ||
|
|
5f1b9943d1 | ||
|
|
155ed4e501 | ||
|
|
80ef2cfed4 | ||
|
|
5efc417690 | ||
|
|
14868615d5 | ||
|
|
512fcdbcb1 | ||
|
|
6f2caf9698 | ||
|
|
a395a14eeb | ||
|
|
95fe0f4dec | ||
|
|
f6d85e021f | ||
|
|
597f08e2a2 | ||
|
|
df4784e7b9 | ||
|
|
ddd3d2d0a9 | ||
|
|
cb4ecfd3a3 | ||
|
|
f4b59492ab | ||
|
|
ae37f49b51 | ||
|
|
6e68315d2c | ||
|
|
c1c9dd03ce | ||
|
|
e00f02b603 | ||
|
|
9f907d5b5e | ||
|
|
6729574201 | ||
|
|
2a2af4878e | ||
|
|
bfb3e09d1d | ||
|
|
89ee0654f4 | ||
|
|
2ac8b685d6 | ||
|
|
4b38100ae2 | ||
|
|
d6c14d5258 | ||
|
|
cb09cabbdd | ||
|
|
77023c0ecf | ||
|
|
0b18c93d19 | ||
|
|
0617753a7f | ||
|
|
b77fad6e5f | ||
|
|
90bf6f2d4a | ||
|
|
ef7646417b | ||
|
|
01874e379f | ||
|
|
7afd8da28f | ||
|
|
3d532c6015 | ||
|
|
a692ef6715 | ||
|
|
ccb8c3c352 | ||
|
|
18f01113c2 | ||
|
|
f7def09dec | ||
|
|
82bf0be3e2 | ||
|
|
212f4fdd95 | ||
|
|
a5058ff999 | ||
|
|
ea1c16f2ac | ||
|
|
67337fadaa | ||
|
|
94c1d66e59 | ||
|
|
510e6edfbf | ||
|
|
e8c31142ae | ||
|
|
71c780a530 | ||
|
|
7e579de163 | ||
|
|
dd1a52c534 | ||
|
|
e815a43164 | ||
|
|
2f17706e76 | ||
|
|
86a94798dd | ||
|
|
a7cca4985e | ||
|
|
965c075362 | ||
|
|
05d7746f62 | ||
|
|
688326b58c | ||
|
|
0c9993cc89 | ||
|
|
0c35e6ad19 | ||
|
|
68521ea46c | ||
|
|
2fe11cd77a | ||
|
|
e27a6a7a91 | ||
|
|
5bf583cba4 | ||
|
|
a23fce519d | ||
|
|
7f3f127165 | ||
|
|
9ff1f05e66 | ||
|
|
b9c4eafd96 | ||
|
|
7fa5846c6b | ||
|
|
3b37f77a4d | ||
|
|
858d2329e5 | ||
|
|
371ee1e457 | ||
|
|
cfbfe90aa0 | ||
|
|
fd30f5dc59 | ||
|
|
0502bd3c2d | ||
|
|
3ce7ef5e8b | ||
|
|
2324aa0782 | ||
|
|
6a57a8da12 | ||
|
|
9276f3202c | ||
|
|
a6598997e2 | ||
|
|
82913cd3f4 | ||
|
|
be6939ee8a | ||
|
|
7ba4a5d24b | ||
|
|
371e6d99c9 | ||
|
|
84b105c82b | ||
|
|
c6def418cf | ||
|
|
5fce2c5712 | ||
|
|
aacd7d8987 | ||
|
|
4a707d7452 | ||
|
|
21551b3c40 | ||
|
|
7727f2cc8f | ||
|
|
9a87dcf0a1 | ||
|
|
9136e64d89 | ||
|
|
765a762ccf | ||
|
|
02ca2ed585 | ||
|
|
f3ba3ddd54 | ||
|
|
65ce948b0b | ||
|
|
383a9f6eeb | ||
|
|
43dda9c9cf | ||
|
|
7a44a1d1c1 | ||
|
|
5d934cd5b6 | ||
|
|
788cda4925 | ||
|
|
55a69e4a45 | ||
|
|
1931d31147 | ||
|
|
e9dbdc9247 | ||
|
|
86b6c6f334 | ||
|
|
cd5afd6ff4 | ||
|
|
d88cc084e6 | ||
|
|
5deaebf0c2 | ||
|
|
19734ad863 | ||
|
|
b16ccb9919 | ||
|
|
d41837817c | ||
|
|
3ab14dfe39 | ||
|
|
4a91ecd91a | ||
|
|
a3da2911e8 | ||
|
|
6f5b418f0b | ||
|
|
57691c82b1 | ||
|
|
37a74b38ba | ||
|
|
9ab81a987d | ||
|
|
4b08e3d571 | ||
|
|
187fea0c28 | ||
|
|
4e54b27398 | ||
|
|
aced5cc3ba | ||
|
|
669ec30413 | ||
|
|
0082b7abdd | ||
|
|
7903f3a546 | ||
|
|
ec75586bdd | ||
|
|
df9902d6a4 | ||
|
|
315919cdd6 | ||
|
|
63c95a3546 | ||
|
|
59d04d1d0c | ||
|
|
d1cc9e868b | ||
|
|
14de46576b | ||
|
|
bdff29a472 | ||
|
|
8655c6c264 | ||
|
|
3f4e4a23a0 | ||
|
|
b682262486 | ||
|
|
5be7be6189 | ||
|
|
3475a07522 | ||
|
|
fb7ea169d4 | ||
|
|
5469293e5f | ||
|
|
9b644b0305 | ||
|
|
0df1404d6a | ||
|
|
bb4a9ed20f | ||
|
|
77887d6fbc | ||
|
|
d89db50465 | ||
|
|
632d00e01d | ||
|
|
2a56031cdc | ||
|
|
16d412323c | ||
|
|
27a6b29c80 | ||
|
|
5d1f874041 | ||
|
|
174122a09b | ||
|
|
1b6a60ecb2 | ||
|
|
1ccb266032 | ||
|
|
a963f1820c | ||
|
|
b9983149ef | ||
|
|
c1ef3f526c | ||
|
|
6b7afb80b2 | ||
|
|
0b8afa12e9 | ||
|
|
1db0a7d4ce | ||
|
|
c12a9ebd92 | ||
|
|
d86dcc4752 | ||
|
|
0bc96241d5 | ||
|
|
a0bb7a61e6 | ||
|
|
b115963a70 | ||
|
|
2e953a937d | ||
|
|
4e05691cab | ||
|
|
b5f58724a0 | ||
|
|
b83309b55d | ||
|
|
e8101a6da5 | ||
|
|
dd9cb008fb | ||
|
|
81f7172c4a | ||
|
|
bab59fbacd | ||
|
|
0f0b10b6cc | ||
|
|
8e68c5358e | ||
|
|
660f01b514 | ||
|
|
13537e3ce4 | ||
|
|
2963a90008 | ||
|
|
15b20b83fa | ||
|
|
1c3c316e45 | ||
|
|
517879382b | ||
|
|
bcd3340a80 | ||
|
|
12ae1e111e | ||
|
|
d3b3370f23 | ||
|
|
8f817a3634 | ||
|
|
cf79b15651 | ||
|
|
a4284351e3 | ||
|
|
906caf329b | ||
|
|
3db13fae13 | ||
|
|
274972f7af | ||
|
|
83fd27e031 | ||
|
|
3126dcfcea | ||
|
|
72aec6c320 | ||
|
|
b709ccbf53 | ||
|
|
7e99b13742 | ||
|
|
8b464033ff | ||
|
|
93c525a8fa | ||
|
|
54b15c1556 | ||
|
|
029f32af63 | ||
|
|
de13df6ede | ||
|
|
0de211674d | ||
|
|
f7a27c156c | ||
|
|
98f11fc7bb | ||
|
|
013e13e546 | ||
|
|
6ff26c561a | ||
|
|
c81358c291 | ||
|
|
ed34d9f22f | ||
|
|
ee05561ef3 | ||
|
|
69ae99406a | ||
|
|
0cfbb56b6c | ||
|
|
8960373f1c | ||
|
|
349a91bd92 | ||
|
|
991b43b7e5 | ||
|
|
a0fa6abcdc | ||
|
|
86501b43c0 | ||
|
|
80592970e9 | ||
|
|
567ed4ecda | ||
|
|
fafbb0abfe | ||
|
|
0f1a36b8e9 | ||
|
|
31c03cdce1 | ||
|
|
e01c85bb3a | ||
|
|
a1b91ad1ea | ||
|
|
6ce6018bb7 | ||
|
|
18eec0f4d4 | ||
|
|
32327c45c2 | ||
|
|
ba485fe2b2 | ||
|
|
f8084b117e | ||
|
|
abdddd5193 | ||
|
|
8eeb02e592 | ||
|
|
8555271102 | ||
|
|
d921bae75e | ||
|
|
a1388ef296 | ||
|
|
ddc7c94a1d | ||
|
|
e36444df27 | ||
|
|
0395c92260 | ||
|
|
f03395b90d | ||
|
|
20d5628786 | ||
|
|
57e089efd3 | ||
|
|
fbbde9de25 | ||
|
|
3d42b9fd75 | ||
|
|
adfae9e75c | ||
|
|
117dfbb563 | ||
|
|
e66dc8b027 | ||
|
|
ae0b49f532 | ||
|
|
a37ea13fd1 | ||
|
|
cc29126d61 | ||
|
|
810f2f9243 | ||
|
|
60e651cb4c | ||
|
|
472ce8566d | ||
|
|
27ac15f298 | ||
|
|
d12dba16db | ||
|
|
0f1d114c03 | ||
|
|
3e7700e9ac | ||
|
|
60615c232c | ||
|
|
3884cfb809 | ||
|
|
caa6e22e53 | ||
|
|
19f6ff330c | ||
|
|
8fdd127f72 | ||
|
|
0a5eba64e2 | ||
|
|
b82c4444b2 | ||
|
|
95a17b8f98 | ||
|
|
325f72fd91 | ||
|
|
a237225683 | ||
|
|
29b173f4e7 | ||
|
|
50a979161c | ||
|
|
264d71e29e | ||
|
|
a873688a44 | ||
|
|
7cc8533b8e | ||
|
|
04342acff1 | ||
|
|
c37df0e70d | ||
|
|
460dfa1031 | ||
|
|
08a1d3ca1d | ||
|
|
1daeed4a52 | ||
|
|
99724e2458 | ||
|
|
cd18629433 | ||
|
|
41510fdb32 | ||
|
|
9cb249610a | ||
|
|
543857ddb2 | ||
|
|
1e5b0e8726 | ||
|
|
0d0d822904 | ||
|
|
9ff4a7b205 | ||
|
|
0e96197a94 | ||
|
|
9b9d0250f7 | ||
|
|
4a35676794 | ||
|
|
465c91b9a9 | ||
|
|
60249af04c | ||
|
|
c3653dc417 | ||
|
|
3d61095ba4 | ||
|
|
7a0be94cde | ||
|
|
fad6427078 | ||
|
|
4dfde7f9a2 | ||
|
|
e2eceaa904 | ||
|
|
f34af0ad67 | ||
|
|
e07904d436 | ||
|
|
26468bef83 | ||
|
|
ea1b1e11ea | ||
|
|
e68e6c0a1a | ||
|
|
7190226c84 | ||
|
|
6f2915e25e | ||
|
|
6f7ac0720b | ||
|
|
b76554a487 | ||
|
|
8da55c3742 | ||
|
|
05111edd04 | ||
|
|
361bdd20d3 | ||
|
|
8bdace68f6 | ||
|
|
0e1eb20781 | ||
|
|
4c2dea83c5 | ||
|
|
d066817d0b | ||
|
|
a632121368 | ||
|
|
473d09b5cd | ||
|
|
893738d6f0 | ||
|
|
22cfef7d36 | ||
|
|
e1bbe1d9a9 | ||
|
|
ec981b415a | ||
|
|
57a17697a0 | ||
|
|
f4fe09ffbf | ||
|
|
871b5e17ee | ||
|
|
9b00fc3474 | ||
|
|
3b1dc36d8a | ||
|
|
4edf8f2079 | ||
|
|
54987fd9ca | ||
|
|
da9c3e7d7d | ||
|
|
a948142ef5 | ||
|
|
4f6c3f94e0 | ||
|
|
25d6d6bbe5 | ||
|
|
649781d823 | ||
|
|
08ca7a8166 | ||
|
|
dd78c62c3d | ||
|
|
29de1645fe | ||
|
|
4139b0b0c7 | ||
|
|
0c33e917d5 | ||
|
|
e401a016f5 | ||
|
|
e0fde8665c | ||
|
|
752520c823 | ||
|
|
6ba2492360 | ||
|
|
d5d798f6fa | ||
|
|
9c9cf76a0d | ||
|
|
041e201713 | ||
|
|
e09505b22d | ||
|
|
6b15cb9b10 | ||
|
|
ff4fcdc760 | ||
|
|
f43ba44b15 | ||
|
|
79c3e0583d | ||
|
|
f6ef8383bb | ||
|
|
6f5307fda7 | ||
|
|
37004e331a | ||
|
|
57acf85b42 | ||
|
|
96790d50e5 | ||
|
|
d32ff3410c | ||
|
|
35838f5e64 | ||
|
|
913488910c | ||
|
|
17b984a7cd | ||
|
|
f79b44eefe | ||
|
|
146c254c0f | ||
|
|
ce2966dd7f | ||
|
|
0fbca8b8ef | ||
|
|
3f7a583de6 | ||
|
|
1196983d5f | ||
|
|
bbb2c7cf62 | ||
|
|
ff100bdac4 | ||
|
|
4feb038d0a | ||
|
|
1792e0fb9b | ||
|
|
d4f8b3ebbc | ||
|
|
aeef9bac33 | ||
|
|
eff361a104 | ||
|
|
389f9b45bb | ||
|
|
c9741cb291 | ||
|
|
bf6f563df2 | ||
|
|
58f34d4f4b | ||
|
|
2c4d0144ba | ||
|
|
afd1a0bf9b | ||
|
|
37f6c213f6 | ||
|
|
76736902c6 | ||
|
|
d266171ed8 | ||
|
|
e7df373544 | ||
|
|
aa4b64d0bb | ||
|
|
4559ddd74f | ||
|
|
eecc45f8ba | ||
|
|
d76476040a | ||
|
|
0c8c149b86 | ||
|
|
60a7f8614c | ||
|
|
c31b67bf7a |
@@ -1,2 +1,6 @@
|
|||||||
[run]
|
[run]
|
||||||
omit = freqtrade/tests/*
|
omit =
|
||||||
|
scripts/*
|
||||||
|
freqtrade/tests/*
|
||||||
|
freqtrade/vendor/*
|
||||||
|
freqtrade/__main__.py
|
||||||
|
|||||||
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
config.json*
|
||||||
|
*.sqlite
|
||||||
|
.coveragerc
|
||||||
|
.eggs
|
||||||
|
.github
|
||||||
|
.pylintrc
|
||||||
|
.travis.yml
|
||||||
|
CONTRIBUTING.md
|
||||||
|
MANIFEST.in
|
||||||
|
README.md
|
||||||
|
freqtrade.service
|
||||||
32
.github/ISSUE_TEMPLATE.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
## Step 1: Have you search for this issue before posting it?
|
||||||
|
|
||||||
|
If you have discovered a bug in the bot, please [search our issue tracker](https://github.com/freqtrade/freqtrade/issues?q=is%3Aissue).
|
||||||
|
If it hasn't been reported, please create a new issue.
|
||||||
|
|
||||||
|
## Step 2: Describe your environment
|
||||||
|
|
||||||
|
* Python Version: _____ (`python -V`)
|
||||||
|
* CCXT version: _____ (`pip freeze | grep ccxt`)
|
||||||
|
* Branch: Master | Develop
|
||||||
|
* Last Commit ID: _____ (`git log --format="%H" -n 1`)
|
||||||
|
|
||||||
|
## Step 3: Describe the problem:
|
||||||
|
|
||||||
|
*Explain the problem you have encountered*
|
||||||
|
|
||||||
|
### Steps to reproduce:
|
||||||
|
|
||||||
|
1. _____
|
||||||
|
2. _____
|
||||||
|
3. _____
|
||||||
|
|
||||||
|
### Observed Results:
|
||||||
|
|
||||||
|
* What happened?
|
||||||
|
* What did you expect to happen?
|
||||||
|
|
||||||
|
### Relevant code exceptions or logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
// paste your log here
|
||||||
|
```
|
||||||
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
15
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
Thank you for sending your pull request. But first, have you included
|
||||||
|
unit tests, and is your code PEP8 conformant? [More details](https://github.com/freqtrade/freqtrade/blob/develop/CONTRIBUTING.md)
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Explain in one sentence the goal of this PR
|
||||||
|
|
||||||
|
Solve the issue: #___
|
||||||
|
|
||||||
|
## Quick changelog
|
||||||
|
|
||||||
|
- <change log #1>
|
||||||
|
- <change log #2>
|
||||||
|
|
||||||
|
## What's new?
|
||||||
|
*Explain in details what this PR solve or improve. You can include visuals.*
|
||||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -1,3 +1,15 @@
|
|||||||
|
# Freqtrade rules
|
||||||
|
freqtrade/tests/testdata/*.json
|
||||||
|
hyperopt_conf.py
|
||||||
|
config*.json
|
||||||
|
*.sqlite
|
||||||
|
.hyperopt
|
||||||
|
logfile.txt
|
||||||
|
hyperopt_trials.pickle
|
||||||
|
user_data/
|
||||||
|
freqtrade-plot.html
|
||||||
|
freqtrade-profit-plot.html
|
||||||
|
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
@@ -73,11 +85,10 @@ target/
|
|||||||
# pyenv
|
# pyenv
|
||||||
.python-version
|
.python-version
|
||||||
|
|
||||||
config.json
|
|
||||||
preprocessor.py
|
|
||||||
*.sqlite
|
|
||||||
|
|
||||||
.env
|
.env
|
||||||
.venv
|
.venv
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
|
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
|||||||
@@ -1,2 +1,10 @@
|
|||||||
|
[MASTER]
|
||||||
|
extension-pkg-whitelist=numpy,talib,talib.abstract
|
||||||
|
|
||||||
[BASIC]
|
[BASIC]
|
||||||
good-names=logger
|
good-names=logger
|
||||||
|
ignore=vendor
|
||||||
|
|
||||||
|
[TYPECHECK]
|
||||||
|
ignored-modules=numpy,talib,talib.abstract
|
||||||
|
|
||||||
|
|||||||
4
.pyup.yml
Normal file
4
.pyup.yml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# autogenerated pyup.io config file
|
||||||
|
# see https://pyup.io/docs/configuration/ for all available options
|
||||||
|
|
||||||
|
schedule: every day
|
||||||
30
.travis.yml
30
.travis.yml
@@ -1,4 +1,4 @@
|
|||||||
sudo: false
|
sudo: true
|
||||||
os:
|
os:
|
||||||
- linux
|
- linux
|
||||||
language: python
|
language: python
|
||||||
@@ -11,16 +11,28 @@ addons:
|
|||||||
- libdw-dev
|
- libdw-dev
|
||||||
- binutils-dev
|
- binutils-dev
|
||||||
install:
|
install:
|
||||||
- wget http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz
|
- ./install_ta-lib.sh
|
||||||
- tar zxvf ta-lib-0.4.0-src.tar.gz
|
|
||||||
- cd ta-lib && ./configure && sudo make && sudo make install && cd ..
|
|
||||||
- export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
|
- export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
|
||||||
- pip install coveralls
|
- pip install --upgrade flake8 coveralls pytest-random-order mypy
|
||||||
- pip install -r requirements.txt
|
- pip install -r requirements.txt
|
||||||
script:
|
- pip install -e .
|
||||||
- pytest --cov=freqtrade --cov-config=.coveragerc freqtrade/tests/
|
jobs:
|
||||||
after_success:
|
include:
|
||||||
- coveralls
|
- script:
|
||||||
|
- pytest --cov=freqtrade --cov-config=.coveragerc freqtrade/tests/
|
||||||
|
- coveralls
|
||||||
|
- script:
|
||||||
|
- cp config.json.example config.json
|
||||||
|
- python freqtrade/main.py --datadir freqtrade/tests/testdata backtesting
|
||||||
|
- script:
|
||||||
|
- cp config.json.example config.json
|
||||||
|
- python freqtrade/main.py --datadir freqtrade/tests/testdata hyperopt -e 5
|
||||||
|
- script: flake8 freqtrade
|
||||||
|
- script: mypy freqtrade
|
||||||
notifications:
|
notifications:
|
||||||
slack:
|
slack:
|
||||||
secure: bKLXmOrx8e2aPZl7W8DA5BdPAXWGpI5UzST33oc1G/thegXcDVmHBTJrBs4sZak6bgAclQQrdZIsRd2eFYzHLalJEaw6pk7hoAw8SvLnZO0ZurWboz7qg2+aZZXfK4eKl/VUe4sM9M4e/qxjkK+yWG7Marg69c4v1ypF7ezUi1fPYILYw8u0paaiX0N5UX8XNlXy+PBlga2MxDjUY70MuajSZhPsY2pDUvYnMY1D/7XN3cFW0g+3O8zXjF0IF4q1Z/1ASQe+eYjKwPQacE+O8KDD+ZJYoTOFBAPllrtpO1jnOPFjNGf3JIbVMZw4bFjIL0mSQaiSUaUErbU3sFZ5Or79rF93XZ81V7uEZ55vD8KMfR2CB1cQJcZcj0v50BxLo0InkFqa0Y8Nra3sbpV4fV5Oe8pDmomPJrNFJnX6ULQhQ1gTCe0M5beKgVms5SITEpt4/Y0CmLUr6iHDT0CUiyMIRWAXdIgbGh1jfaWOMksybeRevlgDsIsNBjXmYI1Sw2ZZR2Eo2u4R6zyfyjOMLwYJ3vgq9IrACv2w5nmf0+oguMWHf6iWi2hiOqhlAN1W74+3HsYQcqnuM3LGOmuCnPprV1oGBqkPXjIFGpy21gNx4vHfO1noLUyJnMnlu2L7SSuN1CdLsnjJ1hVjpJjPfqB4nn8g12x87TqM1bOm+3Q=
|
secure: bKLXmOrx8e2aPZl7W8DA5BdPAXWGpI5UzST33oc1G/thegXcDVmHBTJrBs4sZak6bgAclQQrdZIsRd2eFYzHLalJEaw6pk7hoAw8SvLnZO0ZurWboz7qg2+aZZXfK4eKl/VUe4sM9M4e/qxjkK+yWG7Marg69c4v1ypF7ezUi1fPYILYw8u0paaiX0N5UX8XNlXy+PBlga2MxDjUY70MuajSZhPsY2pDUvYnMY1D/7XN3cFW0g+3O8zXjF0IF4q1Z/1ASQe+eYjKwPQacE+O8KDD+ZJYoTOFBAPllrtpO1jnOPFjNGf3JIbVMZw4bFjIL0mSQaiSUaUErbU3sFZ5Or79rF93XZ81V7uEZ55vD8KMfR2CB1cQJcZcj0v50BxLo0InkFqa0Y8Nra3sbpV4fV5Oe8pDmomPJrNFJnX6ULQhQ1gTCe0M5beKgVms5SITEpt4/Y0CmLUr6iHDT0CUiyMIRWAXdIgbGh1jfaWOMksybeRevlgDsIsNBjXmYI1Sw2ZZR2Eo2u4R6zyfyjOMLwYJ3vgq9IrACv2w5nmf0+oguMWHf6iWi2hiOqhlAN1W74+3HsYQcqnuM3LGOmuCnPprV1oGBqkPXjIFGpy21gNx4vHfO1noLUyJnMnlu2L7SSuN1CdLsnjJ1hVjpJjPfqB4nn8g12x87TqM1bOm+3Q=
|
||||||
|
cache:
|
||||||
|
directories:
|
||||||
|
- $HOME/.cache/pip
|
||||||
|
- ta-lib
|
||||||
|
|||||||
62
CONTRIBUTING.md
Normal file
62
CONTRIBUTING.md
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
# Contribute to freqtrade
|
||||||
|
|
||||||
|
Feel like our bot is missing a feature? We welcome your pull requests! Few pointers for contributions:
|
||||||
|
|
||||||
|
- Create your PR against the `develop` branch, not `master`.
|
||||||
|
- New features need to contain unit tests and must be PEP8
|
||||||
|
conformant (max-line-length = 100).
|
||||||
|
|
||||||
|
If you are unsure, discuss the feature on our [Slack](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE)
|
||||||
|
or in a [issue](https://github.com/freqtrade/freqtrade/issues) before a PR.
|
||||||
|
|
||||||
|
|
||||||
|
**Before sending the PR:**
|
||||||
|
|
||||||
|
## 1. Run unit tests
|
||||||
|
|
||||||
|
All unit tests must pass. If a unit test is broken, change your code to
|
||||||
|
make it pass. It means you have introduced a regression.
|
||||||
|
|
||||||
|
**Test the whole project**
|
||||||
|
```bash
|
||||||
|
pytest freqtrade
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test only one file**
|
||||||
|
```bash
|
||||||
|
pytest freqtrade/tests/test_<file_name>.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test only one method from one file**
|
||||||
|
```bash
|
||||||
|
pytest freqtrade/tests/test_<file_name>.py::test_<method_name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Test if your code is PEP8 compliant
|
||||||
|
**Install packages** (If not already installed)
|
||||||
|
```bash
|
||||||
|
pip3.6 install flake8 coveralls
|
||||||
|
```
|
||||||
|
**Run Flake8**
|
||||||
|
```bash
|
||||||
|
flake8 freqtrade
|
||||||
|
```
|
||||||
|
|
||||||
|
We receive a lot of code that fails the `flake8` checks.
|
||||||
|
To help with that, we encourage you to install the git pre-commit
|
||||||
|
hook that will warn you when you try to commit code that fails these checks.
|
||||||
|
Guide for installing them is [here](http://flake8.pycqa.org/en/latest/user/using-hooks.html).
|
||||||
|
|
||||||
|
## 3. Test if all type-hints are correct
|
||||||
|
|
||||||
|
**Install packages** (If not already installed)
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
pip3.6 install mypy
|
||||||
|
```
|
||||||
|
|
||||||
|
**Run mypy**
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
mypy freqtrade
|
||||||
|
```
|
||||||
23
Dockerfile
23
Dockerfile
@@ -1,20 +1,23 @@
|
|||||||
FROM python:3.6.2
|
FROM python:3.6.5-slim-stretch
|
||||||
|
|
||||||
RUN apt-get update
|
|
||||||
RUN apt-get -y install build-essential
|
|
||||||
|
|
||||||
# Install TA-lib
|
# Install TA-lib
|
||||||
RUN wget http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz
|
RUN apt-get update && apt-get -y install curl build-essential && apt-get clean
|
||||||
RUN tar zxvf ta-lib-0.4.0-src.tar.gz
|
RUN curl -L http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz | \
|
||||||
RUN cd ta-lib && ./configure && make && make install
|
tar xzvf - && \
|
||||||
|
cd ta-lib && \
|
||||||
|
./configure && make && make install && \
|
||||||
|
cd .. && rm -rf ta-lib
|
||||||
ENV LD_LIBRARY_PATH /usr/local/lib
|
ENV LD_LIBRARY_PATH /usr/local/lib
|
||||||
|
|
||||||
# Prepare environment
|
# Prepare environment
|
||||||
RUN mkdir /freqtrade
|
RUN mkdir /freqtrade
|
||||||
COPY . /freqtrade/
|
|
||||||
WORKDIR /freqtrade
|
WORKDIR /freqtrade
|
||||||
|
|
||||||
# Install dependencies and execute
|
# Install dependencies
|
||||||
|
COPY requirements.txt /freqtrade/
|
||||||
RUN pip install -r requirements.txt
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
# Install and execute
|
||||||
|
COPY . /freqtrade/
|
||||||
RUN pip install -e .
|
RUN pip install -e .
|
||||||
CMD ["freqtrade"]
|
ENTRYPOINT ["freqtrade"]
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
include LICENSE
|
include LICENSE
|
||||||
include README.md
|
include README.md
|
||||||
include config.json.example
|
include config.json.example
|
||||||
include freqtrade/exchange/*.py
|
recursive-include freqtrade *.py
|
||||||
include freqtrade/rpc/*.py
|
|
||||||
include freqtrade/tests/*.py
|
|
||||||
include freqtrade/tests/testdata/*.json
|
include freqtrade/tests/testdata/*.json
|
||||||
|
|||||||
252
README.md
252
README.md
@@ -1,103 +1,199 @@
|
|||||||
# freqtrade
|
# freqtrade
|
||||||
|
|
||||||
[](https://travis-ci.org/gcarq/freqtrade)
|
[](https://travis-ci.org/freqtrade/freqtrade)
|
||||||
[](https://coveralls.io/github/gcarq/freqtrade?branch=develop)
|
[](https://coveralls.io/github/freqtrade/freqtrade?branch=develop)
|
||||||
|
[](https://codeclimate.com/github/freqtrade/freqtrade/maintainability)
|
||||||
|
|
||||||
|
|
||||||
Simple High frequency trading bot for crypto currencies.
|
Simple High frequency trading bot for crypto currencies designed to
|
||||||
Currently supports trading on Bittrex exchange.
|
support multi exchanges and be controlled via Telegram.
|
||||||
|
|
||||||
This software is for educational purposes only.
|

|
||||||
Don't risk money which you are afraid to lose.
|
|
||||||
|
|
||||||
The command interface is accessible via Telegram (not required).
|
## Disclaimer
|
||||||
Just register a new bot on https://telegram.me/BotFather
|
This software is for educational purposes only. Do not risk money which
|
||||||
and enter the telegram `token` and your `chat_id` in `config.json`
|
you are afraid to lose. USE THE SOFTWARE AT YOUR OWN RISK. THE AUTHORS
|
||||||
|
AND ALL AFFILIATES ASSUME NO RESPONSIBILITY FOR YOUR TRADING RESULTS.
|
||||||
|
|
||||||
Persistence is achieved through sqlite.
|
Always start by running a trading bot in Dry-run and do not engage money
|
||||||
|
before you understand how it works and what profit/loss you should
|
||||||
|
expect.
|
||||||
|
|
||||||
#### Telegram RPC commands:
|
We strongly recommend you to have coding and Python knowledge. Do not
|
||||||
* /start: Starts the trader
|
hesitate to read the source code and understand the mechanism of this bot.
|
||||||
* /stop: Stops the trader
|
|
||||||
* /status: Lists all open trades
|
|
||||||
* /profit: Lists cumulative profit from all finished trades
|
|
||||||
* /forcesell <trade_id>: Instantly sells the given trade (Ignoring `minimum_roi`).
|
|
||||||
* /performance: Show performance of each finished trade grouped by pair
|
|
||||||
|
|
||||||
#### Config
|
## Exchange marketplaces supported
|
||||||
`minimal_roi` is a JSON object where the key is a duration
|
- [X] [Bittrex](https://bittrex.com/)
|
||||||
in minutes and the value is the minimum ROI in percent.
|
- [X] [Binance](https://www.binance.com/)
|
||||||
See the example below:
|
- [ ] [113 others to tests](https://github.com/ccxt/ccxt/). _(We cannot guarantee they will work)_
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- [x] **Based on Python 3.6+**: For botting on any operating system -
|
||||||
|
Windows, macOS and Linux
|
||||||
|
- [x] **Persistence**: Persistence is achieved through sqlite
|
||||||
|
- [x] **Dry-run**: Run the bot without playing money.
|
||||||
|
- [x] **Backtesting**: Run a simulation of your buy/sell strategy.
|
||||||
|
- [x] **Strategy Optimization by machine learning**: Use machine learning to optimize your buy/sell
|
||||||
|
strategy parameters with real exchange data.
|
||||||
|
- [x] **Whitelist crypto-currencies**: Select which crypto-currency you want to trade.
|
||||||
|
- [x] **Blacklist crypto-currencies**: Select which crypto-currency you want to avoid.
|
||||||
|
- [x] **Manageable via Telegram**: Manage the bot with Telegram
|
||||||
|
- [x] **Display profit/loss in fiat**: Display your profit/loss in 33 fiat.
|
||||||
|
- [x] **Daily summary of profit/loss**: Provide a daily summary of your profit/loss.
|
||||||
|
- [x] **Performance status report**: Provide a performance status of your current trades.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Quick start](#quick-start)
|
||||||
|
- [Documentations](https://github.com/freqtrade/freqtrade/blob/develop/docs/index.md)
|
||||||
|
- [Installation](https://github.com/freqtrade/freqtrade/blob/develop/docs/installation.md)
|
||||||
|
- [Configuration](https://github.com/freqtrade/freqtrade/blob/develop/docs/configuration.md)
|
||||||
|
- [Strategy Optimization](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-optimization.md)
|
||||||
|
- [Backtesting](https://github.com/freqtrade/freqtrade/blob/develop/docs/backtesting.md)
|
||||||
|
- [Hyperopt](https://github.com/freqtrade/freqtrade/blob/develop/docs/hyperopt.md)
|
||||||
|
- [Basic Usage](#basic-usage)
|
||||||
|
- [Bot commands](#bot-commands)
|
||||||
|
- [Telegram RPC commands](#telegram-rpc-commands)
|
||||||
|
- [Support](#support)
|
||||||
|
- [Help](#help--slack)
|
||||||
|
- [Bugs](#bugs--issues)
|
||||||
|
- [Feature Requests](#feature-requests)
|
||||||
|
- [Pull Requests](#pull-requests)
|
||||||
|
- [Requirements](#requirements)
|
||||||
|
- [Min hardware required](#min-hardware-required)
|
||||||
|
- [Software requirements](#software-requirements)
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
Freqtrade provides a Linux/macOS script to install all dependencies and help you to configure the bot.
|
||||||
|
```bash
|
||||||
|
git clone git@github.com:freqtrade/freqtrade.git
|
||||||
|
git checkout develop
|
||||||
|
cd freqtrade
|
||||||
|
./setup.sh --install
|
||||||
```
|
```
|
||||||
"minimal_roi": {
|
_Windows installation is explained in [Installation doc](https://github.com/freqtrade/freqtrade/blob/develop/docs/installation.md)_
|
||||||
"2880": 0.005, # Sell after 48 hours if there is at least 0.5% profit
|
|
||||||
"1440": 0.01, # Sell after 24 hours if there is at least 1% profit
|
|
||||||
"720": 0.02, # Sell after 12 hours if there is at least 2% profit
|
## Documentation
|
||||||
"360": 0.02, # Sell after 6 hours if there is at least 2% profit
|
We invite you to read the bot documentation to ensure you understand how the bot is working.
|
||||||
"0": 0.025 # Sell immediately if there is at least 2.5% profit
|
- [Index](https://github.com/freqtrade/freqtrade/blob/develop/docs/index.md)
|
||||||
},
|
- [Installation](https://github.com/freqtrade/freqtrade/blob/develop/docs/installation.md)
|
||||||
|
- [Configuration](https://github.com/freqtrade/freqtrade/blob/develop/docs/configuration.md)
|
||||||
|
- [Bot usage](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-usage.md)
|
||||||
|
- [How to run the bot](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-usage.md#bot-commands)
|
||||||
|
- [How to use Backtesting](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-usage.md#backtesting-commands)
|
||||||
|
- [How to use Hyperopt](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-usage.md#hyperopt-commands)
|
||||||
|
- [Strategy Optimization](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-optimization.md)
|
||||||
|
- [Backtesting](https://github.com/freqtrade/freqtrade/blob/develop/docs/backtesting.md)
|
||||||
|
- [Hyperopt](https://github.com/freqtrade/freqtrade/blob/develop/docs/hyperopt.md)
|
||||||
|
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
### Bot commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
usage: main.py [-h] [-v] [--version] [-c PATH] [-d PATH] [-s NAME]
|
||||||
|
[--strategy-path PATH] [--dynamic-whitelist [INT]]
|
||||||
|
[--dry-run-db]
|
||||||
|
{backtesting,hyperopt} ...
|
||||||
|
|
||||||
|
Simple High Frequency Trading Bot for crypto currencies
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
{backtesting,hyperopt}
|
||||||
|
backtesting backtesting module
|
||||||
|
hyperopt hyperopt module
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
-v, --verbose be verbose
|
||||||
|
--version show program's version number and exit
|
||||||
|
-c PATH, --config PATH
|
||||||
|
specify configuration file (default: config.json)
|
||||||
|
-d PATH, --datadir PATH
|
||||||
|
path to backtest data (default:
|
||||||
|
freqtrade/tests/testdata
|
||||||
|
-s NAME, --strategy NAME
|
||||||
|
specify strategy class name (default: DefaultStrategy)
|
||||||
|
--strategy-path PATH specify additional strategy lookup path
|
||||||
|
--dynamic-whitelist [INT]
|
||||||
|
dynamically generate and update whitelist based on 24h
|
||||||
|
BaseVolume (Default 20 currencies)
|
||||||
|
--dry-run-db Force dry run to use a local DB
|
||||||
|
"tradesv3.dry_run.sqlite" instead of memory DB. Work
|
||||||
|
only if dry_run is enabled.
|
||||||
```
|
```
|
||||||
|
|
||||||
`stoploss` is loss in percentage that should trigger a sale.
|
### Telegram RPC commands
|
||||||
For example value `-0.10` will cause immediate sell if the
|
Telegram is not mandatory. However, this is a great way to control your
|
||||||
profit dips below -10% for a given trade. This parameter is optional.
|
bot. More details on our
|
||||||
|
[documentation](https://github.com/freqtrade/freqtrade/blob/develop/docs/index.md)
|
||||||
|
|
||||||
`initial_state` is an optional field that defines the initial application state.
|
- `/start`: Starts the trader
|
||||||
Possible values are `running` or `stopped`. (default=`running`)
|
- `/stop`: Stops the trader
|
||||||
If the value is `stopped` the bot has to be started with `/start` first.
|
- `/status [table]`: Lists all open trades
|
||||||
|
- `/count`: Displays number of open trades
|
||||||
|
- `/profit`: Lists cumulative profit from all finished trades
|
||||||
|
- `/forcesell <trade_id>|all`: Instantly sells the given trade
|
||||||
|
(Ignoring `minimum_roi`).
|
||||||
|
- `/performance`: Show performance of each finished trade grouped by pair
|
||||||
|
- `/balance`: Show account balance per currency
|
||||||
|
- `/daily <n>`: Shows profit or loss per day, over the last n days
|
||||||
|
- `/help`: Show help message
|
||||||
|
- `/version`: Show version
|
||||||
|
|
||||||
`ask_last_balance` sets the bidding price. Value `0.0` will use `ask` price, `1.0` will
|
|
||||||
use the `last` price and values between those interpolate between ask and last price. Using `ask` price will guarantee quick success in bid, but bot will also end up paying more then would probably have been necessary.
|
|
||||||
|
|
||||||
The other values should be self-explanatory,
|
## Development branches
|
||||||
if not feel free to raise a github issue.
|
The project is currently setup in two main branches:
|
||||||
|
- `develop` - This branch has often new features, but might also cause
|
||||||
|
breaking changes.
|
||||||
|
- `master` - This branch contains the latest stable release. The bot
|
||||||
|
'should' be stable on this branch, and is generally well tested.
|
||||||
|
|
||||||
#### Prerequisites
|
|
||||||
* python3.6
|
|
||||||
* sqlite
|
|
||||||
* [TA-lib](https://github.com/mrjbq7/ta-lib#dependencies) binaries
|
|
||||||
|
|
||||||
#### Install
|
## Support
|
||||||
|
### Help / Slack
|
||||||
|
For any questions not covered by the documentation or for further
|
||||||
|
information about the bot, we encourage you to join our slack channel.
|
||||||
|
- [Click here to join Slack channel](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE).
|
||||||
|
|
||||||
`master` branch contains the latest stable release.
|
### [Bugs / Issues](https://github.com/freqtrade/freqtrade/issues?q=is%3Aissue)
|
||||||
|
If you discover a bug in the bot, please
|
||||||
|
[search our issue tracker](https://github.com/freqtrade/freqtrade/issues?q=is%3Aissue)
|
||||||
|
first. If it hasn't been reported, please
|
||||||
|
[create a new issue](https://github.com/freqtrade/freqtrade/issues/new) and
|
||||||
|
ensure you follow the template guide so that our team can assist you as
|
||||||
|
quickly as possible.
|
||||||
|
|
||||||
`develop` branch has often new features, but might also cause breaking changes. To use it, you are encouraged to join our [slack channel](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE).
|
### [Feature Requests](https://github.com/freqtrade/freqtrade/labels/enhancement)
|
||||||
|
Have you a great idea to improve the bot you want to share? Please,
|
||||||
|
first search if this feature was not [already discussed](https://github.com/freqtrade/freqtrade/labels/enhancement).
|
||||||
|
If it hasn't been requested, please
|
||||||
|
[create a new request](https://github.com/freqtrade/freqtrade/issues/new)
|
||||||
|
and ensure you follow the template guide so that it does not get lost
|
||||||
|
in the bug reports.
|
||||||
|
|
||||||
```
|
### [Pull Requests](https://github.com/freqtrade/freqtrade/pulls)
|
||||||
$ cd freqtrade/
|
Feel like our bot is missing a feature? We welcome your pull requests!
|
||||||
# copy example config. Dont forget to insert your api keys
|
Please read our
|
||||||
$ cp config.json.example config.json
|
[Contributing document](https://github.com/freqtrade/freqtrade/blob/develop/CONTRIBUTING.md)
|
||||||
$ python -m venv .env
|
to understand the requirements before sending your pull-requests.
|
||||||
$ source .env/bin/activate
|
|
||||||
$ pip install -r requirements.txt
|
|
||||||
$ pip install -e .
|
|
||||||
$ ./freqtrade/main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
There is also an [article](https://www.sales4k.com/blockchain/high-frequency-trading-bot-tutorial/) about how to setup the bot (thanks [@gurghet](https://github.com/gurghet)).
|
**Note** before starting any major new feature work, *please open an issue describing what you are planning to do* or talk to us on [Slack](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE). This will ensure that interested parties can give valuable feedback on the feature, and let others know that you are working on it.
|
||||||
|
|
||||||
#### Execute tests
|
**Important:** Always create your PR against the `develop` branch, not
|
||||||
|
`master`.
|
||||||
|
|
||||||
```
|
## Requirements
|
||||||
$ pytest
|
|
||||||
```
|
|
||||||
This will by default skip the slow running backtest set. To run backtest set:
|
|
||||||
|
|
||||||
```
|
### Min hardware required
|
||||||
$ BACKTEST=true pytest
|
To run this bot we recommend you a cloud instance with a minimum of:
|
||||||
```
|
* Minimal (advised) system requirements: 2GB RAM, 1GB disk space, 2vCPU
|
||||||
|
|
||||||
#### Docker
|
### Software requirements
|
||||||
```
|
- [Python 3.6.x](http://docs.python-guide.org/en/latest/starting/installation/)
|
||||||
$ cd freqtrade
|
- [pip](https://pip.pypa.io/en/stable/installing/)
|
||||||
$ docker build -t freqtrade .
|
- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||||
$ docker run --rm -it freqtrade
|
- [TA-Lib](https://mrjbq7.github.io/ta-lib/install.html)
|
||||||
```
|
- [virtualenv](https://virtualenv.pypa.io/en/stable/installation/) (Recommended)
|
||||||
|
- [Docker](https://www.docker.com/products/docker) (Recommended)
|
||||||
#### Contributing
|
|
||||||
|
|
||||||
Feel like our bot is missing a feature? We welcome your pull requests! Few pointers for contributions:
|
|
||||||
|
|
||||||
- Create your PR against the `develop` branch, not `master`.
|
|
||||||
- New features need to contain unit tests.
|
|
||||||
- If you are unsure, discuss the feature on [slack](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE) or in a [issue](https://github.com/gcarq/freqtrade/issues) before a PR.
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
from freqtrade.main import main
|
import sys
|
||||||
main()
|
|
||||||
|
from freqtrade.main import main, set_loggers
|
||||||
|
set_loggers()
|
||||||
|
main(sys.argv[1:])
|
||||||
|
|||||||
@@ -2,37 +2,45 @@
|
|||||||
"max_open_trades": 3,
|
"max_open_trades": 3,
|
||||||
"stake_currency": "BTC",
|
"stake_currency": "BTC",
|
||||||
"stake_amount": 0.05,
|
"stake_amount": 0.05,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"ticker_interval" : "5m",
|
||||||
"dry_run": false,
|
"dry_run": false,
|
||||||
"minimal_roi": {
|
"unfilledtimeout": 600,
|
||||||
"60": 0.0,
|
|
||||||
"40": 0.01,
|
|
||||||
"20": 0.02,
|
|
||||||
"0": 0.03
|
|
||||||
},
|
|
||||||
"stoploss": -0.40,
|
|
||||||
"bid_strategy": {
|
"bid_strategy": {
|
||||||
"ask_last_balance": 0.0
|
"ask_last_balance": 0.0
|
||||||
},
|
},
|
||||||
"exchange": {
|
"exchange": {
|
||||||
"name": "bittrex",
|
"name": "bittrex",
|
||||||
"key": "key",
|
"key": "your_exchange_key",
|
||||||
"secret": "secret",
|
"secret": "your_exchange_secret",
|
||||||
"pair_whitelist": [
|
"pair_whitelist": [
|
||||||
"BTC_RLC",
|
"ETH/BTC",
|
||||||
"BTC_TKN",
|
"LTC/BTC",
|
||||||
"BTC_TRST",
|
"ETC/BTC",
|
||||||
"BTC_SWT",
|
"DASH/BTC",
|
||||||
"BTC_PIVX",
|
"ZEC/BTC",
|
||||||
"BTC_MLN",
|
"XLM/BTC",
|
||||||
"BTC_XZC",
|
"NXT/BTC",
|
||||||
"BTC_TIME",
|
"POWR/BTC",
|
||||||
"BTC_LUN"
|
"ADA/BTC",
|
||||||
|
"XMR/BTC"
|
||||||
|
],
|
||||||
|
"pair_blacklist": [
|
||||||
|
"DOGE/BTC"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"experimental": {
|
||||||
|
"use_sell_signal": false,
|
||||||
|
"sell_profit_only": false,
|
||||||
|
"ignore_roi_if_buy_signal": false
|
||||||
|
},
|
||||||
"telegram": {
|
"telegram": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"token": "token",
|
"token": "your_telegram_token",
|
||||||
"chat_id": "chat_id"
|
"chat_id": "your_telegram_chat_id"
|
||||||
},
|
},
|
||||||
"initial_state": "running"
|
"initial_state": "running",
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 5
|
||||||
|
}
|
||||||
}
|
}
|
||||||
56
config_full.json.example
Normal file
56
config_full.json.example
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{
|
||||||
|
"max_open_trades": 3,
|
||||||
|
"stake_currency": "BTC",
|
||||||
|
"stake_amount": 0.05,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"dry_run": false,
|
||||||
|
"ticker_interval": "5m",
|
||||||
|
"minimal_roi": {
|
||||||
|
"40": 0.0,
|
||||||
|
"30": 0.01,
|
||||||
|
"20": 0.02,
|
||||||
|
"0": 0.04
|
||||||
|
},
|
||||||
|
"stoploss": -0.10,
|
||||||
|
"unfilledtimeout": 600,
|
||||||
|
"bid_strategy": {
|
||||||
|
"ask_last_balance": 0.0
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "bittrex",
|
||||||
|
"key": "your_exchange_key",
|
||||||
|
"secret": "your_exchange_secret",
|
||||||
|
"pair_whitelist": [
|
||||||
|
"ETH/BTC",
|
||||||
|
"LTC/BTC",
|
||||||
|
"ETC/BTC",
|
||||||
|
"DASH/BTC",
|
||||||
|
"ZEC/BTC",
|
||||||
|
"XLM/BTC",
|
||||||
|
"NXT/BTC",
|
||||||
|
"POWR/BTC",
|
||||||
|
"ADA/BTC",
|
||||||
|
"XMR/BTC"
|
||||||
|
],
|
||||||
|
"pair_blacklist": [
|
||||||
|
"DOGE/BTC"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"experimental": {
|
||||||
|
"use_sell_signal": false,
|
||||||
|
"sell_profit_only": false,
|
||||||
|
"ignore_roi_if_buy_signal": false
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"enabled": true,
|
||||||
|
"token": "your_telegram_token",
|
||||||
|
"chat_id": "your_telegram_chat_id"
|
||||||
|
},
|
||||||
|
"db_url": "sqlite:///tradesv3.sqlite",
|
||||||
|
"initial_state": "running",
|
||||||
|
"internals": {
|
||||||
|
"process_throttle_secs": 5
|
||||||
|
},
|
||||||
|
"strategy": "DefaultStrategy",
|
||||||
|
"strategy_path": "/some/folder/"
|
||||||
|
}
|
||||||
BIN
docs/assets/freqtrade-screenshot.png
Normal file
BIN
docs/assets/freqtrade-screenshot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
215
docs/backtesting.md
Normal file
215
docs/backtesting.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Backtesting
|
||||||
|
|
||||||
|
This page explains how to validate your strategy performance by using
|
||||||
|
Backtesting.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Test your strategy with Backtesting](#test-your-strategy-with-backtesting)
|
||||||
|
- [Understand the backtesting result](#understand-the-backtesting-result)
|
||||||
|
|
||||||
|
## Test your strategy with Backtesting
|
||||||
|
|
||||||
|
Now you have good Buy and Sell strategies, you want to test it against
|
||||||
|
real data. This is what we call
|
||||||
|
[backtesting](https://en.wikipedia.org/wiki/Backtesting).
|
||||||
|
|
||||||
|
Backtesting will use the crypto-currencies (pair) from your config file
|
||||||
|
and load static tickers located in
|
||||||
|
[/freqtrade/tests/testdata](https://github.com/freqtrade/freqtrade/tree/develop/freqtrade/tests/testdata).
|
||||||
|
If the 5 min and 1 min ticker for the crypto-currencies to test is not
|
||||||
|
already in the `testdata` folder, backtesting will download them
|
||||||
|
automatically. Testdata files will not be updated until you specify it.
|
||||||
|
|
||||||
|
The result of backtesting will confirm you if your bot has better odds of making a profit than a loss.
|
||||||
|
|
||||||
|
The backtesting is very easy with freqtrade.
|
||||||
|
|
||||||
|
### Run a backtesting against the currencies listed in your config file
|
||||||
|
#### With 5 min tickers (Per default)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py backtesting --realistic-simulation
|
||||||
|
```
|
||||||
|
|
||||||
|
#### With 1 min tickers
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py backtesting --realistic-simulation --ticker-interval 1m
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update cached pairs with the latest data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py backtesting --realistic-simulation --refresh-pairs-cached
|
||||||
|
```
|
||||||
|
|
||||||
|
#### With live data (do not alter your testdata files)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py backtesting --realistic-simulation --live
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Using a different on-disk ticker-data source
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py backtesting --datadir freqtrade/tests/testdata-20180101
|
||||||
|
```
|
||||||
|
|
||||||
|
#### With a (custom) strategy file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py -s TestStrategy backtesting
|
||||||
|
```
|
||||||
|
|
||||||
|
Where `-s TestStrategy` refers to the class name within the strategy file `test_strategy.py` found in the `freqtrade/user_data/strategies` directory
|
||||||
|
|
||||||
|
#### Exporting trades to file
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py backtesting --export trades
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Exporting trades to file specifying a custom filename
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py backtesting --export trades --export-filename=backtest_teststrategy.json
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Running backtest with smaller testset
|
||||||
|
|
||||||
|
Use the `--timerange` argument to change how much of the testset
|
||||||
|
you want to use. The last N ticks/timeframes will be used.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py backtesting --timerange=-200
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Advanced use of timerange
|
||||||
|
|
||||||
|
Doing `--timerange=-200` will get the last 200 timeframes
|
||||||
|
from your inputdata. You can also specify specific dates,
|
||||||
|
or a range span indexed by start and stop.
|
||||||
|
|
||||||
|
The full timerange specification:
|
||||||
|
|
||||||
|
- Use last 123 tickframes of data: `--timerange=-123`
|
||||||
|
- Use first 123 tickframes of data: `--timerange=123-`
|
||||||
|
- Use tickframes from line 123 through 456: `--timerange=123-456`
|
||||||
|
- Use tickframes till 2018/01/31: `--timerange=-20180131`
|
||||||
|
- Use tickframes since 2018/01/31: `--timerange=20180131-`
|
||||||
|
- Use tickframes since 2018/01/31 till 2018/03/01 : `--timerange=20180131-20180301`
|
||||||
|
- Use tickframes between POSIX timestamps 1527595200 1527618600:
|
||||||
|
`--timerange=1527595200-1527618600`
|
||||||
|
|
||||||
|
#### Downloading new set of ticker data
|
||||||
|
|
||||||
|
To download new set of backtesting ticker data, you can use a download script.
|
||||||
|
|
||||||
|
If you are using Binance for example:
|
||||||
|
|
||||||
|
- create a folder `user_data/data/binance` and copy `pairs.json` in that folder.
|
||||||
|
- update the `pairs.json` to contain the currency pairs you are interested in.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p user_data/data/binance
|
||||||
|
cp freqtrade/tests/testdata/pairs.json user_data/data/binance
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/download_backtest_data --exchange binance
|
||||||
|
```
|
||||||
|
|
||||||
|
This will download ticker data for all the currency pairs you defined in `pairs.json`.
|
||||||
|
|
||||||
|
- To use a different folder than the exchange specific default, use `--export user_data/data/some_directory`.
|
||||||
|
- To change the exchange used to download the tickers, use `--exchange`. Default is `bittrex`.
|
||||||
|
- To use `pairs.json` from some other folder, use `--pairs-file some_other_dir/pairs.json`.
|
||||||
|
- To download ticker data for only 10 days, use `--days 10`.
|
||||||
|
- Use `--timeframes` to specify which tickers to download. Default is `--timeframes 1m 5m` which will download 1-minute and 5-minute tickers.
|
||||||
|
|
||||||
|
For help about backtesting usage, please refer to [Backtesting commands](#backtesting-commands).
|
||||||
|
|
||||||
|
## Understand the backtesting result
|
||||||
|
|
||||||
|
The most important in the backtesting is to understand the result.
|
||||||
|
|
||||||
|
A backtesting result will look like that:
|
||||||
|
|
||||||
|
```
|
||||||
|
======================================== BACKTESTING REPORT =========================================
|
||||||
|
| pair | buy count | avg profit % | total profit BTC | avg duration | profit | loss |
|
||||||
|
|:---------|------------:|---------------:|-------------------:|---------------:|---------:|-------:|
|
||||||
|
| ETH/BTC | 44 | 0.18 | 0.00159118 | 50.9 | 44 | 0 |
|
||||||
|
| LTC/BTC | 27 | 0.10 | 0.00051931 | 103.1 | 26 | 1 |
|
||||||
|
| ETC/BTC | 24 | 0.05 | 0.00022434 | 166.0 | 22 | 2 |
|
||||||
|
| DASH/BTC | 29 | 0.18 | 0.00103223 | 192.2 | 29 | 0 |
|
||||||
|
| ZEC/BTC | 65 | -0.02 | -0.00020621 | 202.7 | 62 | 3 |
|
||||||
|
| XLM/BTC | 35 | 0.02 | 0.00012877 | 242.4 | 32 | 3 |
|
||||||
|
| BCH/BTC | 12 | 0.62 | 0.00149284 | 50.0 | 12 | 0 |
|
||||||
|
| POWR/BTC | 21 | 0.26 | 0.00108215 | 134.8 | 21 | 0 |
|
||||||
|
| ADA/BTC | 54 | -0.19 | -0.00205202 | 191.3 | 47 | 7 |
|
||||||
|
| XMR/BTC | 24 | -0.43 | -0.00206013 | 120.6 | 20 | 4 |
|
||||||
|
| TOTAL | 335 | 0.03 | 0.00175246 | 157.9 | 315 | 20 |
|
||||||
|
2018-06-13 06:57:27,347 - freqtrade.optimize.backtesting - INFO -
|
||||||
|
====================================== LEFT OPEN TRADES REPORT ======================================
|
||||||
|
| pair | buy count | avg profit % | total profit BTC | avg duration | profit | loss |
|
||||||
|
|:---------|------------:|---------------:|-------------------:|---------------:|---------:|-------:|
|
||||||
|
| ETH/BTC | 3 | 0.16 | 0.00009619 | 25.0 | 3 | 0 |
|
||||||
|
| LTC/BTC | 1 | -1.00 | -0.00020118 | 1085.0 | 0 | 1 |
|
||||||
|
| ETC/BTC | 2 | -1.80 | -0.00071933 | 1092.5 | 0 | 2 |
|
||||||
|
| DASH/BTC | 0 | nan | 0.00000000 | nan | 0 | 0 |
|
||||||
|
| ZEC/BTC | 3 | -4.27 | -0.00256826 | 1301.7 | 0 | 3 |
|
||||||
|
| XLM/BTC | 3 | -1.11 | -0.00066744 | 965.0 | 0 | 3 |
|
||||||
|
| BCH/BTC | 0 | nan | 0.00000000 | nan | 0 | 0 |
|
||||||
|
| POWR/BTC | 0 | nan | 0.00000000 | nan | 0 | 0 |
|
||||||
|
| ADA/BTC | 7 | -3.58 | -0.00503604 | 850.0 | 0 | 7 |
|
||||||
|
| XMR/BTC | 4 | -3.79 | -0.00303456 | 291.2 | 0 | 4 |
|
||||||
|
| TOTAL | 23 | -2.63 | -0.01213062 | 750.4 | 3 | 20 |
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
The 1st table will contain all trades the bot made.
|
||||||
|
|
||||||
|
The 2nd table will contain all trades the bot had to `forcesell` at the end of the backtest period to prsent a full picture.
|
||||||
|
These trades are also included in the first table, but are extracted separately for clarity.
|
||||||
|
|
||||||
|
The last line will give you the overall performance of your strategy,
|
||||||
|
here:
|
||||||
|
|
||||||
|
```
|
||||||
|
TOTAL 419 -0.41 -0.00348593 52.9
|
||||||
|
```
|
||||||
|
|
||||||
|
We understand the bot has made `419` trades for an average duration of
|
||||||
|
`52.9` min, with a performance of `-0.41%` (loss), that means it has
|
||||||
|
lost a total of `-0.00348593 BTC`.
|
||||||
|
|
||||||
|
As you will see your strategy performance will be influenced by your buy
|
||||||
|
strategy, your sell strategy, and also by the `minimal_roi` and
|
||||||
|
`stop_loss` you have set.
|
||||||
|
|
||||||
|
As for an example if your minimal_roi is only `"0": 0.01`. You cannot
|
||||||
|
expect the bot to make more profit than 1% (because it will sell every
|
||||||
|
time a trade will reach 1%).
|
||||||
|
|
||||||
|
```json
|
||||||
|
"minimal_roi": {
|
||||||
|
"0": 0.01
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
On the other hand, if you set a too high `minimal_roi` like `"0": 0.55`
|
||||||
|
(55%), there is a lot of chance that the bot will never reach this
|
||||||
|
profit. Hence, keep in mind that your performance is a mix of your
|
||||||
|
strategies, your configuration, and the crypto-currency you have set up.
|
||||||
|
|
||||||
|
## Next step
|
||||||
|
|
||||||
|
Great, your strategy is profitable. What if the bot can give your the
|
||||||
|
optimal parameters to use for your strategy?
|
||||||
|
Your next step is to learn [how to find optimal parameters with Hyperopt](https://github.com/freqtrade/freqtrade/blob/develop/docs/hyperopt.md)
|
||||||
173
docs/bot-optimization.md
Normal file
173
docs/bot-optimization.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Bot Optimization
|
||||||
|
|
||||||
|
This page explains where to customize your strategies, and add new
|
||||||
|
indicators.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [Install a custom strategy file](#install-a-custom-strategy-file)
|
||||||
|
- [Customize your strategy](#change-your-strategy)
|
||||||
|
- [Add more Indicator](#add-more-indicator)
|
||||||
|
- [Where is the default strategy](#where-is-the-default-strategy)
|
||||||
|
|
||||||
|
Since the version `0.16.0` the bot allows using custom strategy file.
|
||||||
|
|
||||||
|
## Install a custom strategy file
|
||||||
|
|
||||||
|
This is very simple. Copy paste your strategy file into the folder
|
||||||
|
`user_data/strategies`.
|
||||||
|
|
||||||
|
Let assume you have a class called `AwesomeStrategy` in the file `awesome-strategy.py`:
|
||||||
|
|
||||||
|
1. Move your file into `user_data/strategies` (you should have `user_data/strategies/awesome-strategy.py`
|
||||||
|
2. Start the bot with the param `--strategy AwesomeStrategy` (the parameter is the class name)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py --strategy AwesomeStrategy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Change your strategy
|
||||||
|
|
||||||
|
The bot includes a default strategy file. However, we recommend you to
|
||||||
|
use your own file to not have to lose your parameters every time the default
|
||||||
|
strategy file will be updated on Github. Put your custom strategy file
|
||||||
|
into the folder `user_data/strategies`.
|
||||||
|
|
||||||
|
A strategy file contains all the information needed to build a good strategy:
|
||||||
|
|
||||||
|
- Buy strategy rules
|
||||||
|
- Sell strategy rules
|
||||||
|
- Minimal ROI recommended
|
||||||
|
- Stoploss recommended
|
||||||
|
- Hyperopt parameter
|
||||||
|
|
||||||
|
The bot also include a sample strategy called `TestStrategy` you can update: `user_data/strategies/test_strategy.py`.
|
||||||
|
You can test it with the parameter: `--strategy TestStrategy`
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
python3 ./freqtrade/main.py --strategy AwesomeStrategy
|
||||||
|
```
|
||||||
|
|
||||||
|
### Specify custom strategy location
|
||||||
|
|
||||||
|
If you want to use a strategy from a different folder you can pass `--strategy-path`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py --strategy AwesomeStrategy --strategy-path /some/folder
|
||||||
|
```
|
||||||
|
|
||||||
|
**For the following section we will use the [user_data/strategies/test_strategy.py](https://github.com/freqtrade/freqtrade/blob/develop/user_data/strategies/test_strategy.py)
|
||||||
|
file as reference.**
|
||||||
|
|
||||||
|
### Buy strategy
|
||||||
|
|
||||||
|
Edit the method `populate_buy_trend()` into your strategy file to
|
||||||
|
update your buy strategy.
|
||||||
|
|
||||||
|
Sample from `user_data/strategies/test_strategy.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def populate_buy_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Based on TA indicators, populates the buy signal for the given dataframe
|
||||||
|
:param dataframe: DataFrame
|
||||||
|
:return: DataFrame with buy column
|
||||||
|
"""
|
||||||
|
dataframe.loc[
|
||||||
|
(
|
||||||
|
(dataframe['adx'] > 30) &
|
||||||
|
(dataframe['tema'] <= dataframe['blower']) &
|
||||||
|
(dataframe['tema'] > dataframe['tema'].shift(1))
|
||||||
|
),
|
||||||
|
'buy'] = 1
|
||||||
|
|
||||||
|
return dataframe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sell strategy
|
||||||
|
|
||||||
|
Edit the method `populate_sell_trend()` into your strategy file to update your sell strategy.
|
||||||
|
|
||||||
|
Sample from `user_data/strategies/test_strategy.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def populate_sell_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Based on TA indicators, populates the sell signal for the given dataframe
|
||||||
|
:param dataframe: DataFrame
|
||||||
|
:return: DataFrame with buy column
|
||||||
|
"""
|
||||||
|
dataframe.loc[
|
||||||
|
(
|
||||||
|
(dataframe['adx'] > 70) &
|
||||||
|
(dataframe['tema'] > dataframe['blower']) &
|
||||||
|
(dataframe['tema'] < dataframe['tema'].shift(1))
|
||||||
|
),
|
||||||
|
'sell'] = 1
|
||||||
|
return dataframe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Add more Indicator
|
||||||
|
|
||||||
|
As you have seen, buy and sell strategies need indicators. You can add
|
||||||
|
more indicators by extending the list contained in
|
||||||
|
the method `populate_indicators()` from your strategy file.
|
||||||
|
|
||||||
|
Sample:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def populate_indicators(dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Adds several different TA indicators to the given DataFrame
|
||||||
|
"""
|
||||||
|
dataframe['sar'] = ta.SAR(dataframe)
|
||||||
|
dataframe['adx'] = ta.ADX(dataframe)
|
||||||
|
stoch = ta.STOCHF(dataframe)
|
||||||
|
dataframe['fastd'] = stoch['fastd']
|
||||||
|
dataframe['fastk'] = stoch['fastk']
|
||||||
|
dataframe['blower'] = ta.BBANDS(dataframe, nbdevup=2, nbdevdn=2)['lowerband']
|
||||||
|
dataframe['sma'] = ta.SMA(dataframe, timeperiod=40)
|
||||||
|
dataframe['tema'] = ta.TEMA(dataframe, timeperiod=9)
|
||||||
|
dataframe['mfi'] = ta.MFI(dataframe)
|
||||||
|
dataframe['rsi'] = ta.RSI(dataframe)
|
||||||
|
dataframe['ema5'] = ta.EMA(dataframe, timeperiod=5)
|
||||||
|
dataframe['ema10'] = ta.EMA(dataframe, timeperiod=10)
|
||||||
|
dataframe['ema50'] = ta.EMA(dataframe, timeperiod=50)
|
||||||
|
dataframe['ema100'] = ta.EMA(dataframe, timeperiod=100)
|
||||||
|
dataframe['ao'] = awesome_oscillator(dataframe)
|
||||||
|
macd = ta.MACD(dataframe)
|
||||||
|
dataframe['macd'] = macd['macd']
|
||||||
|
dataframe['macdsignal'] = macd['macdsignal']
|
||||||
|
dataframe['macdhist'] = macd['macdhist']
|
||||||
|
hilbert = ta.HT_SINE(dataframe)
|
||||||
|
dataframe['htsine'] = hilbert['sine']
|
||||||
|
dataframe['htleadsine'] = hilbert['leadsine']
|
||||||
|
dataframe['plus_dm'] = ta.PLUS_DM(dataframe)
|
||||||
|
dataframe['plus_di'] = ta.PLUS_DI(dataframe)
|
||||||
|
dataframe['minus_dm'] = ta.MINUS_DM(dataframe)
|
||||||
|
dataframe['minus_di'] = ta.MINUS_DI(dataframe)
|
||||||
|
return dataframe
|
||||||
|
```
|
||||||
|
|
||||||
|
### Want more indicator examples
|
||||||
|
|
||||||
|
Look into the [user_data/strategies/test_strategy.py](https://github.com/freqtrade/freqtrade/blob/develop/user_data/strategies/test_strategy.py).
|
||||||
|
Then uncomment indicators you need.
|
||||||
|
|
||||||
|
### Where is the default strategy?
|
||||||
|
|
||||||
|
The default buy strategy is located in the file
|
||||||
|
[freqtrade/default_strategy.py](https://github.com/freqtrade/freqtrade/blob/develop/freqtrade/strategy/default_strategy.py).
|
||||||
|
|
||||||
|
### Further strategy ideas
|
||||||
|
|
||||||
|
To get additional Ideas for strategies, head over to our [strategy repository](https://github.com/freqtrade/freqtrade-strategies). Feel free to use them as they are - but results will depend on the current market situation, pairs used etc. - therefore please backtest the strategy for your exchange/desired pairs first, evaluate carefully, use at your own risk.
|
||||||
|
Feel free to use any of them as inspiration for your own strategies.
|
||||||
|
We're happy to accept Pull Requests containing new Strategies to that repo.
|
||||||
|
|
||||||
|
We also got a *strategy-sharing* channel in our [Slack community](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE) which is a great place to get and/or share ideas.
|
||||||
|
|
||||||
|
## Next step
|
||||||
|
|
||||||
|
Now you have a perfect strategy you probably want to backtest it.
|
||||||
|
Your next step is to learn [How to use the Backtesting](https://github.com/freqtrade/freqtrade/blob/develop/docs/backtesting.md).
|
||||||
192
docs/bot-usage.md
Normal file
192
docs/bot-usage.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# Bot usage
|
||||||
|
This page explains the difference parameters of the bot and how to run
|
||||||
|
it.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Bot commands](#bot-commands)
|
||||||
|
- [Backtesting commands](#backtesting-commands)
|
||||||
|
- [Hyperopt commands](#hyperopt-commands)
|
||||||
|
|
||||||
|
## Bot commands
|
||||||
|
```
|
||||||
|
usage: freqtrade [-h] [-v] [--version] [-c PATH] [-d PATH] [-s NAME]
|
||||||
|
[--strategy-path PATH] [--dynamic-whitelist [INT]]
|
||||||
|
[--db-url PATH]
|
||||||
|
{backtesting,hyperopt} ...
|
||||||
|
|
||||||
|
Simple High Frequency Trading Bot for crypto currencies
|
||||||
|
|
||||||
|
positional arguments:
|
||||||
|
{backtesting,hyperopt}
|
||||||
|
backtesting backtesting module
|
||||||
|
hyperopt hyperopt module
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
-v, --verbose be verbose
|
||||||
|
--version show program's version number and exit
|
||||||
|
-c PATH, --config PATH
|
||||||
|
specify configuration file (default: config.json)
|
||||||
|
-d PATH, --datadir PATH
|
||||||
|
path to backtest data
|
||||||
|
-s NAME, --strategy NAME
|
||||||
|
specify strategy class name (default: DefaultStrategy)
|
||||||
|
--strategy-path PATH specify additional strategy lookup path
|
||||||
|
--dynamic-whitelist [INT]
|
||||||
|
dynamically generate and update whitelist based on 24h
|
||||||
|
BaseVolume (default: 20)
|
||||||
|
--db-url PATH Override trades database URL, this is useful if
|
||||||
|
dry_run is enabled or in custom deployments (default:
|
||||||
|
sqlite:///tradesv3.sqlite)
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to use a different config file?
|
||||||
|
The bot allows you to select which config file you want to use. Per
|
||||||
|
default, the bot will load the file `./config.json`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py -c path/far/far/away/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to use --strategy?
|
||||||
|
This parameter will allow you to load your custom strategy class.
|
||||||
|
Per default without `--strategy` or `-s` the bot will load the
|
||||||
|
`DefaultStrategy` included with the bot (`freqtrade/strategy/default_strategy.py`).
|
||||||
|
|
||||||
|
The bot will search your strategy file within `user_data/strategies` and `freqtrade/strategy`.
|
||||||
|
|
||||||
|
To load a strategy, simply pass the class name (e.g.: `CustomStrategy`) in this parameter.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
In `user_data/strategies` you have a file `my_awesome_strategy.py` which has
|
||||||
|
a strategy class called `AwesomeStrategy` to load it:
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py --strategy AwesomeStrategy
|
||||||
|
```
|
||||||
|
|
||||||
|
If the bot does not find your strategy file, it will display in an error
|
||||||
|
message the reason (File not found, or errors in your code).
|
||||||
|
|
||||||
|
Learn more about strategy file in [optimize your bot](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-optimization.md).
|
||||||
|
|
||||||
|
### How to use --strategy-path?
|
||||||
|
This parameter allows you to add an additional strategy lookup path, which gets
|
||||||
|
checked before the default locations (The passed path must be a folder!):
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py --strategy AwesomeStrategy --strategy-path /some/folder
|
||||||
|
```
|
||||||
|
|
||||||
|
#### How to install a strategy?
|
||||||
|
This is very simple. Copy paste your strategy file into the folder
|
||||||
|
`user_data/strategies` or use `--strategy-path`. And voila, the bot is ready to use it.
|
||||||
|
|
||||||
|
### How to use --dynamic-whitelist?
|
||||||
|
Per default `--dynamic-whitelist` will retrieve the 20 currencies based
|
||||||
|
on BaseVolume. This value can be changed when you run the script.
|
||||||
|
|
||||||
|
**By Default**
|
||||||
|
Get the 20 currencies based on BaseVolume.
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py --dynamic-whitelist
|
||||||
|
```
|
||||||
|
|
||||||
|
**Customize the number of currencies to retrieve**
|
||||||
|
Get the 30 currencies based on BaseVolume.
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py --dynamic-whitelist 30
|
||||||
|
```
|
||||||
|
|
||||||
|
**Exception**
|
||||||
|
`--dynamic-whitelist` must be greater than 0. If you enter 0 or a
|
||||||
|
negative value (e.g -2), `--dynamic-whitelist` will use the default
|
||||||
|
value (20).
|
||||||
|
|
||||||
|
### How to use --db-url?
|
||||||
|
When you run the bot in Dry-run mode, per default no transactions are
|
||||||
|
stored in a database. If you want to store your bot actions in a DB
|
||||||
|
using `--db-url`. This can also be used to specify a custom database
|
||||||
|
in production mode. Example command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py -c config.json --db-url sqlite:///tradesv3.dry_run.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Backtesting commands
|
||||||
|
|
||||||
|
Backtesting also uses the config specified via `-c/--config`.
|
||||||
|
|
||||||
|
```
|
||||||
|
usage: main.py backtesting [-h] [-i TICKER_INTERVAL] [--realistic-simulation]
|
||||||
|
[--timerange TIMERANGE] [-l] [-r] [--export EXPORT]
|
||||||
|
[--export-filename EXPORTFILENAME]
|
||||||
|
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
-i TICKER_INTERVAL, --ticker-interval TICKER_INTERVAL
|
||||||
|
specify ticker interval (1m, 5m, 30m, 1h, 1d)
|
||||||
|
--realistic-simulation
|
||||||
|
uses max_open_trades from config to simulate real
|
||||||
|
world limitations
|
||||||
|
--timerange TIMERANGE
|
||||||
|
specify what timerange of data to use.
|
||||||
|
-l, --live using live data
|
||||||
|
-r, --refresh-pairs-cached
|
||||||
|
refresh the pairs files in tests/testdata with the
|
||||||
|
latest data from the exchange. Use it if you want to
|
||||||
|
run your backtesting with up-to-date data.
|
||||||
|
--export EXPORT export backtest results, argument are: trades Example
|
||||||
|
--export=trades
|
||||||
|
--export-filename EXPORTFILENAME
|
||||||
|
Save backtest results to this filename requires
|
||||||
|
--export to be set as well Example --export-
|
||||||
|
filename=backtest_today.json (default: backtest-
|
||||||
|
result.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### How to use --refresh-pairs-cached parameter?
|
||||||
|
The first time your run Backtesting, it will take the pairs you have
|
||||||
|
set in your config file and download data from Bittrex.
|
||||||
|
|
||||||
|
If for any reason you want to update your data set, you use
|
||||||
|
`--refresh-pairs-cached` to force Backtesting to update the data it has.
|
||||||
|
**Use it only if you want to update your data set. You will not be able
|
||||||
|
to come back to the previous version.**
|
||||||
|
|
||||||
|
To test your strategy with latest data, we recommend continuing using
|
||||||
|
the parameter `-l` or `--live`.
|
||||||
|
|
||||||
|
|
||||||
|
## Hyperopt commands
|
||||||
|
|
||||||
|
To optimize your strategy, you can use hyperopt parameter hyperoptimization
|
||||||
|
to find optimal parameter values for your stategy.
|
||||||
|
|
||||||
|
```
|
||||||
|
usage: main.py hyperopt [-h] [-i TICKER_INTERVAL] [--realistic-simulation]
|
||||||
|
[--timerange TIMERANGE] [-e INT]
|
||||||
|
[-s {all,buy,roi,stoploss} [{all,buy,roi,stoploss} ...]]
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-h, --help show this help message and exit
|
||||||
|
-i TICKER_INTERVAL, --ticker-interval TICKER_INTERVAL
|
||||||
|
specify ticker interval (1m, 5m, 30m, 1h, 1d)
|
||||||
|
--realistic-simulation
|
||||||
|
uses max_open_trades from config to simulate real
|
||||||
|
world limitations
|
||||||
|
--timerange TIMERANGE specify what timerange of data to use.
|
||||||
|
-e INT, --epochs INT specify number of epochs (default: 100)
|
||||||
|
-s {all,buy,roi,stoploss} [{all,buy,roi,stoploss} ...], --spaces {all,buy,roi,stoploss} [{all,buy,roi,stoploss} ...]
|
||||||
|
Specify which parameters to hyperopt. Space separate
|
||||||
|
list. Default: all
|
||||||
|
```
|
||||||
|
|
||||||
|
## A parameter missing in the configuration?
|
||||||
|
All parameters for `main.py`, `backtesting`, `hyperopt` are referenced
|
||||||
|
in [misc.py](https://github.com/freqtrade/freqtrade/blob/develop/freqtrade/misc.py#L84)
|
||||||
|
|
||||||
|
## Next step
|
||||||
|
The optimal strategy of the bot will change with time depending of the
|
||||||
|
market trends. The next step is to
|
||||||
|
[optimize your bot](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-optimization.md).
|
||||||
169
docs/configuration.md
Normal file
169
docs/configuration.md
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
# Configure the bot
|
||||||
|
This page explains how to configure your `config.json` file.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Bot commands](#bot-commands)
|
||||||
|
- [Backtesting commands](#backtesting-commands)
|
||||||
|
- [Hyperopt commands](#hyperopt-commands)
|
||||||
|
|
||||||
|
## Setup config.json
|
||||||
|
We recommend to copy and use the `config.json.example` as a template
|
||||||
|
for your bot configuration.
|
||||||
|
|
||||||
|
The table below will list all configuration parameters.
|
||||||
|
|
||||||
|
| Command | Default | Mandatory | Description |
|
||||||
|
|----------|---------|----------|-------------|
|
||||||
|
| `max_open_trades` | 3 | Yes | Number of trades open your bot will have.
|
||||||
|
| `stake_currency` | BTC | Yes | Crypto-currency used for trading.
|
||||||
|
| `stake_amount` | 0.05 | Yes | Amount of crypto-currency your bot will use for each trade. Per default, the bot will use (0.05 BTC x 3) = 0.15 BTC in total will be always engaged. Set it to 'unlimited' to allow the bot to use all avaliable balance.
|
||||||
|
| `ticker_interval` | [1m, 5m, 30m, 1h, 1d] | No | The ticker interval to use (1min, 5 min, 30 min, 1 hour or 1 day). Default is 5 minutes
|
||||||
|
| `fiat_display_currency` | USD | Yes | Fiat currency used to show your profits. More information below.
|
||||||
|
| `dry_run` | true | Yes | Define if the bot must be in Dry-run or production mode.
|
||||||
|
| `minimal_roi` | See below | No | Set the threshold in percent the bot will use to sell a trade. More information below. If set, this parameter will override `minimal_roi` from your strategy file.
|
||||||
|
| `stoploss` | -0.10 | No | Value of the stoploss in percent used by the bot. More information below. If set, this parameter will override `stoploss` from your strategy file.
|
||||||
|
| `unfilledtimeout` | 0 | No | How long (in minutes) the bot will wait for an unfilled order to complete, after which the order will be cancelled.
|
||||||
|
| `bid_strategy.ask_last_balance` | 0.0 | Yes | Set the bidding price. More information below.
|
||||||
|
| `exchange.name` | bittrex | Yes | Name of the exchange class to use. [List below](#user-content-what-values-for-exchangename).
|
||||||
|
| `exchange.key` | key | No | API key to use for the exchange. Only required when you are in production mode.
|
||||||
|
| `exchange.secret` | secret | No | API secret to use for the exchange. Only required when you are in production mode.
|
||||||
|
| `exchange.pair_whitelist` | [] | No | List of currency to use by the bot. Can be overrided with `--dynamic-whitelist` param.
|
||||||
|
| `exchange.pair_blacklist` | [] | No | List of currency the bot must avoid. Useful when using `--dynamic-whitelist` param.
|
||||||
|
| `experimental.use_sell_signal` | false | No | Use your sell strategy in addition of the `minimal_roi`.
|
||||||
|
| `experimental.sell_profit_only` | false | No | waits until you have made a positive profit before taking a sell decision.
|
||||||
|
| `experimental.ignore_roi_if_buy_signal` | false | No | Does not sell if the buy-signal is still active. Takes preference over `minimal_roi` and `use_sell_signal`
|
||||||
|
| `telegram.enabled` | true | Yes | Enable or not the usage of Telegram.
|
||||||
|
| `telegram.token` | token | No | Your Telegram bot token. Only required if `telegram.enabled` is `true`.
|
||||||
|
| `telegram.chat_id` | chat_id | No | Your personal Telegram account id. Only required if `telegram.enabled` is `true`.
|
||||||
|
| `db_url` | `sqlite:///tradesv3.sqlite` | No | Declares database URL to use. NOTE: This defaults to `sqlite://` if `dry_run` is `True`.
|
||||||
|
| `initial_state` | running | No | Defines the initial application state. More information below.
|
||||||
|
| `strategy` | DefaultStrategy | No | Defines Strategy class to use.
|
||||||
|
| `strategy_path` | null | No | Adds an additional strategy lookup path (must be a folder).
|
||||||
|
| `internals.process_throttle_secs` | 5 | Yes | Set the process throttle. Value in second.
|
||||||
|
|
||||||
|
The definition of each config parameters is in
|
||||||
|
[misc.py](https://github.com/freqtrade/freqtrade/blob/develop/freqtrade/misc.py#L205).
|
||||||
|
|
||||||
|
### Understand stake_amount
|
||||||
|
`stake_amount` is an amount of crypto-currency your bot will use for each trade.
|
||||||
|
The minimal value is 0.0005. If there is not enough crypto-currency in
|
||||||
|
the account an exception is generated.
|
||||||
|
To allow the bot to trade all the avaliable `stake_currency` in your account set `stake_amount` = `unlimited`.
|
||||||
|
In this case a trade amount is calclulated as `currency_balanse / (max_open_trades - current_open_trades)`.
|
||||||
|
|
||||||
|
### Understand minimal_roi
|
||||||
|
`minimal_roi` is a JSON object where the key is a duration
|
||||||
|
in minutes and the value is the minimum ROI in percent.
|
||||||
|
See the example below:
|
||||||
|
```
|
||||||
|
"minimal_roi": {
|
||||||
|
"40": 0.0, # Sell after 40 minutes if the profit is not negative
|
||||||
|
"30": 0.01, # Sell after 30 minutes if there is at least 1% profit
|
||||||
|
"20": 0.02, # Sell after 20 minutes if there is at least 2% profit
|
||||||
|
"0": 0.04 # Sell immediately if there is at least 4% profit
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
Most of the strategy files already include the optimal `minimal_roi`
|
||||||
|
value. This parameter is optional. If you use it, it will take over the
|
||||||
|
`minimal_roi` value from the strategy file.
|
||||||
|
|
||||||
|
### Understand stoploss
|
||||||
|
`stoploss` is loss in percentage that should trigger a sale.
|
||||||
|
For example value `-0.10` will cause immediate sell if the
|
||||||
|
profit dips below -10% for a given trade. This parameter is optional.
|
||||||
|
|
||||||
|
Most of the strategy files already include the optimal `stoploss`
|
||||||
|
value. This parameter is optional. If you use it, it will take over the
|
||||||
|
`stoploss` value from the strategy file.
|
||||||
|
|
||||||
|
### Understand initial_state
|
||||||
|
`initial_state` is an optional field that defines the initial application state.
|
||||||
|
Possible values are `running` or `stopped`. (default=`running`)
|
||||||
|
If the value is `stopped` the bot has to be started with `/start` first.
|
||||||
|
|
||||||
|
### Understand process_throttle_secs
|
||||||
|
`process_throttle_secs` is an optional field that defines in seconds how long the bot should wait
|
||||||
|
before asking the strategy if we should buy or a sell an asset. After each wait period, the strategy is asked again for
|
||||||
|
every opened trade wether or not we should sell, and for all the remaining pairs (either the dynamic list of pairs or
|
||||||
|
the static list of pairs) if we should buy.
|
||||||
|
|
||||||
|
### Understand ask_last_balance
|
||||||
|
`ask_last_balance` sets the bidding price. Value `0.0` will use `ask` price, `1.0` will
|
||||||
|
use the `last` price and values between those interpolate between ask and last
|
||||||
|
price. Using `ask` price will guarantee quick success in bid, but bot will also
|
||||||
|
end up paying more then would probably have been necessary.
|
||||||
|
|
||||||
|
### What values for exchange.name?
|
||||||
|
Freqtrade is based on [CCXT library](https://github.com/ccxt/ccxt) that supports 115 cryptocurrency
|
||||||
|
exchange markets and trading APIs. The complete up-to-date list can be found in the
|
||||||
|
[CCXT repo homepage](https://github.com/ccxt/ccxt/tree/master/python). However, the bot was tested
|
||||||
|
with only Bittrex and Binance.
|
||||||
|
|
||||||
|
The bot was tested with the following exchanges:
|
||||||
|
- [Bittrex](https://bittrex.com/): "bittrex"
|
||||||
|
- [Binance](https://www.binance.com/): "binance"
|
||||||
|
|
||||||
|
Feel free to test other exchanges and submit your PR to improve the bot.
|
||||||
|
|
||||||
|
### What values for fiat_display_currency?
|
||||||
|
`fiat_display_currency` set the base currency to use for the conversion from coin to fiat in Telegram.
|
||||||
|
The valid values are: "AUD", "BRL", "CAD", "CHF", "CLP", "CNY", "CZK", "DKK", "EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "JPY", "KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PKR", "PLN", "RUB", "SEK", "SGD", "THB", "TRY", "TWD", "ZAR", "USD".
|
||||||
|
In addition to central bank currencies, a range of cryto currencies are supported.
|
||||||
|
The valid values are: "BTC", "ETH", "XRP", "LTC", "BCH", "USDT".
|
||||||
|
|
||||||
|
## Switch to dry-run mode
|
||||||
|
We recommend starting the bot in dry-run mode to see how your bot will
|
||||||
|
behave and how is the performance of your strategy. In Dry-run mode the
|
||||||
|
bot does not engage your money. It only runs a live simulation without
|
||||||
|
creating trades.
|
||||||
|
|
||||||
|
### To switch your bot in Dry-run mode:
|
||||||
|
1. Edit your `config.json` file
|
||||||
|
2. Switch dry-run to true and specify db_url for a persistent db
|
||||||
|
```json
|
||||||
|
"dry_run": true,
|
||||||
|
"db_url": "sqlite///tradesv3.dryrun.sqlite",
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Remove your Exchange API key (change them by fake api credentials)
|
||||||
|
```json
|
||||||
|
"exchange": {
|
||||||
|
"name": "bittrex",
|
||||||
|
"key": "key",
|
||||||
|
"secret": "secret",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Once you will be happy with your bot performance, you can switch it to
|
||||||
|
production mode.
|
||||||
|
|
||||||
|
## Switch to production mode
|
||||||
|
In production mode, the bot will engage your money. Be careful a wrong
|
||||||
|
strategy can lose all your money. Be aware of what you are doing when
|
||||||
|
you run it in production mode.
|
||||||
|
|
||||||
|
### To switch your bot in production mode:
|
||||||
|
1. Edit your `config.json` file
|
||||||
|
|
||||||
|
2. Switch dry-run to false and don't forget to adapt your database URL if set
|
||||||
|
```json
|
||||||
|
"dry_run": false,
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Insert your Exchange API key (change them by fake api keys)
|
||||||
|
```json
|
||||||
|
"exchange": {
|
||||||
|
"name": "bittrex",
|
||||||
|
"key": "af8ddd35195e9dc500b9a6f799f6f5c93d89193b",
|
||||||
|
"secret": "08a9dc6db3d7b53e1acebd9275677f4b0a04f1a5",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
If you have not your Bittrex API key yet,
|
||||||
|
[see our tutorial](https://github.com/freqtrade/freqtrade/blob/develop/docs/pre-requisite.md).
|
||||||
|
|
||||||
|
## Next step
|
||||||
|
Now you have configured your config.json, the next step is to
|
||||||
|
[start your bot](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-usage.md).
|
||||||
71
docs/faq.md
Normal file
71
docs/faq.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# freqtrade FAQ
|
||||||
|
|
||||||
|
#### I have waited 5 minutes, why hasn't the bot made any trades yet?!
|
||||||
|
|
||||||
|
Depending on the buy strategy, the amount of whitelisted coins, the
|
||||||
|
situation of the market etc, it can take up to hours to find good entry
|
||||||
|
position for a trade. Be patient!
|
||||||
|
|
||||||
|
#### I have made 12 trades already, why is my total profit negative?!
|
||||||
|
|
||||||
|
I understand your disappointment but unfortunately 12 trades is just
|
||||||
|
not enough to say anything. If you run backtesting, you can see that our
|
||||||
|
current algorithm does leave you on the plus side, but that is after
|
||||||
|
thousands of trades and even there, you will be left with losses on
|
||||||
|
specific coins that you have traded tens if not hundreds of times. We
|
||||||
|
of course constantly aim to improve the bot but it will _always_ be a
|
||||||
|
gamble, which should leave you with modest wins on monthly basis but
|
||||||
|
you can't say much from few trades.
|
||||||
|
|
||||||
|
#### I’d like to change the stake amount. Can I just stop the bot with
|
||||||
|
/stop and then change the config.json and run it again?
|
||||||
|
|
||||||
|
Not quite. Trades are persisted to a database but the configuration is
|
||||||
|
currently only read when the bot is killed and restarted. `/stop` more
|
||||||
|
like pauses. You can stop your bot, adjust settings and start it again.
|
||||||
|
|
||||||
|
#### I want to improve the bot with a new strategy
|
||||||
|
|
||||||
|
That's great. We have a nice backtesting and hyperoptimizing setup. See
|
||||||
|
the tutorial [here|Testing-new-strategies-with-Hyperopt](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-usage.md#hyperopt-commands).
|
||||||
|
|
||||||
|
#### Is there a setting to only SELL the coins being held and not
|
||||||
|
perform anymore BUYS?
|
||||||
|
|
||||||
|
You can use the `/forcesell all` command from Telegram.
|
||||||
|
|
||||||
|
### How many epoch do I need to get a good Hyperopt result?
|
||||||
|
Per default Hyperopts without `-e` or `--epochs` parameter will only
|
||||||
|
run 100 epochs, means 100 evals of your triggers, guards, .... Too few
|
||||||
|
to find a great result (unless if you are very lucky), so you probably
|
||||||
|
have to run it for 10.000 or more. But it will take an eternity to
|
||||||
|
compute.
|
||||||
|
|
||||||
|
We recommend you to run it at least 10.000 epochs:
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py hyperopt -e 10000
|
||||||
|
```
|
||||||
|
|
||||||
|
or if you want intermediate result to see
|
||||||
|
```bash
|
||||||
|
for i in {1..100}; do python3 ./freqtrade/main.py hyperopt -e 100; done
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Why it is so long to run hyperopt?
|
||||||
|
Finding a great Hyperopt results takes time.
|
||||||
|
|
||||||
|
If you wonder why it takes a while to find great hyperopt results
|
||||||
|
|
||||||
|
This answer was written during the under the release 0.15.1, when we had
|
||||||
|
:
|
||||||
|
- 8 triggers
|
||||||
|
- 9 guards: let's say we evaluate even 10 values from each
|
||||||
|
- 1 stoploss calculation: let's say we want 10 values from that too to
|
||||||
|
be evaluated
|
||||||
|
|
||||||
|
The following calculation is still very rough and not very precise
|
||||||
|
but it will give the idea. With only these triggers and guards there is
|
||||||
|
already 8*10^9*10 evaluations. A roughly total of 80 billion evals.
|
||||||
|
Did you run 100 000 evals? Congrats, you've done roughly 1 / 100 000 th
|
||||||
|
of the search space.
|
||||||
|
|
||||||
279
docs/hyperopt.md
Normal file
279
docs/hyperopt.md
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
# Hyperopt
|
||||||
|
This page explains how to tune your strategy by finding the optimal
|
||||||
|
parameters with Hyperopt.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Prepare your Hyperopt](#prepare-hyperopt)
|
||||||
|
- [1. Configure your Guards and Triggers](#1-configure-your-guards-and-triggers)
|
||||||
|
- [2. Update the hyperopt config file](#2-update-the-hyperopt-config-file)
|
||||||
|
- [Advanced Hyperopt notions](#advanced-notions)
|
||||||
|
- [Understand the Guards and Triggers](#understand-the-guards-and-triggers)
|
||||||
|
- [Execute Hyperopt](#execute-hyperopt)
|
||||||
|
- [Understand the hyperopts result](#understand-the-backtesting-result)
|
||||||
|
|
||||||
|
## Prepare Hyperopt
|
||||||
|
Before we start digging in Hyperopt, we recommend you to take a look at
|
||||||
|
your strategy file located into [user_data/strategies/](https://github.com/freqtrade/freqtrade/blob/develop/user_data/strategies/test_strategy.py)
|
||||||
|
|
||||||
|
### 1. Configure your Guards and Triggers
|
||||||
|
There are two places you need to change in your strategy file to add a
|
||||||
|
new buy strategy for testing:
|
||||||
|
- Inside [populate_buy_trend()](https://github.com/freqtrade/freqtrade/blob/develop/user_data/strategies/test_strategy.py#L278-L294).
|
||||||
|
- Inside [hyperopt_space()](https://github.com/freqtrade/freqtrade/blob/develop/user_data/strategies/test_strategy.py#L244-L297) known as `SPACE`.
|
||||||
|
|
||||||
|
There you have two different type of indicators: 1. `guards` and 2.
|
||||||
|
`triggers`.
|
||||||
|
1. Guards are conditions like "never buy if ADX < 10", or never buy if
|
||||||
|
current price is over EMA10.
|
||||||
|
2. Triggers are ones that actually trigger buy in specific moment, like
|
||||||
|
"buy when EMA5 crosses over EMA10" or buy when close price touches lower
|
||||||
|
bollinger band.
|
||||||
|
|
||||||
|
HyperOpt will, for each eval round, pick just ONE trigger, and possibly
|
||||||
|
multiple guards. So that the constructed strategy will be something like
|
||||||
|
"*buy exactly when close price touches lower bollinger band, BUT only if
|
||||||
|
ADX > 10*".
|
||||||
|
|
||||||
|
|
||||||
|
If you have updated the buy strategy, means change the content of
|
||||||
|
`populate_buy_trend()` method you have to update the `guards` and
|
||||||
|
`triggers` hyperopts must used.
|
||||||
|
|
||||||
|
As for an example if your `populate_buy_trend()` method is:
|
||||||
|
```python
|
||||||
|
def populate_buy_trend(dataframe: DataFrame) -> DataFrame:
|
||||||
|
dataframe.loc[
|
||||||
|
(dataframe['rsi'] < 35) &
|
||||||
|
(dataframe['adx'] > 65),
|
||||||
|
'buy'] = 1
|
||||||
|
|
||||||
|
return dataframe
|
||||||
|
```
|
||||||
|
|
||||||
|
Your hyperopt file must contain `guards` to find the right value for
|
||||||
|
`(dataframe['adx'] > 65)` & and `(dataframe['plus_di'] > 0.5)`. That
|
||||||
|
means you will need to enable/disable triggers.
|
||||||
|
|
||||||
|
In our case the `SPACE` and `populate_buy_trend` in your strategy file
|
||||||
|
will look like:
|
||||||
|
```python
|
||||||
|
space = {
|
||||||
|
'rsi': hp.choice('rsi', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True, 'value': hp.quniform('rsi-value', 20, 40, 1)}
|
||||||
|
]),
|
||||||
|
'adx': hp.choice('adx', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True, 'value': hp.quniform('adx-value', 15, 50, 1)}
|
||||||
|
]),
|
||||||
|
'trigger': hp.choice('trigger', [
|
||||||
|
{'type': 'lower_bb'},
|
||||||
|
{'type': 'faststoch10'},
|
||||||
|
{'type': 'ao_cross_zero'},
|
||||||
|
{'type': 'ema5_cross_ema10'},
|
||||||
|
{'type': 'macd_cross_signal'},
|
||||||
|
{'type': 'sar_reversal'},
|
||||||
|
{'type': 'stochf_cross'},
|
||||||
|
{'type': 'ht_sine'},
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
def populate_buy_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||||
|
conditions = []
|
||||||
|
# GUARDS AND TRENDS
|
||||||
|
if params['adx']['enabled']:
|
||||||
|
conditions.append(dataframe['adx'] > params['adx']['value'])
|
||||||
|
if params['rsi']['enabled']:
|
||||||
|
conditions.append(dataframe['rsi'] < params['rsi']['value'])
|
||||||
|
|
||||||
|
# TRIGGERS
|
||||||
|
triggers = {
|
||||||
|
'lower_bb': dataframe['tema'] <= dataframe['blower'],
|
||||||
|
'faststoch10': (crossed_above(dataframe['fastd'], 10.0)),
|
||||||
|
'ao_cross_zero': (crossed_above(dataframe['ao'], 0.0)),
|
||||||
|
'ema5_cross_ema10': (crossed_above(dataframe['ema5'], dataframe['ema10'])),
|
||||||
|
'macd_cross_signal': (crossed_above(dataframe['macd'], dataframe['macdsignal'])),
|
||||||
|
'sar_reversal': (crossed_above(dataframe['close'], dataframe['sar'])),
|
||||||
|
'stochf_cross': (crossed_above(dataframe['fastk'], dataframe['fastd'])),
|
||||||
|
'ht_sine': (crossed_above(dataframe['htleadsine'], dataframe['htsine'])),
|
||||||
|
}
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### 2. Update the hyperopt config file
|
||||||
|
Hyperopt is using a dedicated config file. Currently hyperopt
|
||||||
|
cannot use your config file. It is also made on purpose to allow you
|
||||||
|
testing your strategy with different configurations.
|
||||||
|
|
||||||
|
The Hyperopt configuration is located in
|
||||||
|
[user_data/hyperopt_conf.py](https://github.com/freqtrade/freqtrade/blob/develop/user_data/hyperopt_conf.py).
|
||||||
|
|
||||||
|
|
||||||
|
## Advanced notions
|
||||||
|
### Understand the Guards and Triggers
|
||||||
|
When you need to add the new guards and triggers to be hyperopt
|
||||||
|
parameters, you do this by adding them into the [hyperopt_space()](https://github.com/freqtrade/freqtrade/blob/develop/user_data/strategies/test_strategy.py#L244-L297).
|
||||||
|
|
||||||
|
If it's a trigger, you add one line to the 'trigger' choice group and that's it.
|
||||||
|
|
||||||
|
If it's a guard, you will add a line like this:
|
||||||
|
```
|
||||||
|
'rsi': hp.choice('rsi', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True, 'value': hp.quniform('rsi-value', 20, 40, 1)}
|
||||||
|
]),
|
||||||
|
```
|
||||||
|
This says, "*one of the guards is RSI, it can have two values, enabled or
|
||||||
|
disabled. If it is enabled, try different values for it between 20 and 40*".
|
||||||
|
|
||||||
|
So, the part of the strategy builder using the above setting looks like
|
||||||
|
this:
|
||||||
|
|
||||||
|
```
|
||||||
|
if params['rsi']['enabled']:
|
||||||
|
conditions.append(dataframe['rsi'] < params['rsi']['value'])
|
||||||
|
```
|
||||||
|
|
||||||
|
It checks if Hyperopt wants the RSI guard to be enabled for this
|
||||||
|
round `params['rsi']['enabled']` and if it is, then it will add a
|
||||||
|
condition that says RSI must be smaller than the value hyperopt picked
|
||||||
|
for this evaluation, which is given in the `params['rsi']['value']`.
|
||||||
|
|
||||||
|
That's it. Now you can add new parts of strategies to Hyperopt and it
|
||||||
|
will try all the combinations with all different values in the search
|
||||||
|
for best working algo.
|
||||||
|
|
||||||
|
|
||||||
|
### Add a new Indicators
|
||||||
|
If you want to test an indicator that isn't used by the bot currently,
|
||||||
|
you need to add it to the `populate_indicators()` method in `hyperopt.py`.
|
||||||
|
|
||||||
|
## Execute Hyperopt
|
||||||
|
Once you have updated your hyperopt configuration you can run it.
|
||||||
|
Because hyperopt tries a lot of combination to find the best parameters
|
||||||
|
it will take time you will have the result (more than 30 mins).
|
||||||
|
|
||||||
|
We strongly recommend to use `screen` to prevent any connection loss.
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py -c config.json hyperopt -e 5000
|
||||||
|
```
|
||||||
|
|
||||||
|
The `-e` flag will set how many evaluations hyperopt will do. We recommend
|
||||||
|
running at least several thousand evaluations.
|
||||||
|
|
||||||
|
### Execute hyperopt with different ticker-data source
|
||||||
|
If you would like to hyperopt parameters using an alternate ticker data that
|
||||||
|
you have on-disk, use the `--datadir PATH` option. Default hyperopt will
|
||||||
|
use data from directory `user_data/data`.
|
||||||
|
|
||||||
|
### Running hyperopt with smaller testset
|
||||||
|
Use the `--timeperiod` argument to change how much of the testset
|
||||||
|
you want to use. The last N ticks/timeframes will be used.
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 ./freqtrade/main.py hyperopt --timeperiod -200
|
||||||
|
```
|
||||||
|
|
||||||
|
### Running hyperopt with smaller search space
|
||||||
|
Use the `--spaces` argument to limit the search space used by hyperopt.
|
||||||
|
Letting Hyperopt optimize everything is a huuuuge search space. Often it
|
||||||
|
might make more sense to start by just searching for initial buy algorithm.
|
||||||
|
Or maybe you just want to optimize your stoploss or roi table for that awesome
|
||||||
|
new buy strategy you have.
|
||||||
|
|
||||||
|
Legal values are:
|
||||||
|
|
||||||
|
- `all`: optimize everything
|
||||||
|
- `buy`: just search for a new buy strategy
|
||||||
|
- `roi`: just optimize the minimal profit table for your strategy
|
||||||
|
- `stoploss`: search for the best stoploss value
|
||||||
|
- space-separated list of any of the above values for example `--spaces roi stoploss`
|
||||||
|
|
||||||
|
## Understand the hyperopts result
|
||||||
|
Once Hyperopt is completed you can use the result to adding new buy
|
||||||
|
signal. Given following result from hyperopt:
|
||||||
|
```
|
||||||
|
Best parameters:
|
||||||
|
{
|
||||||
|
"adx": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 15.0
|
||||||
|
},
|
||||||
|
"fastd": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 40.0
|
||||||
|
},
|
||||||
|
"green_candle": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"mfi": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"over_sar": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"rsi": {
|
||||||
|
"enabled": true,
|
||||||
|
"value": 37.0
|
||||||
|
},
|
||||||
|
"trigger": {
|
||||||
|
"type": "lower_bb"
|
||||||
|
},
|
||||||
|
"uptrend_long_ema": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"uptrend_short_ema": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"uptrend_sma": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Best Result:
|
||||||
|
2197 trades. Avg profit 1.84%. Total profit 0.79367541 BTC. Avg duration 241.0 mins.
|
||||||
|
```
|
||||||
|
|
||||||
|
You should understand this result like:
|
||||||
|
- You should **consider** the guard "adx" (`"adx"` is `"enabled": true`)
|
||||||
|
and the best value is `15.0` (`"value": 15.0,`)
|
||||||
|
- You should **consider** the guard "fastd" (`"fastd"` is `"enabled":
|
||||||
|
true`) and the best value is `40.0` (`"value": 40.0,`)
|
||||||
|
- You should **consider** to enable the guard "green_candle"
|
||||||
|
(`"green_candle"` is `"enabled": true`) but this guards as no
|
||||||
|
customizable value.
|
||||||
|
- You should **ignore** the guard "mfi" (`"mfi"` is `"enabled": false`)
|
||||||
|
- and so on...
|
||||||
|
|
||||||
|
You have to look inside your strategy file into `buy_strategy_generator()`
|
||||||
|
method, what those values match to.
|
||||||
|
|
||||||
|
So for example you had `adx:` with the `value: 15.0` so we would look
|
||||||
|
at `adx`-block, that translates to the following code block:
|
||||||
|
```
|
||||||
|
(dataframe['adx'] > 15.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Translating your whole hyperopt result to as the new buy-signal
|
||||||
|
would be the following:
|
||||||
|
```
|
||||||
|
def populate_buy_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||||
|
dataframe.loc[
|
||||||
|
(
|
||||||
|
(dataframe['adx'] > 15.0) & # adx-value
|
||||||
|
(dataframe['fastd'] < 40.0) & # fastd-value
|
||||||
|
(dataframe['close'] > dataframe['open']) & # green_candle
|
||||||
|
(dataframe['rsi'] < 37.0) & # rsi-value
|
||||||
|
(dataframe['ema50'] > dataframe['ema100']) # uptrend_long_ema
|
||||||
|
),
|
||||||
|
'buy'] = 1
|
||||||
|
return dataframe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next step
|
||||||
|
Now you have a perfect bot and want to control it from Telegram. Your
|
||||||
|
next step is to learn the [Telegram usage](https://github.com/freqtrade/freqtrade/blob/develop/docs/telegram-usage.md).
|
||||||
32
docs/index.md
Normal file
32
docs/index.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# freqtrade documentation
|
||||||
|
Welcome to freqtrade documentation. Please feel free to contribute to
|
||||||
|
this documentation if you see it became outdated by sending us a
|
||||||
|
Pull-request. Do not hesitate to reach us on
|
||||||
|
[Slack](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE)
|
||||||
|
if you do not find the answer to your questions.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Pre-requisite](https://github.com/freqtrade/freqtrade/blob/develop/docs/pre-requisite.md)
|
||||||
|
- [Setup your Bittrex account](https://github.com/freqtrade/freqtrade/blob/develop/docs/pre-requisite.md#setup-your-bittrex-account)
|
||||||
|
- [Setup your Telegram bot](https://github.com/freqtrade/freqtrade/blob/develop/docs/pre-requisite.md#setup-your-telegram-bot)
|
||||||
|
- [Bot Installation](https://github.com/freqtrade/freqtrade/blob/develop/docs/installation.md)
|
||||||
|
- [Install with Docker (all platforms)](https://github.com/freqtrade/freqtrade/blob/develop/docs/installation.md#docker)
|
||||||
|
- [Install on Linux Ubuntu](https://github.com/freqtrade/freqtrade/blob/develop/docs/installation.md#21-linux---ubuntu-1604)
|
||||||
|
- [Install on MacOS](https://github.com/freqtrade/freqtrade/blob/develop/docs/installation.md#23-macos-installation)
|
||||||
|
- [Install on Windows](https://github.com/freqtrade/freqtrade/blob/develop/docs/installation.md#windows)
|
||||||
|
- [Bot Configuration](https://github.com/freqtrade/freqtrade/blob/develop/docs/configuration.md)
|
||||||
|
- [Bot usage (Start your bot)](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-usage.md)
|
||||||
|
- [Bot commands](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-usage.md#bot-commands)
|
||||||
|
- [Backtesting commands](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-usage.md#backtesting-commands)
|
||||||
|
- [Hyperopt commands](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-usage.md#hyperopt-commands)
|
||||||
|
- [Bot Optimization](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-optimization.md)
|
||||||
|
- [Change your strategy](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-optimization.md#change-your-strategy)
|
||||||
|
- [Add more Indicator](https://github.com/freqtrade/freqtrade/blob/develop/docs/bot-optimization.md#add-more-indicator)
|
||||||
|
- [Test your strategy with Backtesting](https://github.com/freqtrade/freqtrade/blob/develop/docs/backtesting.md)
|
||||||
|
- [Find optimal parameters with Hyperopt](https://github.com/freqtrade/freqtrade/blob/develop/docs/hyperopt.md)
|
||||||
|
- [Control the bot with telegram](https://github.com/freqtrade/freqtrade/blob/develop/docs/telegram-usage.md)
|
||||||
|
- [Contribute to the project](https://github.com/freqtrade/freqtrade/blob/develop/CONTRIBUTING.md)
|
||||||
|
- [How to contribute](https://github.com/freqtrade/freqtrade/blob/develop/CONTRIBUTING.md)
|
||||||
|
- [Run tests & Check PEP8 compliance](https://github.com/freqtrade/freqtrade/blob/develop/CONTRIBUTING.md)
|
||||||
|
- [FAQ](https://github.com/freqtrade/freqtrade/blob/develop/docs/faq.md)
|
||||||
|
- [SQL cheatsheet](https://github.com/freqtrade/freqtrade/blob/develop/docs/sql_cheatsheet.md)
|
||||||
387
docs/installation.md
Normal file
387
docs/installation.md
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
# Installation
|
||||||
|
|
||||||
|
This page explains how to prepare your environment for running the bot.
|
||||||
|
|
||||||
|
To understand how to set up the bot please read the [Bot Configuration](https://github.com/freqtrade/freqtrade/blob/develop/docs/configuration.md) page.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
* [Table of Contents](#table-of-contents)
|
||||||
|
* [Easy Installation - Linux Script](#easy-installation---linux-script)
|
||||||
|
* [Manual installation](#manual-installation)
|
||||||
|
* [Automatic Installation - Docker](#automatic-installation---docker)
|
||||||
|
* [Custom Linux MacOS Installation](#custom-installation)
|
||||||
|
- [Requirements](#requirements)
|
||||||
|
- [Linux - Ubuntu 16.04](#linux---ubuntu-1604)
|
||||||
|
- [MacOS](#macos)
|
||||||
|
- [Setup Config and virtual env](#setup-config-and-virtual-env)
|
||||||
|
* [Windows](#windows)
|
||||||
|
|
||||||
|
<!-- /TOC -->
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
## Easy Installation - Linux Script
|
||||||
|
|
||||||
|
If you are on Debian, Ubuntu or MacOS a freqtrade provides a script to Install, Update, Configure, and Reset your bot.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
$ ./setup.sh
|
||||||
|
usage:
|
||||||
|
-i,--install Install freqtrade from scratch
|
||||||
|
-u,--update Command git pull to update.
|
||||||
|
-r,--reset Hard reset your develop/master branch.
|
||||||
|
-c,--config Easy config generator (Will override your existing file).
|
||||||
|
```
|
||||||
|
|
||||||
|
### --install
|
||||||
|
|
||||||
|
This script will install everything you need to run the bot:
|
||||||
|
|
||||||
|
* Mandatory software as: `Python3`, `ta-lib`, `wget`
|
||||||
|
* Setup your virtualenv
|
||||||
|
* Configure your `config.json` file
|
||||||
|
|
||||||
|
This script is a combination of `install script` `--reset`, `--config`
|
||||||
|
|
||||||
|
### --update
|
||||||
|
|
||||||
|
Update parameter will pull the last version of your current branch and update your virtualenv.
|
||||||
|
|
||||||
|
### --reset
|
||||||
|
|
||||||
|
Reset parameter will hard reset your branch (only if you are on `master` or `develop`) and recreate your virtualenv.
|
||||||
|
|
||||||
|
### --config
|
||||||
|
|
||||||
|
Config parameter is a `config.json` configurator. This script will ask you questions to setup your bot and create your `config.json`.
|
||||||
|
|
||||||
|
|
||||||
|
## Manual installation - Linux/MacOS
|
||||||
|
The following steps are made for Linux/MacOS environment
|
||||||
|
|
||||||
|
**1. Clone the repo**
|
||||||
|
```bash
|
||||||
|
git clone git@github.com:freqtrade/freqtrade.git
|
||||||
|
git checkout develop
|
||||||
|
cd freqtrade
|
||||||
|
```
|
||||||
|
**2. Create the config file**
|
||||||
|
Switch `"dry_run": true,`
|
||||||
|
```bash
|
||||||
|
cp config.json.example config.json
|
||||||
|
vi config.json
|
||||||
|
```
|
||||||
|
**3. Build your docker image and run it**
|
||||||
|
```bash
|
||||||
|
docker build -t freqtrade .
|
||||||
|
docker run --rm -v /etc/localtime:/etc/localtime:ro -v `pwd`/config.json:/freqtrade/config.json -it freqtrade
|
||||||
|
```
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
## Automatic Installation - Docker
|
||||||
|
|
||||||
|
Start by downloading Docker for your platform:
|
||||||
|
|
||||||
|
* [Mac](https://www.docker.com/products/docker#/mac)
|
||||||
|
* [Windows](https://www.docker.com/products/docker#/windows)
|
||||||
|
* [Linux](https://www.docker.com/products/docker#/linux)
|
||||||
|
|
||||||
|
Once you have Docker installed, simply create the config file (e.g. `config.json`) and then create a Docker image for `freqtrade` using the Dockerfile in this repo.
|
||||||
|
|
||||||
|
### 1. Prepare the Bot
|
||||||
|
|
||||||
|
#### 1.1. Clone the git repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/freqtrade/freqtrade.git
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.2. (Optional) Checkout the develop branch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.3. Go into the new directory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd freqtrade
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1.4. Copy `config.json.example` to `config.json`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -n config.json.example config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
> To edit the config please refer to the [Bot Configuration](https://github.com/freqtrade/freqtrade/blob/develop/docs/configuration.md) page.
|
||||||
|
|
||||||
|
#### 1.5. Create your database file *(optional - the bot will create it if it is missing)*
|
||||||
|
|
||||||
|
Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
touch tradesv3.sqlite
|
||||||
|
````
|
||||||
|
|
||||||
|
Dry-Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
touch tradesv3.dryrun.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build the Docker image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd freqtrade
|
||||||
|
docker build -t freqtrade .
|
||||||
|
```
|
||||||
|
|
||||||
|
For security reasons, your configuration file will not be included in the image, you will need to bind mount it. It is also advised to bind mount an SQLite database file (see the "5. Run a restartable docker image" section) to keep it between updates.
|
||||||
|
|
||||||
|
### 3. Verify the Docker image
|
||||||
|
|
||||||
|
After the build process you can verify that the image was created with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker images
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Run the Docker image
|
||||||
|
|
||||||
|
You can run a one-off container that is immediately deleted upon exiting with the following command (`config.json` must be in the current working directory):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -v /etc/localtime:/etc/localtime:ro -v `pwd`/config.json:/freqtrade/config.json -it freqtrade
|
||||||
|
```
|
||||||
|
|
||||||
|
There is known issue in OSX Docker versions after 17.09.1, whereby /etc/localtime cannot be shared causing Docker to not start. A work-around for this is to start with the following cmd.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -e TZ=`ls -la /etc/localtime | cut -d/ -f8-9` -v `pwd`/config.json:/freqtrade/config.json -it freqtrade
|
||||||
|
```
|
||||||
|
|
||||||
|
More information on this docker issue and work-around can be read [here](https://github.com/docker/for-mac/issues/2396)
|
||||||
|
|
||||||
|
In this example, the database will be created inside the docker instance and will be lost when you will refresh your image.
|
||||||
|
|
||||||
|
### 5. Run a restartable docker image
|
||||||
|
|
||||||
|
To run a restartable instance in the background (feel free to place your configuration and database files wherever it feels comfortable on your filesystem).
|
||||||
|
|
||||||
|
#### 5.1. Move your config file and database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir ~/.freqtrade
|
||||||
|
mv config.json ~/.freqtrade
|
||||||
|
mv tradesv3.sqlite ~/.freqtrade
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 5.2. Run the docker image
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d \
|
||||||
|
--name freqtrade \
|
||||||
|
-v /etc/localtime:/etc/localtime:ro \
|
||||||
|
-v ~/.freqtrade/config.json:/freqtrade/config.json \
|
||||||
|
-v ~/.freqtrade/tradesv3.sqlite:/freqtrade/tradesv3.sqlite \
|
||||||
|
freqtrade --db-url sqlite:///tradesv3.sqlite
|
||||||
|
```
|
||||||
|
|
||||||
|
NOTE: db-url defaults to `sqlite:///tradesv3.sqlite` but it defaults to `sqlite://` if `dry_run=True` is being used.
|
||||||
|
To override this behaviour use a custom db-url value: i.e.: `--db-url sqlite:///tradesv3.dryrun.sqlite`
|
||||||
|
|
||||||
|
### 6. Monitor your Docker instance
|
||||||
|
|
||||||
|
You can then use the following commands to monitor and manage your container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs freqtrade
|
||||||
|
docker logs -f freqtrade
|
||||||
|
docker restart freqtrade
|
||||||
|
docker stop freqtrade
|
||||||
|
docker start freqtrade
|
||||||
|
```
|
||||||
|
|
||||||
|
You do not need to rebuild the image for configuration changes, it will suffice to edit `config.json` and restart the container.
|
||||||
|
|
||||||
|
### 7. Backtest with docker
|
||||||
|
|
||||||
|
The following assumes that the above steps (1-4) have been completed successfully.
|
||||||
|
Also, backtest-data should be available at `~/.freqtrade/user_data/`.
|
||||||
|
|
||||||
|
|
||||||
|
``` bash
|
||||||
|
docker run -d \
|
||||||
|
--name freqtrade \
|
||||||
|
-v /etc/localtime:/etc/localtime:ro \
|
||||||
|
-v ~/.freqtrade/config.json:/freqtrade/config.json \
|
||||||
|
-v ~/.freqtrade/tradesv3.sqlite:/freqtrade/tradesv3.sqlite \
|
||||||
|
-v ~/.freqtrade/user_data/:/freqtrade/user_data/ \
|
||||||
|
freqtrade --strategy AwsomelyProfitableStrategy backtesting
|
||||||
|
```
|
||||||
|
|
||||||
|
Head over to the [Backtesting Documentation](https://github.com/freqtrade/freqtrade/blob/develop/docs/backtesting.md) for more details.
|
||||||
|
|
||||||
|
*Note*: Additional parameters can be appended after the image name (`freqtrade` in the above example).
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
## Custom Installation
|
||||||
|
|
||||||
|
We've included/collected install instructions for Ubuntu 16.04, MacOS, and Windows. These are guidelines and your success may vary with other distros.
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
|
||||||
|
Click each one for install guide:
|
||||||
|
|
||||||
|
* [Python 3.6.x](http://docs.python-guide.org/en/latest/starting/installation/), note the bot was not tested on Python >= 3.7.x
|
||||||
|
* [pip](https://pip.pypa.io/en/stable/installing/)
|
||||||
|
* [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||||
|
* [virtualenv](https://virtualenv.pypa.io/en/stable/installation/) (Recommended)
|
||||||
|
* [TA-Lib](https://mrjbq7.github.io/ta-lib/install.html)
|
||||||
|
|
||||||
|
### Linux - Ubuntu 16.04
|
||||||
|
|
||||||
|
#### 1. Install Python 3.6, Git, and wget
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo add-apt-repository ppa:jonathonf/python-3.6
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install python3.6 python3.6-venv python3.6-dev build-essential autoconf libtool pkg-config make wget git
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Install TA-Lib
|
||||||
|
|
||||||
|
Official webpage: https://mrjbq7.github.io/ta-lib/install.html
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wget http://prdownloads.sourceforge.net/ta-lib/ta-lib-0.4.0-src.tar.gz
|
||||||
|
tar xvzf ta-lib-0.4.0-src.tar.gz
|
||||||
|
cd ta-lib
|
||||||
|
./configure --prefix=/usr
|
||||||
|
make
|
||||||
|
make install
|
||||||
|
cd ..
|
||||||
|
rm -rf ./ta-lib*
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Install FreqTrade
|
||||||
|
|
||||||
|
Clone the git repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/freqtrade/freqtrade.git
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. Configure `freqtrade` as a `systemd` service
|
||||||
|
|
||||||
|
From the freqtrade repo... copy `freqtrade.service` to your systemd user directory (usually `~/.config/systemd/user`) and update `WorkingDirectory` and `ExecStart` to match your setup.
|
||||||
|
|
||||||
|
After that you can start the daemon with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
systemctl --user start freqtrade
|
||||||
|
```
|
||||||
|
|
||||||
|
For this to be persistent (run when user is logged out) you'll need to enable `linger` for your freqtrade user.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo loginctl enable-linger "$USER"
|
||||||
|
```
|
||||||
|
|
||||||
|
### MacOS
|
||||||
|
|
||||||
|
#### 1. Install Python 3.6, git, wget and ta-lib
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install python3 git wget ta-lib
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Install FreqTrade
|
||||||
|
|
||||||
|
Clone the git repository:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/freqtrade/freqtrade.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Optionally checkout the develop branch:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git checkout develop
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup Config and virtual env
|
||||||
|
|
||||||
|
#### 1. Initialize the configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd freqtrade
|
||||||
|
cp config.json.example config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
> *To edit the config please refer to [Bot Configuration](https://github.com/freqtrade/freqtrade/blob/develop/docs/configuration.md).*
|
||||||
|
|
||||||
|
#### 2. Setup your Python virtual environment (virtualenv)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.6 -m venv .env
|
||||||
|
source .env/bin/activate
|
||||||
|
pip3.6 install --upgrade pip
|
||||||
|
pip3.6 install -r requirements.txt
|
||||||
|
pip3.6 install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Run the Bot
|
||||||
|
|
||||||
|
If this is the first time you run the bot, ensure you are running it in Dry-run `"dry_run": true,` otherwise it will start to buy and sell coins.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3.6 ./freqtrade/main.py -c config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
------
|
||||||
|
|
||||||
|
## Windows
|
||||||
|
|
||||||
|
We recommend that Windows users use [Docker](#docker) as this will work much easier and smoother (also more secure).
|
||||||
|
|
||||||
|
If that is not possible, try using the Windows Linux subsystem (WSL) - for which the Ubuntu instructions should work.
|
||||||
|
If that is not available on your system, feel free to try the instructions below, which led to success for some.
|
||||||
|
|
||||||
|
### Install freqtrade manually
|
||||||
|
|
||||||
|
#### Clone the git repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/freqtrade/freqtrade.git
|
||||||
|
```
|
||||||
|
|
||||||
|
copy paste `config.json` to ``\path\freqtrade-develop\freqtrade`
|
||||||
|
|
||||||
|
#### install ta-lib
|
||||||
|
|
||||||
|
Install ta-lib according to the [ta-lib documentation](https://github.com/mrjbq7/ta-lib#windows).
|
||||||
|
|
||||||
|
As compiling from source on windows has heavy dependencies (requires a partial visual studio installation), there is also a repository of inofficial precompiled windows Wheels [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib), which needs to be downloaded and installed using `pip install TA_Lib‑0.4.17‑cp36‑cp36m‑win32.whl` (make sure to use the version matching your python version)
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
>cd \path\freqtrade-develop
|
||||||
|
>python -m venv .env
|
||||||
|
>cd .env\Scripts
|
||||||
|
>activate.bat
|
||||||
|
>cd \path\freqtrade-develop
|
||||||
|
REM optionally install ta-lib from wheel
|
||||||
|
REM >pip install TA_Lib‑0.4.17‑cp36‑cp36m‑win32.whl
|
||||||
|
>pip install -r requirements.txt
|
||||||
|
>pip install -e .
|
||||||
|
>python freqtrade\main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
> Thanks [Owdr](https://github.com/Owdr) for the commands. Source: [Issue #222](https://github.com/freqtrade/freqtrade/issues/222)
|
||||||
|
|
||||||
|
Now you have an environment ready, the next step is
|
||||||
|
[Bot Configuration](https://github.com/freqtrade/freqtrade/blob/develop/docs/configuration.md)...
|
||||||
87
docs/plotting.md
Normal file
87
docs/plotting.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Plotting
|
||||||
|
This page explains how to plot prices, indicator, profits.
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Plot price and indicators](#plot-price-and-indicators)
|
||||||
|
- [Plot profit](#plot-profit)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Plotting scripts use Plotly library. Install/upgrade it with:
|
||||||
|
|
||||||
|
```
|
||||||
|
pip install --upgrade plotly
|
||||||
|
```
|
||||||
|
|
||||||
|
At least version 2.3.0 is required.
|
||||||
|
|
||||||
|
## Plot price and indicators
|
||||||
|
Usage for the price plotter:
|
||||||
|
|
||||||
|
```
|
||||||
|
script/plot_dataframe.py [-h] [-p pair] [--live]
|
||||||
|
```
|
||||||
|
|
||||||
|
Example
|
||||||
|
```
|
||||||
|
python scripts/plot_dataframe.py -p BTC_ETH
|
||||||
|
```
|
||||||
|
|
||||||
|
The `-p` pair argument, can be used to specify what
|
||||||
|
pair you would like to plot.
|
||||||
|
|
||||||
|
**Advanced use**
|
||||||
|
|
||||||
|
To plot the current live price use the `--live` flag:
|
||||||
|
```
|
||||||
|
python scripts/plot_dataframe.py -p BTC_ETH --live
|
||||||
|
```
|
||||||
|
|
||||||
|
To plot a timerange (to zoom in):
|
||||||
|
```
|
||||||
|
python scripts/plot_dataframe.py -p BTC_ETH --timerange=100-200
|
||||||
|
```
|
||||||
|
Timerange doesn't work with live data.
|
||||||
|
|
||||||
|
To plot trades stored in a database use `--db-url` argument:
|
||||||
|
```
|
||||||
|
python scripts/plot_dataframe.py --db-url tradesv3.dry_run.sqlite -p BTC_ETH
|
||||||
|
```
|
||||||
|
|
||||||
|
To plot a test strategy the strategy should have first be backtested.
|
||||||
|
The results may then be plotted with the -s argument:
|
||||||
|
```
|
||||||
|
python scripts/plot_dataframe.py -s Strategy_Name -p BTC/ETH --datadir user_data/data/<exchange_name>/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plot profit
|
||||||
|
|
||||||
|
The profit plotter show a picture with three plots:
|
||||||
|
1) Average closing price for all pairs
|
||||||
|
2) The summarized profit made by backtesting.
|
||||||
|
Note that this is not the real-world profit, but
|
||||||
|
more of an estimate.
|
||||||
|
3) Each pair individually profit
|
||||||
|
|
||||||
|
The first graph is good to get a grip of how the overall market
|
||||||
|
progresses.
|
||||||
|
|
||||||
|
The second graph will show how you algorithm works or doesnt.
|
||||||
|
Perhaps you want an algorithm that steadily makes small profits,
|
||||||
|
or one that acts less seldom, but makes big swings.
|
||||||
|
|
||||||
|
The third graph can be useful to spot outliers, events in pairs
|
||||||
|
that makes profit spikes.
|
||||||
|
|
||||||
|
Usage for the profit plotter:
|
||||||
|
|
||||||
|
```
|
||||||
|
script/plot_profit.py [-h] [-p pair] [--datadir directory] [--ticker_interval num]
|
||||||
|
```
|
||||||
|
|
||||||
|
The `-p` pair argument, can be used to plot a single pair
|
||||||
|
|
||||||
|
Example
|
||||||
|
```
|
||||||
|
python3 scripts/plot_profit.py --datadir ../freqtrade/freqtrade/tests/testdata-20171221/ -p BTC_LTC
|
||||||
|
```
|
||||||
48
docs/pre-requisite.md
Normal file
48
docs/pre-requisite.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Pre-requisite
|
||||||
|
Before running your bot in production you will need to setup few
|
||||||
|
external API. In production mode, the bot required valid Bittrex API
|
||||||
|
credentials and a Telegram bot (optional but recommended).
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
- [Setup your Bittrex account](#setup-your-bittrex-account)
|
||||||
|
- [Backtesting commands](#setup-your-telegram-bot)
|
||||||
|
|
||||||
|
## Setup your Bittrex account
|
||||||
|
*To be completed, please feel free to complete this section.*
|
||||||
|
|
||||||
|
## Setup your Telegram bot
|
||||||
|
The only things you need is a working Telegram bot and its API token.
|
||||||
|
Below we explain how to create your Telegram Bot, and how to get your
|
||||||
|
Telegram user id.
|
||||||
|
|
||||||
|
### 1. Create your Telegram bot
|
||||||
|
**1.1. Start a chat with https://telegram.me/BotFather**
|
||||||
|
**1.2. Send the message** `/newbot`
|
||||||
|
*BotFather response:*
|
||||||
|
```
|
||||||
|
Alright, a new bot. How are we going to call it? Please choose a name for your bot.
|
||||||
|
```
|
||||||
|
**1.3. Choose the public name of your bot (e.g "`Freqtrade bot`")**
|
||||||
|
*BotFather response:*
|
||||||
|
```
|
||||||
|
Good. Now let's choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot.
|
||||||
|
```
|
||||||
|
**1.4. Choose the name id of your bot (e.g "`My_own_freqtrade_bot`")**
|
||||||
|
**1.5. Father bot will return you the token (API key)**
|
||||||
|
Copy it and keep it you will use it for the config parameter `token`.
|
||||||
|
*BotFather response:*
|
||||||
|
```
|
||||||
|
Done! Congratulations on your new bot. You will find it at t.me/My_own_freqtrade_bot. You can now add a description, about section and profile picture for your bot, see /help for a list of commands. By the way, when you've finished creating your cool bot, ping our Bot Support if you want a better username for it. Just make sure the bot is fully operational before you do this.
|
||||||
|
|
||||||
|
Use this token to access the HTTP API:
|
||||||
|
521095879:AAEcEZEL7ADJ56FtG_qD0bQJSKETbXCBCi0
|
||||||
|
|
||||||
|
For a description of the Bot API, see this page: https://core.telegram.org/bots/api
|
||||||
|
```
|
||||||
|
**1.6. Don't forget to start the conversation with your bot, by clicking /START button**
|
||||||
|
|
||||||
|
### 2. Get your user id
|
||||||
|
**2.1. Talk to https://telegram.me/userinfobot**
|
||||||
|
**2.2. Get your "Id", you will use it for the config parameter
|
||||||
|
`chat_id`.**
|
||||||
|
|
||||||
93
docs/sql_cheatsheet.md
Normal file
93
docs/sql_cheatsheet.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# SQL Helper
|
||||||
|
This page constains some help if you want to edit your sqlite db.
|
||||||
|
|
||||||
|
## Install sqlite3
|
||||||
|
**Ubuntu/Debian installation**
|
||||||
|
```bash
|
||||||
|
sudo apt-get install sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open the DB
|
||||||
|
```bash
|
||||||
|
sqlite3
|
||||||
|
.open <filepath>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Table structure
|
||||||
|
|
||||||
|
### List tables
|
||||||
|
```bash
|
||||||
|
.tables
|
||||||
|
```
|
||||||
|
|
||||||
|
### Display table structure
|
||||||
|
```bash
|
||||||
|
.schema <table_name>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Trade table structure
|
||||||
|
```sql
|
||||||
|
CREATE TABLE trades (
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
exchange VARCHAR NOT NULL,
|
||||||
|
pair VARCHAR NOT NULL,
|
||||||
|
is_open BOOLEAN NOT NULL,
|
||||||
|
fee_open FLOAT NOT NULL,
|
||||||
|
fee_close FLOAT NOT NULL,
|
||||||
|
open_rate FLOAT,
|
||||||
|
open_rate_requested FLOAT,
|
||||||
|
close_rate FLOAT,
|
||||||
|
close_rate_requested FLOAT,
|
||||||
|
close_profit FLOAT,
|
||||||
|
stake_amount FLOAT NOT NULL,
|
||||||
|
amount FLOAT,
|
||||||
|
open_date DATETIME NOT NULL,
|
||||||
|
close_date DATETIME,
|
||||||
|
open_order_id VARCHAR,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CHECK (is_open IN (0, 1))
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Get all trades in the table
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM trades;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix trade still open after a /forcesell
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE trades
|
||||||
|
SET is_open=0, close_date=<close_date>, close_rate=<close_rate>, close_profit=close_rate/open_rate
|
||||||
|
WHERE id=<trade_ID_to_update>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```sql
|
||||||
|
UPDATE trades
|
||||||
|
SET is_open=0, close_date='2017-12-20 03:08:45.103418', close_rate=0.19638016, close_profit=0.0496
|
||||||
|
WHERE id=31;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Insert manually a new trade
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT
|
||||||
|
INTO trades (exchange, pair, is_open, fee_open, fee_close, open_rate, stake_amount, amount, open_date)
|
||||||
|
VALUES ('BITTREX', 'BTC_<COIN>', 1, 0.0025, 0.0025, <open_rate>, <stake_amount>, <amount>, '<datetime>')
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```sql
|
||||||
|
INSERT INTO trades (exchange, pair, is_open, fee_open, fee_close, open_rate, stake_amount, amount, open_date) VALUES ('BITTREX', 'BTC_ETC', 1, 0.0025, 0.0025, 0.00258580, 0.002, 0.7715262081, '2017-11-28 12:44:24.000000')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fix wrong fees in the table
|
||||||
|
If your DB was created before
|
||||||
|
[PR#200](https://github.com/freqtrade/freqtrade/pull/200) was merged
|
||||||
|
(before 12/23/17).
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE trades SET fee=0.0025 WHERE fee=0.005;
|
||||||
|
```
|
||||||
137
docs/telegram-usage.md
Normal file
137
docs/telegram-usage.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# Telegram usage
|
||||||
|
|
||||||
|
This page explains how to command your bot with Telegram.
|
||||||
|
|
||||||
|
## Pre-requisite
|
||||||
|
To control your bot with Telegram, you need first to
|
||||||
|
[set up a Telegram bot](https://github.com/freqtrade/freqtrade/blob/develop/docs/pre-requisite.md)
|
||||||
|
and add your Telegram API keys into your config file.
|
||||||
|
|
||||||
|
## Telegram commands
|
||||||
|
Per default, the Telegram bot shows predefined commands. Some commands
|
||||||
|
are only available by sending them to the bot. The table below list the
|
||||||
|
official commands. You can ask at any moment for help with `/help`.
|
||||||
|
|
||||||
|
| Command | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `/start` | | Starts the trader
|
||||||
|
| `/stop` | | Stops the trader
|
||||||
|
| `/reload_conf` | | Reloads the configuration file
|
||||||
|
| `/status` | | Lists all open trades
|
||||||
|
| `/status table` | | List all open trades in a table format
|
||||||
|
| `/count` | | Displays number of trades used and available
|
||||||
|
| `/profit` | | Display a summary of your profit/loss from close trades and some stats about your performance
|
||||||
|
| `/forcesell <trade_id>` | | Instantly sells the given trade (Ignoring `minimum_roi`).
|
||||||
|
| `/forcesell all` | | Instantly sells all open trades (Ignoring `minimum_roi`).
|
||||||
|
| `/performance` | | Show performance of each finished trade grouped by pair
|
||||||
|
| `/balance` | | Show account balance per currency
|
||||||
|
| `/daily <n>` | 7 | Shows profit or loss per day, over the last n days
|
||||||
|
| `/help` | | Show help message
|
||||||
|
| `/version` | | Show version
|
||||||
|
|
||||||
|
## Telegram commands in action
|
||||||
|
Below, example of Telegram message you will receive for each command.
|
||||||
|
|
||||||
|
### /start
|
||||||
|
> **Status:** `running`
|
||||||
|
|
||||||
|
### /stop
|
||||||
|
> `Stopping trader ...`
|
||||||
|
> **Status:** `stopped`
|
||||||
|
|
||||||
|
## /status
|
||||||
|
For each open trade, the bot will send you the following message.
|
||||||
|
|
||||||
|
> **Trade ID:** `123`
|
||||||
|
> **Current Pair:** CVC/BTC
|
||||||
|
> **Open Since:** `1 days ago`
|
||||||
|
> **Amount:** `26.64180098`
|
||||||
|
> **Open Rate:** `0.00007489`
|
||||||
|
> **Close Rate:** `None`
|
||||||
|
> **Current Rate:** `0.00007489`
|
||||||
|
> **Close Profit:** `None`
|
||||||
|
> **Current Profit:** `12.95%`
|
||||||
|
> **Open Order:** `None`
|
||||||
|
|
||||||
|
## /status table
|
||||||
|
Return the status of all open trades in a table format.
|
||||||
|
```
|
||||||
|
ID Pair Since Profit
|
||||||
|
---- -------- ------- --------
|
||||||
|
67 SC/BTC 1 d 13.33%
|
||||||
|
123 CVC/BTC 1 h 12.95%
|
||||||
|
```
|
||||||
|
|
||||||
|
## /count
|
||||||
|
Return the number of trades used and available.
|
||||||
|
```
|
||||||
|
current max
|
||||||
|
--------- -----
|
||||||
|
2 10
|
||||||
|
```
|
||||||
|
|
||||||
|
## /profit
|
||||||
|
Return a summary of your profit/loss and performance.
|
||||||
|
|
||||||
|
> **ROI:** Close trades
|
||||||
|
> ∙ `0.00485701 BTC (258.45%)`
|
||||||
|
> ∙ `62.968 USD`
|
||||||
|
> **ROI:** All trades
|
||||||
|
> ∙ `0.00255280 BTC (143.43%)`
|
||||||
|
> ∙ `33.095 EUR`
|
||||||
|
>
|
||||||
|
> **Total Trade Count:** `138`
|
||||||
|
> **First Trade opened:** `3 days ago`
|
||||||
|
> **Latest Trade opened:** `2 minutes ago`
|
||||||
|
> **Avg. Duration:** `2:33:45`
|
||||||
|
> **Best Performing:** `PAY/BTC: 50.23%`
|
||||||
|
|
||||||
|
## /forcesell <trade_id>
|
||||||
|
|
||||||
|
> **BITTREX:** Selling BTC/LTC with limit `0.01650000 (profit: ~-4.07%, -0.00008168)`
|
||||||
|
|
||||||
|
## /performance
|
||||||
|
Return the performance of each crypto-currency the bot has sold.
|
||||||
|
> Performance:
|
||||||
|
> 1. `RCN/BTC 57.77%`
|
||||||
|
> 2. `PAY/BTC 56.91%`
|
||||||
|
> 3. `VIB/BTC 47.07%`
|
||||||
|
> 4. `SALT/BTC 30.24%`
|
||||||
|
> 5. `STORJ/BTC 27.24%`
|
||||||
|
> ...
|
||||||
|
|
||||||
|
## /balance
|
||||||
|
Return the balance of all crypto-currency your have on the exchange.
|
||||||
|
|
||||||
|
> **Currency:** BTC
|
||||||
|
> **Available:** 3.05890234
|
||||||
|
> **Balance:** 3.05890234
|
||||||
|
> **Pending:** 0.0
|
||||||
|
|
||||||
|
> **Currency:** CVC
|
||||||
|
> **Available:** 86.64180098
|
||||||
|
> **Balance:** 86.64180098
|
||||||
|
> **Pending:** 0.0
|
||||||
|
|
||||||
|
## /daily <n>
|
||||||
|
Per default `/daily` will return the 7 last days.
|
||||||
|
The example below if for `/daily 3`:
|
||||||
|
|
||||||
|
> **Daily Profit over the last 3 days:**
|
||||||
|
```
|
||||||
|
Day Profit BTC Profit USD
|
||||||
|
---------- -------------- ------------
|
||||||
|
2018-01-03 0.00224175 BTC 29,142 USD
|
||||||
|
2018-01-02 0.00033131 BTC 4,307 USD
|
||||||
|
2018-01-01 0.00269130 BTC 34.986 USD
|
||||||
|
```
|
||||||
|
|
||||||
|
## /version
|
||||||
|
> **Version:** `0.14.3`
|
||||||
|
|
||||||
|
### using proxy with telegram
|
||||||
|
```
|
||||||
|
$ export HTTP_PROXY="http://addr:port"
|
||||||
|
$ export HTTPS_PROXY="http://addr:port"
|
||||||
|
$ freqtrade
|
||||||
|
```
|
||||||
14
freqtrade.service
Normal file
14
freqtrade.service
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Freqtrade Daemon
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
# Set WorkingDirectory and ExecStart to your file paths accordingly
|
||||||
|
# NOTE: %h will be resolved to /home/<username>
|
||||||
|
WorkingDirectory=%h/freqtrade
|
||||||
|
ExecStart=/usr/bin/freqtrade --dynamic-whitelist 40
|
||||||
|
Restart=on-failure
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
|
||||||
@@ -1,3 +1,25 @@
|
|||||||
__version__ = '0.11.0'
|
""" FreqTrade bot """
|
||||||
|
__version__ = '0.17.0'
|
||||||
|
|
||||||
from . import main
|
|
||||||
|
class DependencyException(BaseException):
|
||||||
|
"""
|
||||||
|
Indicates that a assumed dependency is not met.
|
||||||
|
This could happen when there is currently not enough money on the account.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class OperationalException(BaseException):
|
||||||
|
"""
|
||||||
|
Requires manual intervention.
|
||||||
|
This happens when an exchange returns an unexpected error during runtime
|
||||||
|
or given configuration is invalid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TemporaryError(BaseException):
|
||||||
|
"""
|
||||||
|
Temporary network or exchange related error.
|
||||||
|
This could happen when an exchange is congested, unavailable, or the user
|
||||||
|
has networking problems. Usually resolves itself after a time.
|
||||||
|
"""
|
||||||
|
|||||||
15
freqtrade/__main__.py
Normal file
15
freqtrade/__main__.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
__main__.py for Freqtrade
|
||||||
|
To launch Freqtrade as a module
|
||||||
|
|
||||||
|
> python -m freqtrade (with Python >= 3.6)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from freqtrade import main
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main.set_loggers()
|
||||||
|
main.main(sys.argv[1:])
|
||||||
@@ -1,150 +1,237 @@
|
|||||||
|
"""
|
||||||
|
Functions to analyze ticker data with indicators and produce buy and sell signals
|
||||||
|
"""
|
||||||
import logging
|
import logging
|
||||||
import time
|
from datetime import datetime, timedelta
|
||||||
from datetime import timedelta
|
from enum import Enum
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
import talib.abstract as ta
|
from pandas import DataFrame, to_datetime
|
||||||
from pandas import DataFrame
|
|
||||||
|
from freqtrade import constants
|
||||||
|
from freqtrade.exchange import Exchange
|
||||||
|
from freqtrade.persistence import Trade
|
||||||
|
from freqtrade.strategy.resolver import StrategyResolver, IStrategy
|
||||||
|
|
||||||
from freqtrade.exchange import get_ticker_history
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def parse_ticker_dataframe(ticker: list, minimum_date: arrow.Arrow) -> DataFrame:
|
class SignalType(Enum):
|
||||||
"""
|
"""
|
||||||
Analyses the trend for the given pair
|
Enum to distinguish between buy and sell signals
|
||||||
:param pair: pair as str in format BTC_ETH or BTC-ETH
|
"""
|
||||||
|
BUY = "buy"
|
||||||
|
SELL = "sell"
|
||||||
|
|
||||||
|
|
||||||
|
class Analyze(object):
|
||||||
|
"""
|
||||||
|
Analyze class contains everything the bot need to determine if the situation is good for
|
||||||
|
buying or selling.
|
||||||
|
"""
|
||||||
|
def __init__(self, config: dict) -> None:
|
||||||
|
"""
|
||||||
|
Init Analyze
|
||||||
|
:param config: Bot configuration (use the one from Configuration())
|
||||||
|
"""
|
||||||
|
self.config = config
|
||||||
|
self.strategy: IStrategy = StrategyResolver(self.config).strategy
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_ticker_dataframe(ticker: list) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Analyses the trend for the given ticker history
|
||||||
|
:param ticker: See exchange.get_ticker_history
|
||||||
:return: DataFrame
|
:return: DataFrame
|
||||||
"""
|
"""
|
||||||
df = DataFrame(ticker) \
|
cols = ['date', 'open', 'high', 'low', 'close', 'volume']
|
||||||
.drop('BV', 1) \
|
frame = DataFrame(ticker, columns=cols)
|
||||||
.rename(columns={'C':'close', 'V':'volume', 'O':'open', 'H':'high', 'L':'low', 'T':'date'}) \
|
|
||||||
.sort_values('date')
|
|
||||||
return df[df['date'].map(arrow.get) > minimum_date]
|
|
||||||
|
|
||||||
|
frame['date'] = to_datetime(frame['date'],
|
||||||
|
unit='ms',
|
||||||
|
utc=True,
|
||||||
|
infer_datetime_format=True)
|
||||||
|
|
||||||
def populate_indicators(dataframe: DataFrame) -> DataFrame:
|
# group by index and aggregate results to eliminate duplicate ticks
|
||||||
|
frame = frame.groupby(by='date', as_index=False, sort=True).agg({
|
||||||
|
'open': 'first',
|
||||||
|
'high': 'max',
|
||||||
|
'low': 'min',
|
||||||
|
'close': 'last',
|
||||||
|
'volume': 'max',
|
||||||
|
})
|
||||||
|
frame.drop(frame.tail(1).index, inplace=True) # eliminate partial candle
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def populate_indicators(self, dataframe: DataFrame) -> DataFrame:
|
||||||
"""
|
"""
|
||||||
Adds several different TA indicators to the given DataFrame
|
Adds several different TA indicators to the given DataFrame
|
||||||
"""
|
|
||||||
dataframe['sar'] = ta.SAR(dataframe, 0.02, 0.22)
|
|
||||||
dataframe['adx'] = ta.ADX(dataframe)
|
|
||||||
stoch = ta.STOCHF(dataframe)
|
|
||||||
dataframe['fastd'] = stoch['fastd']
|
|
||||||
dataframe['fastk'] = stoch['fastk']
|
|
||||||
dataframe['blower'] = ta.BBANDS(dataframe, nbdevup=2, nbdevdn=2)['lowerband']
|
|
||||||
dataframe['cci'] = ta.CCI(dataframe, timeperiod=5)
|
|
||||||
dataframe['sma'] = ta.SMA(dataframe, timeperiod=100)
|
|
||||||
dataframe['tema'] = ta.TEMA(dataframe, timeperiod=4)
|
|
||||||
dataframe['mfi'] = ta.MFI(dataframe)
|
|
||||||
return dataframe
|
|
||||||
|
|
||||||
|
Performance Note: For the best performance be frugal on the number of indicators
|
||||||
def populate_buy_trend(dataframe: DataFrame) -> DataFrame:
|
you are using. Let uncomment only the indicator you are using in your strategies
|
||||||
|
or your hyperopt configuration, otherwise you will waste your memory and CPU usage.
|
||||||
"""
|
"""
|
||||||
Based on TA indicators, populates the buy trend for the given dataframe
|
return self.strategy.populate_indicators(dataframe=dataframe)
|
||||||
|
|
||||||
|
def populate_buy_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Based on TA indicators, populates the buy signal for the given dataframe
|
||||||
:param dataframe: DataFrame
|
:param dataframe: DataFrame
|
||||||
:return: DataFrame with buy column
|
:return: DataFrame with buy column
|
||||||
"""
|
"""
|
||||||
|
return self.strategy.populate_buy_trend(dataframe=dataframe)
|
||||||
|
|
||||||
dataframe.loc[
|
def populate_sell_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||||
(dataframe['close'] < dataframe['sma']) &
|
|
||||||
(dataframe['cci'] < -100) &
|
|
||||||
(dataframe['tema'] <= dataframe['blower']) &
|
|
||||||
(dataframe['mfi'] < 30) &
|
|
||||||
(dataframe['fastd'] < 20) &
|
|
||||||
(dataframe['adx'] > 20),
|
|
||||||
'buy'] = 1
|
|
||||||
dataframe.loc[dataframe['buy'] == 1, 'buy_price'] = dataframe['close']
|
|
||||||
|
|
||||||
return dataframe
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_ticker(pair: str) -> DataFrame:
|
|
||||||
"""
|
"""
|
||||||
Get ticker data for given currency pair, push it to a DataFrame and
|
Based on TA indicators, populates the sell signal for the given dataframe
|
||||||
|
:param dataframe: DataFrame
|
||||||
|
:return: DataFrame with buy column
|
||||||
|
"""
|
||||||
|
return self.strategy.populate_sell_trend(dataframe=dataframe)
|
||||||
|
|
||||||
|
def get_ticker_interval(self) -> str:
|
||||||
|
"""
|
||||||
|
Return ticker interval to use
|
||||||
|
:return: Ticker interval value to use
|
||||||
|
"""
|
||||||
|
return self.strategy.ticker_interval
|
||||||
|
|
||||||
|
def get_stoploss(self) -> float:
|
||||||
|
"""
|
||||||
|
Return stoploss to use
|
||||||
|
:return: Strategy stoploss value to use
|
||||||
|
"""
|
||||||
|
return self.strategy.stoploss
|
||||||
|
|
||||||
|
def analyze_ticker(self, ticker_history: List[Dict]) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Parses the given ticker history and returns a populated DataFrame
|
||||||
add several TA indicators and buy signal to it
|
add several TA indicators and buy signal to it
|
||||||
:return DataFrame with ticker data and indicator data
|
:return DataFrame with ticker data and indicator data
|
||||||
"""
|
"""
|
||||||
minimum_date = arrow.utcnow().shift(hours=-24)
|
dataframe = self.parse_ticker_dataframe(ticker_history)
|
||||||
data = get_ticker_history(pair, minimum_date)
|
dataframe = self.populate_indicators(dataframe)
|
||||||
dataframe = parse_ticker_dataframe(data['result'], minimum_date)
|
dataframe = self.populate_buy_trend(dataframe)
|
||||||
|
dataframe = self.populate_sell_trend(dataframe)
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
def get_signal(self, exchange: Exchange, pair: str, interval: str) -> Tuple[bool, bool]:
|
||||||
|
"""
|
||||||
|
Calculates current signal based several technical analysis indicators
|
||||||
|
:param pair: pair in format ANT/BTC
|
||||||
|
:param interval: Interval to use (in min)
|
||||||
|
:return: (Buy, Sell) A bool-tuple indicating buy/sell signal
|
||||||
|
"""
|
||||||
|
ticker_hist = exchange.get_ticker_history(pair, interval)
|
||||||
|
if not ticker_hist:
|
||||||
|
logger.warning('Empty ticker history for pair %s', pair)
|
||||||
|
return False, False
|
||||||
|
|
||||||
|
try:
|
||||||
|
dataframe = self.analyze_ticker(ticker_hist)
|
||||||
|
except ValueError as error:
|
||||||
|
logger.warning(
|
||||||
|
'Unable to analyze ticker for pair %s: %s',
|
||||||
|
pair,
|
||||||
|
str(error)
|
||||||
|
)
|
||||||
|
return False, False
|
||||||
|
except Exception as error:
|
||||||
|
logger.exception(
|
||||||
|
'Unexpected error when analyzing ticker for pair %s: %s',
|
||||||
|
pair,
|
||||||
|
str(error)
|
||||||
|
)
|
||||||
|
return False, False
|
||||||
|
|
||||||
if dataframe.empty:
|
if dataframe.empty:
|
||||||
logger.warning('Empty dataframe for pair %s', pair)
|
logger.warning('Empty dataframe for pair %s', pair)
|
||||||
return dataframe
|
return False, False
|
||||||
|
|
||||||
dataframe = populate_indicators(dataframe)
|
|
||||||
dataframe = populate_buy_trend(dataframe)
|
|
||||||
return dataframe
|
|
||||||
|
|
||||||
|
|
||||||
def get_buy_signal(pair: str) -> bool:
|
|
||||||
"""
|
|
||||||
Calculates a buy signal based several technical analysis indicators
|
|
||||||
:param pair: pair in format BTC_ANT or BTC-ANT
|
|
||||||
:return: True if pair is good for buying, False otherwise
|
|
||||||
"""
|
|
||||||
dataframe = analyze_ticker(pair)
|
|
||||||
|
|
||||||
if dataframe.empty:
|
|
||||||
return False
|
|
||||||
|
|
||||||
latest = dataframe.iloc[-1]
|
latest = dataframe.iloc[-1]
|
||||||
|
|
||||||
# Check if dataframe is out of date
|
# Check if dataframe is out of date
|
||||||
signal_date = arrow.get(latest['date'])
|
signal_date = arrow.get(latest['date'])
|
||||||
if signal_date < arrow.now() - timedelta(minutes=10):
|
interval_minutes = constants.TICKER_INTERVAL_MINUTES[interval]
|
||||||
|
if signal_date < (arrow.utcnow() - timedelta(minutes=(interval_minutes + 5))):
|
||||||
|
logger.warning(
|
||||||
|
'Outdated history for pair %s. Last tick is %s minutes old',
|
||||||
|
pair,
|
||||||
|
(arrow.utcnow() - signal_date).seconds // 60
|
||||||
|
)
|
||||||
|
return False, False
|
||||||
|
|
||||||
|
(buy, sell) = latest[SignalType.BUY.value] == 1, latest[SignalType.SELL.value] == 1
|
||||||
|
logger.debug(
|
||||||
|
'trigger: %s (pair=%s) buy=%s sell=%s',
|
||||||
|
latest['date'],
|
||||||
|
pair,
|
||||||
|
str(buy),
|
||||||
|
str(sell)
|
||||||
|
)
|
||||||
|
return buy, sell
|
||||||
|
|
||||||
|
def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool, sell: bool) -> bool:
|
||||||
|
"""
|
||||||
|
This function evaluate if on the condition required to trigger a sell has been reached
|
||||||
|
if the threshold is reached and updates the trade record.
|
||||||
|
:return: True if trade should be sold, False otherwise
|
||||||
|
"""
|
||||||
|
current_profit = trade.calc_profit_percent(rate)
|
||||||
|
if self.stop_loss_reached(current_profit=current_profit):
|
||||||
|
return True
|
||||||
|
|
||||||
|
experimental = self.config.get('experimental', {})
|
||||||
|
|
||||||
|
if buy and experimental.get('ignore_roi_if_buy_signal', False):
|
||||||
|
logger.debug('Buy signal still active - not selling.')
|
||||||
return False
|
return False
|
||||||
|
|
||||||
signal = latest['buy'] == 1
|
# Check if minimal roi has been reached and no longer in buy conditions (avoiding a fee)
|
||||||
logger.debug('buy_trigger: %s (pair=%s, signal=%s)', latest['date'], pair, signal)
|
if self.min_roi_reached(trade=trade, current_profit=current_profit, current_time=date):
|
||||||
return signal
|
logger.debug('Required profit reached. Selling..')
|
||||||
|
return True
|
||||||
|
|
||||||
|
if experimental.get('sell_profit_only', False):
|
||||||
|
logger.debug('Checking if trade is profitable..')
|
||||||
|
if trade.calc_profit(rate=rate) <= 0:
|
||||||
|
return False
|
||||||
|
if sell and not buy and experimental.get('use_sell_signal', False):
|
||||||
|
logger.debug('Sell signal received. Selling..')
|
||||||
|
return True
|
||||||
|
|
||||||
def plot_dataframe(dataframe: DataFrame, pair: str) -> None:
|
return False
|
||||||
|
|
||||||
|
def stop_loss_reached(self, current_profit: float) -> bool:
|
||||||
|
"""Based on current profit of the trade and configured stoploss, decides to sell or not"""
|
||||||
|
|
||||||
|
if self.strategy.stoploss is not None and current_profit < self.strategy.stoploss:
|
||||||
|
logger.debug('Stop loss hit.')
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def min_roi_reached(self, trade: Trade, current_profit: float, current_time: datetime) -> bool:
|
||||||
"""
|
"""
|
||||||
Plots the given dataframe
|
Based an earlier trade and current price and ROI configuration, decides whether bot should
|
||||||
:param dataframe: DataFrame
|
sell
|
||||||
:param pair: pair as str
|
:return True if bot should sell at current rate
|
||||||
:return: None
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import matplotlib
|
# Check if time matches and current rate is above threshold
|
||||||
|
time_diff = (current_time.timestamp() - trade.open_date.timestamp()) / 60
|
||||||
|
for duration, threshold in self.strategy.minimal_roi.items():
|
||||||
|
if time_diff <= duration:
|
||||||
|
return False
|
||||||
|
if current_profit > threshold:
|
||||||
|
return True
|
||||||
|
|
||||||
matplotlib.use("Qt5Agg")
|
return False
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
|
|
||||||
# Two subplots sharing x axis
|
def tickerdata_to_dataframe(self, tickerdata: Dict[str, List]) -> Dict[str, DataFrame]:
|
||||||
fig, (ax1, ax2) = plt.subplots(2, sharex=True)
|
"""
|
||||||
fig.suptitle(pair, fontsize=14, fontweight='bold')
|
Creates a dataframe and populates indicators for given ticker data
|
||||||
ax1.plot(dataframe.index.values, dataframe['sar'], 'g_', label='pSAR')
|
"""
|
||||||
ax1.plot(dataframe.index.values, dataframe['close'], label='close')
|
return {pair: self.populate_indicators(self.parse_ticker_dataframe(pair_data))
|
||||||
# ax1.plot(dataframe.index.values, dataframe['sell'], 'ro', label='sell')
|
for pair, pair_data in tickerdata.items()}
|
||||||
ax1.plot(dataframe.index.values, dataframe['sma'], '--', label='SMA')
|
|
||||||
ax1.plot(dataframe.index.values, dataframe['buy_price'], 'bo', label='buy')
|
|
||||||
ax1.legend()
|
|
||||||
|
|
||||||
# ax2.plot(dataframe.index.values, dataframe['adx'], label='ADX')
|
|
||||||
ax2.plot(dataframe.index.values, dataframe['mfi'], label='MFI')
|
|
||||||
# ax2.plot(dataframe.index.values, [25] * len(dataframe.index.values))
|
|
||||||
ax2.legend()
|
|
||||||
|
|
||||||
# Fine-tune figure; make subplots close to each other and hide x ticks for
|
|
||||||
# all but bottom plot.
|
|
||||||
fig.subplots_adjust(hspace=0)
|
|
||||||
plt.setp([a.get_xticklabels() for a in fig.axes[:-1]], visible=False)
|
|
||||||
plt.show()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Install PYQT5==5.9 manually if you want to test this helper function
|
|
||||||
while True:
|
|
||||||
test_pair = 'BTC_ETH'
|
|
||||||
# for pair in ['BTC_ANT', 'BTC_ETH', 'BTC_GNT', 'BTC_ETC']:
|
|
||||||
# get_buy_signal(pair)
|
|
||||||
plot_dataframe(analyze_ticker(test_pair), test_pair)
|
|
||||||
time.sleep(60)
|
|
||||||
|
|||||||
336
freqtrade/arguments.py
Normal file
336
freqtrade/arguments.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""
|
||||||
|
This module contains the argument manager class
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import arrow
|
||||||
|
from typing import List, Optional, NamedTuple
|
||||||
|
|
||||||
|
from freqtrade import __version__, constants
|
||||||
|
|
||||||
|
|
||||||
|
class TimeRange(NamedTuple):
|
||||||
|
"""
|
||||||
|
NamedTuple Defining timerange inputs.
|
||||||
|
[start/stop]type defines if [start/stop]ts shall be used.
|
||||||
|
if *type is none, don't use corresponding startvalue.
|
||||||
|
"""
|
||||||
|
starttype: Optional[str] = None
|
||||||
|
stoptype: Optional[str] = None
|
||||||
|
startts: int = 0
|
||||||
|
stopts: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class Arguments(object):
|
||||||
|
"""
|
||||||
|
Arguments Class. Manage the arguments received by the cli
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, args: List[str], description: str) -> None:
|
||||||
|
self.args = args
|
||||||
|
self.parsed_arg: Optional[argparse.Namespace] = None
|
||||||
|
self.parser = argparse.ArgumentParser(description=description)
|
||||||
|
|
||||||
|
def _load_args(self) -> None:
|
||||||
|
self.common_args_parser()
|
||||||
|
self._build_subcommands()
|
||||||
|
|
||||||
|
def get_parsed_arg(self) -> argparse.Namespace:
|
||||||
|
"""
|
||||||
|
Return the list of arguments
|
||||||
|
:return: List[str] List of arguments
|
||||||
|
"""
|
||||||
|
if self.parsed_arg is None:
|
||||||
|
self._load_args()
|
||||||
|
self.parsed_arg = self.parse_args()
|
||||||
|
|
||||||
|
return self.parsed_arg
|
||||||
|
|
||||||
|
def parse_args(self) -> argparse.Namespace:
|
||||||
|
"""
|
||||||
|
Parses given arguments and returns an argparse Namespace instance.
|
||||||
|
"""
|
||||||
|
parsed_arg = self.parser.parse_args(self.args)
|
||||||
|
|
||||||
|
return parsed_arg
|
||||||
|
|
||||||
|
def common_args_parser(self) -> None:
|
||||||
|
"""
|
||||||
|
Parses given common arguments and returns them as a parsed object.
|
||||||
|
"""
|
||||||
|
self.parser.add_argument(
|
||||||
|
'-v', '--verbose',
|
||||||
|
help='be verbose',
|
||||||
|
action='store_const',
|
||||||
|
dest='loglevel',
|
||||||
|
const=logging.DEBUG,
|
||||||
|
default=logging.INFO,
|
||||||
|
)
|
||||||
|
self.parser.add_argument(
|
||||||
|
'--version',
|
||||||
|
action='version',
|
||||||
|
version=f'%(prog)s {__version__}'
|
||||||
|
)
|
||||||
|
self.parser.add_argument(
|
||||||
|
'-c', '--config',
|
||||||
|
help='specify configuration file (default: %(default)s)',
|
||||||
|
dest='config',
|
||||||
|
default='config.json',
|
||||||
|
type=str,
|
||||||
|
metavar='PATH',
|
||||||
|
)
|
||||||
|
self.parser.add_argument(
|
||||||
|
'-d', '--datadir',
|
||||||
|
help='path to backtest data',
|
||||||
|
dest='datadir',
|
||||||
|
default=None,
|
||||||
|
type=str,
|
||||||
|
metavar='PATH',
|
||||||
|
)
|
||||||
|
self.parser.add_argument(
|
||||||
|
'-s', '--strategy',
|
||||||
|
help='specify strategy class name (default: %(default)s)',
|
||||||
|
dest='strategy',
|
||||||
|
default='DefaultStrategy',
|
||||||
|
type=str,
|
||||||
|
metavar='NAME',
|
||||||
|
)
|
||||||
|
self.parser.add_argument(
|
||||||
|
'--strategy-path',
|
||||||
|
help='specify additional strategy lookup path',
|
||||||
|
dest='strategy_path',
|
||||||
|
type=str,
|
||||||
|
metavar='PATH',
|
||||||
|
)
|
||||||
|
self.parser.add_argument(
|
||||||
|
'--dynamic-whitelist',
|
||||||
|
help='dynamically generate and update whitelist'
|
||||||
|
' based on 24h BaseVolume (default: %(const)s)',
|
||||||
|
dest='dynamic_whitelist',
|
||||||
|
const=constants.DYNAMIC_WHITELIST,
|
||||||
|
type=int,
|
||||||
|
metavar='INT',
|
||||||
|
nargs='?',
|
||||||
|
)
|
||||||
|
self.parser.add_argument(
|
||||||
|
'--db-url',
|
||||||
|
help='Override trades database URL, this is useful if dry_run is enabled'
|
||||||
|
' or in custom deployments (default: %(default)s)',
|
||||||
|
dest='db_url',
|
||||||
|
default=constants.DEFAULT_DB_PROD_URL,
|
||||||
|
type=str,
|
||||||
|
metavar='PATH',
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def backtesting_options(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""
|
||||||
|
Parses given arguments for Backtesting scripts.
|
||||||
|
"""
|
||||||
|
parser.add_argument(
|
||||||
|
'-l', '--live',
|
||||||
|
help='using live data',
|
||||||
|
action='store_true',
|
||||||
|
dest='live',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-r', '--refresh-pairs-cached',
|
||||||
|
help='refresh the pairs files in tests/testdata with the latest data from the '
|
||||||
|
'exchange. Use it if you want to run your backtesting with up-to-date data.',
|
||||||
|
action='store_true',
|
||||||
|
dest='refresh_pairs',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--export',
|
||||||
|
help='export backtest results, argument are: trades\
|
||||||
|
Example --export=trades',
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
dest='export',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--export-filename',
|
||||||
|
help='Save backtest results to this filename \
|
||||||
|
requires --export to be set as well\
|
||||||
|
Example --export-filename=user_data/backtest_data/backtest_today.json\
|
||||||
|
(default: %(default)s)',
|
||||||
|
type=str,
|
||||||
|
default=os.path.join('user_data', 'backtest_data', 'backtest-result.json'),
|
||||||
|
dest='exportfilename',
|
||||||
|
metavar='PATH',
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def optimizer_shared_options(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""
|
||||||
|
Parses given common arguments for Backtesting and Hyperopt scripts.
|
||||||
|
:param parser:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
parser.add_argument(
|
||||||
|
'-i', '--ticker-interval',
|
||||||
|
help='specify ticker interval (1m, 5m, 30m, 1h, 1d)',
|
||||||
|
dest='ticker_interval',
|
||||||
|
type=str,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--realistic-simulation',
|
||||||
|
help='uses max_open_trades from config to simulate real world limitations',
|
||||||
|
action='store_true',
|
||||||
|
dest='realistic_simulation',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--timerange',
|
||||||
|
help='specify what timerange of data to use.',
|
||||||
|
default=None,
|
||||||
|
type=str,
|
||||||
|
dest='timerange',
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def hyperopt_options(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""
|
||||||
|
Parses given arguments for Hyperopt scripts.
|
||||||
|
"""
|
||||||
|
parser.add_argument(
|
||||||
|
'-e', '--epochs',
|
||||||
|
help='specify number of epochs (default: %(default)d)',
|
||||||
|
dest='epochs',
|
||||||
|
default=constants.HYPEROPT_EPOCH,
|
||||||
|
type=int,
|
||||||
|
metavar='INT',
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'-s', '--spaces',
|
||||||
|
help='Specify which parameters to hyperopt. Space separate list. \
|
||||||
|
Default: %(default)s',
|
||||||
|
choices=['all', 'buy', 'roi', 'stoploss'],
|
||||||
|
default='all',
|
||||||
|
nargs='+',
|
||||||
|
dest='spaces',
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_subcommands(self) -> None:
|
||||||
|
"""
|
||||||
|
Builds and attaches all subcommands
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
from freqtrade.optimize import backtesting, hyperopt
|
||||||
|
|
||||||
|
subparsers = self.parser.add_subparsers(dest='subparser')
|
||||||
|
|
||||||
|
# Add backtesting subcommand
|
||||||
|
backtesting_cmd = subparsers.add_parser('backtesting', help='backtesting module')
|
||||||
|
backtesting_cmd.set_defaults(func=backtesting.start)
|
||||||
|
self.optimizer_shared_options(backtesting_cmd)
|
||||||
|
self.backtesting_options(backtesting_cmd)
|
||||||
|
|
||||||
|
# Add hyperopt subcommand
|
||||||
|
hyperopt_cmd = subparsers.add_parser('hyperopt', help='hyperopt module')
|
||||||
|
hyperopt_cmd.set_defaults(func=hyperopt.start)
|
||||||
|
self.optimizer_shared_options(hyperopt_cmd)
|
||||||
|
self.hyperopt_options(hyperopt_cmd)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_timerange(text: Optional[str]) -> TimeRange:
|
||||||
|
"""
|
||||||
|
Parse the value of the argument --timerange to determine what is the range desired
|
||||||
|
:param text: value from --timerange
|
||||||
|
:return: Start and End range period
|
||||||
|
"""
|
||||||
|
if text is None:
|
||||||
|
return TimeRange(None, None, 0, 0)
|
||||||
|
syntax = [(r'^-(\d{8})$', (None, 'date')),
|
||||||
|
(r'^(\d{8})-$', ('date', None)),
|
||||||
|
(r'^(\d{8})-(\d{8})$', ('date', 'date')),
|
||||||
|
(r'^-(\d{10})$', (None, 'date')),
|
||||||
|
(r'^(\d{10})-$', ('date', None)),
|
||||||
|
(r'^(\d{10})-(\d{10})$', ('date', 'date')),
|
||||||
|
(r'^(-\d+)$', (None, 'line')),
|
||||||
|
(r'^(\d+)-$', ('line', None)),
|
||||||
|
(r'^(\d+)-(\d+)$', ('index', 'index'))]
|
||||||
|
for rex, stype in syntax:
|
||||||
|
# Apply the regular expression to text
|
||||||
|
match = re.match(rex, text)
|
||||||
|
if match: # Regex has matched
|
||||||
|
rvals = match.groups()
|
||||||
|
index = 0
|
||||||
|
start: int = 0
|
||||||
|
stop: int = 0
|
||||||
|
if stype[0]:
|
||||||
|
starts = rvals[index]
|
||||||
|
if stype[0] == 'date' and len(starts) == 8:
|
||||||
|
start = arrow.get(starts, 'YYYYMMDD').timestamp
|
||||||
|
else:
|
||||||
|
start = int(starts)
|
||||||
|
index += 1
|
||||||
|
if stype[1]:
|
||||||
|
stops = rvals[index]
|
||||||
|
if stype[1] == 'date' and len(stops) == 8:
|
||||||
|
stop = arrow.get(stops, 'YYYYMMDD').timestamp
|
||||||
|
else:
|
||||||
|
stop = int(stops)
|
||||||
|
return TimeRange(stype[0], stype[1], start, stop)
|
||||||
|
raise Exception('Incorrect syntax for timerange "%s"' % text)
|
||||||
|
|
||||||
|
def scripts_options(self) -> None:
|
||||||
|
"""
|
||||||
|
Parses given arguments for scripts.
|
||||||
|
"""
|
||||||
|
self.parser.add_argument(
|
||||||
|
'-p', '--pair',
|
||||||
|
help='Show profits for only this pairs. Pairs are comma-separated.',
|
||||||
|
dest='pair',
|
||||||
|
default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
def testdata_dl_options(self) -> None:
|
||||||
|
"""
|
||||||
|
Parses given arguments for testdata download
|
||||||
|
"""
|
||||||
|
self.parser.add_argument(
|
||||||
|
'--pairs-file',
|
||||||
|
help='File containing a list of pairs to download',
|
||||||
|
dest='pairs_file',
|
||||||
|
default=None,
|
||||||
|
metavar='PATH',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.parser.add_argument(
|
||||||
|
'--export',
|
||||||
|
help='Export files to given dir',
|
||||||
|
dest='export',
|
||||||
|
default=None,
|
||||||
|
metavar='PATH',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.parser.add_argument(
|
||||||
|
'--days',
|
||||||
|
help='Download data for number of days',
|
||||||
|
dest='days',
|
||||||
|
type=int,
|
||||||
|
metavar='INT',
|
||||||
|
default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
self.parser.add_argument(
|
||||||
|
'--exchange',
|
||||||
|
help='Exchange name (default: %(default)s)',
|
||||||
|
dest='exchange',
|
||||||
|
type=str,
|
||||||
|
default='bittrex'
|
||||||
|
)
|
||||||
|
|
||||||
|
self.parser.add_argument(
|
||||||
|
'-t', '--timeframes',
|
||||||
|
help='Specify which tickers to download. Space separated list. \
|
||||||
|
Default: %(default)s',
|
||||||
|
choices=['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h',
|
||||||
|
'6h', '8h', '12h', '1d', '3d', '1w'],
|
||||||
|
default=['1m', '5m'],
|
||||||
|
nargs='+',
|
||||||
|
dest='timeframes',
|
||||||
|
)
|
||||||
243
freqtrade/configuration.py
Normal file
243
freqtrade/configuration.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"""
|
||||||
|
This module contains the configuration class
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from argparse import Namespace
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
from jsonschema import Draft4Validator, validate
|
||||||
|
from jsonschema.exceptions import ValidationError, best_match
|
||||||
|
import ccxt
|
||||||
|
|
||||||
|
from freqtrade import OperationalException, constants
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Configuration(object):
|
||||||
|
"""
|
||||||
|
Class to read and init the bot configuration
|
||||||
|
Reuse this class for the bot, backtesting, hyperopt and every script that required configuration
|
||||||
|
"""
|
||||||
|
def __init__(self, args: Namespace) -> None:
|
||||||
|
self.args = args
|
||||||
|
self.config: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
def load_config(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract information for sys.argv and load the bot configuration
|
||||||
|
:return: Configuration dictionary
|
||||||
|
"""
|
||||||
|
logger.info('Using config: %s ...', self.args.config)
|
||||||
|
config = self._load_config_file(self.args.config)
|
||||||
|
|
||||||
|
# Set strategy if not specified in config and or if it's non default
|
||||||
|
if self.args.strategy != constants.DEFAULT_STRATEGY or not config.get('strategy'):
|
||||||
|
config.update({'strategy': self.args.strategy})
|
||||||
|
|
||||||
|
if self.args.strategy_path:
|
||||||
|
config.update({'strategy_path': self.args.strategy_path})
|
||||||
|
|
||||||
|
# Load Common configuration
|
||||||
|
config = self._load_common_config(config)
|
||||||
|
|
||||||
|
# Load Backtesting
|
||||||
|
config = self._load_backtesting_config(config)
|
||||||
|
|
||||||
|
# Load Hyperopt
|
||||||
|
config = self._load_hyperopt_config(config)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def _load_config_file(self, path: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Loads a config file from the given path
|
||||||
|
:param path: path as str
|
||||||
|
:return: configuration as dictionary
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path) as file:
|
||||||
|
conf = json.load(file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise OperationalException(
|
||||||
|
'Config file "{}" not found!'
|
||||||
|
' Please create a config file or check whether it exists.'.format(path))
|
||||||
|
|
||||||
|
if 'internals' not in conf:
|
||||||
|
conf['internals'] = {}
|
||||||
|
logger.info('Validating configuration ...')
|
||||||
|
|
||||||
|
return self._validate_config(conf)
|
||||||
|
|
||||||
|
def _load_common_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract information for sys.argv and load common configuration
|
||||||
|
:return: configuration as dictionary
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Log level
|
||||||
|
if 'loglevel' in self.args and self.args.loglevel:
|
||||||
|
config.update({'loglevel': self.args.loglevel})
|
||||||
|
logging.basicConfig(
|
||||||
|
level=config['loglevel'],
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||||
|
)
|
||||||
|
logger.info('Log level set to %s', logging.getLevelName(config['loglevel']))
|
||||||
|
|
||||||
|
# Add dynamic_whitelist if found
|
||||||
|
if 'dynamic_whitelist' in self.args and self.args.dynamic_whitelist:
|
||||||
|
config.update({'dynamic_whitelist': self.args.dynamic_whitelist})
|
||||||
|
logger.info(
|
||||||
|
'Parameter --dynamic-whitelist detected. '
|
||||||
|
'Using dynamically generated whitelist. '
|
||||||
|
'(not applicable with Backtesting and Hyperopt)'
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.args.db_url != constants.DEFAULT_DB_PROD_URL:
|
||||||
|
config.update({'db_url': self.args.db_url})
|
||||||
|
logger.info('Parameter --db-url detected ...')
|
||||||
|
|
||||||
|
if config.get('dry_run', False):
|
||||||
|
logger.info('Dry run is enabled')
|
||||||
|
if config.get('db_url') in [None, constants.DEFAULT_DB_PROD_URL]:
|
||||||
|
# Default to in-memory db for dry_run if not specified
|
||||||
|
config['db_url'] = constants.DEFAULT_DB_DRYRUN_URL
|
||||||
|
else:
|
||||||
|
if not config.get('db_url', None):
|
||||||
|
config['db_url'] = constants.DEFAULT_DB_PROD_URL
|
||||||
|
logger.info('Dry run is disabled')
|
||||||
|
|
||||||
|
logger.info('Using DB: "{}"'.format(config['db_url']))
|
||||||
|
|
||||||
|
# Check if the exchange set by the user is supported
|
||||||
|
self.check_exchange(config)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def _create_default_datadir(self, config: Dict[str, Any]) -> str:
|
||||||
|
exchange_name = config.get('exchange', {}).get('name').lower()
|
||||||
|
default_path = os.path.join('user_data', 'data', exchange_name)
|
||||||
|
if not os.path.isdir(default_path):
|
||||||
|
os.makedirs(default_path)
|
||||||
|
logger.info(f'Created data directory: {default_path}')
|
||||||
|
return default_path
|
||||||
|
|
||||||
|
def _load_backtesting_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract information for sys.argv and load Backtesting configuration
|
||||||
|
:return: configuration as dictionary
|
||||||
|
"""
|
||||||
|
|
||||||
|
# If -i/--ticker-interval is used we override the configuration parameter
|
||||||
|
# (that will override the strategy configuration)
|
||||||
|
if 'ticker_interval' in self.args and self.args.ticker_interval:
|
||||||
|
config.update({'ticker_interval': self.args.ticker_interval})
|
||||||
|
logger.info('Parameter -i/--ticker-interval detected ...')
|
||||||
|
logger.info('Using ticker_interval: %s ...', config.get('ticker_interval'))
|
||||||
|
|
||||||
|
# If -l/--live is used we add it to the configuration
|
||||||
|
if 'live' in self.args and self.args.live:
|
||||||
|
config.update({'live': True})
|
||||||
|
logger.info('Parameter -l/--live detected ...')
|
||||||
|
|
||||||
|
# If --realistic-simulation is used we add it to the configuration
|
||||||
|
if 'realistic_simulation' in self.args and self.args.realistic_simulation:
|
||||||
|
config.update({'realistic_simulation': True})
|
||||||
|
logger.info('Parameter --realistic-simulation detected ...')
|
||||||
|
logger.info('Using max_open_trades: %s ...', config.get('max_open_trades'))
|
||||||
|
|
||||||
|
# If --timerange is used we add it to the configuration
|
||||||
|
if 'timerange' in self.args and self.args.timerange:
|
||||||
|
config.update({'timerange': self.args.timerange})
|
||||||
|
logger.info('Parameter --timerange detected: %s ...', self.args.timerange)
|
||||||
|
|
||||||
|
# If --datadir is used we add it to the configuration
|
||||||
|
if 'datadir' in self.args and self.args.datadir:
|
||||||
|
config.update({'datadir': self.args.datadir})
|
||||||
|
else:
|
||||||
|
config.update({'datadir': self._create_default_datadir(config)})
|
||||||
|
logger.info('Using data folder: %s ...', config.get('datadir'))
|
||||||
|
|
||||||
|
# If -r/--refresh-pairs-cached is used we add it to the configuration
|
||||||
|
if 'refresh_pairs' in self.args and self.args.refresh_pairs:
|
||||||
|
config.update({'refresh_pairs': True})
|
||||||
|
logger.info('Parameter -r/--refresh-pairs-cached detected ...')
|
||||||
|
|
||||||
|
# If --export is used we add it to the configuration
|
||||||
|
if 'export' in self.args and self.args.export:
|
||||||
|
config.update({'export': self.args.export})
|
||||||
|
logger.info('Parameter --export detected: %s ...', self.args.export)
|
||||||
|
|
||||||
|
# If --export-filename is used we add it to the configuration
|
||||||
|
if 'export' in config and 'exportfilename' in self.args and self.args.exportfilename:
|
||||||
|
config.update({'exportfilename': self.args.exportfilename})
|
||||||
|
logger.info('Storing backtest results to %s ...', self.args.exportfilename)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def _load_hyperopt_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Extract information for sys.argv and load Hyperopt configuration
|
||||||
|
:return: configuration as dictionary
|
||||||
|
"""
|
||||||
|
# If --realistic-simulation is used we add it to the configuration
|
||||||
|
if 'epochs' in self.args and self.args.epochs:
|
||||||
|
config.update({'epochs': self.args.epochs})
|
||||||
|
logger.info('Parameter --epochs detected ...')
|
||||||
|
logger.info('Will run Hyperopt with for %s epochs ...', config.get('epochs'))
|
||||||
|
|
||||||
|
# If --spaces is used we add it to the configuration
|
||||||
|
if 'spaces' in self.args and self.args.spaces:
|
||||||
|
config.update({'spaces': self.args.spaces})
|
||||||
|
logger.info('Parameter -s/--spaces detected: %s', config.get('spaces'))
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
def _validate_config(self, conf: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Validate the configuration follow the Config Schema
|
||||||
|
:param conf: Config in JSON format
|
||||||
|
:return: Returns the config if valid, otherwise throw an exception
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validate(conf, constants.CONF_SCHEMA)
|
||||||
|
return conf
|
||||||
|
except ValidationError as exception:
|
||||||
|
logger.critical(
|
||||||
|
'Invalid configuration. See config.json.example. Reason: %s',
|
||||||
|
exception
|
||||||
|
)
|
||||||
|
raise ValidationError(
|
||||||
|
best_match(Draft4Validator(constants.CONF_SCHEMA).iter_errors(conf)).message
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_config(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Return the config. Use this method to get the bot config
|
||||||
|
:return: Dict: Bot config
|
||||||
|
"""
|
||||||
|
if self.config is None:
|
||||||
|
self.config = self.load_config()
|
||||||
|
|
||||||
|
return self.config
|
||||||
|
|
||||||
|
def check_exchange(self, config: Dict[str, Any]) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the exchange name in the config file is supported by Freqtrade
|
||||||
|
:return: True or raised an exception if the exchange if not supported
|
||||||
|
"""
|
||||||
|
exchange = config.get('exchange', {}).get('name').lower()
|
||||||
|
if exchange not in ccxt.exchanges:
|
||||||
|
|
||||||
|
exception_msg = f'Exchange "{exchange}" not supported.\n' \
|
||||||
|
f'The following exchanges are supported: {", ".join(ccxt.exchanges)}'
|
||||||
|
|
||||||
|
logger.critical(exception_msg)
|
||||||
|
raise OperationalException(
|
||||||
|
exception_msg
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug('Exchange "%s" supported', exchange)
|
||||||
|
return True
|
||||||
144
freqtrade/constants.py
Normal file
144
freqtrade/constants.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# pragma pylint: disable=too-few-public-methods
|
||||||
|
|
||||||
|
"""
|
||||||
|
bot constants
|
||||||
|
"""
|
||||||
|
DYNAMIC_WHITELIST = 20 # pairs
|
||||||
|
PROCESS_THROTTLE_SECS = 5 # sec
|
||||||
|
TICKER_INTERVAL = 5 # min
|
||||||
|
HYPEROPT_EPOCH = 100 # epochs
|
||||||
|
RETRY_TIMEOUT = 30 # sec
|
||||||
|
DEFAULT_STRATEGY = 'DefaultStrategy'
|
||||||
|
DEFAULT_DB_PROD_URL = 'sqlite:///tradesv3.sqlite'
|
||||||
|
DEFAULT_DB_DRYRUN_URL = 'sqlite://'
|
||||||
|
UNLIMITED_STAKE_AMOUNT = 'unlimited'
|
||||||
|
|
||||||
|
|
||||||
|
TICKER_INTERVAL_MINUTES = {
|
||||||
|
'1m': 1,
|
||||||
|
'3m': 3,
|
||||||
|
'5m': 5,
|
||||||
|
'15m': 15,
|
||||||
|
'30m': 30,
|
||||||
|
'1h': 60,
|
||||||
|
'2h': 120,
|
||||||
|
'4h': 240,
|
||||||
|
'6h': 360,
|
||||||
|
'8h': 480,
|
||||||
|
'12h': 720,
|
||||||
|
'1d': 1440,
|
||||||
|
'3d': 4320,
|
||||||
|
'1w': 10080,
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPPORTED_FIAT = [
|
||||||
|
"AUD", "BRL", "CAD", "CHF", "CLP", "CNY", "CZK", "DKK",
|
||||||
|
"EUR", "GBP", "HKD", "HUF", "IDR", "ILS", "INR", "JPY",
|
||||||
|
"KRW", "MXN", "MYR", "NOK", "NZD", "PHP", "PKR", "PLN",
|
||||||
|
"RUB", "SEK", "SGD", "THB", "TRY", "TWD", "ZAR", "USD",
|
||||||
|
"BTC", "ETH", "XRP", "LTC", "BCH", "USDT"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Required json-schema for user specified config
|
||||||
|
CONF_SCHEMA = {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'max_open_trades': {'type': 'integer', 'minimum': 0},
|
||||||
|
'ticker_interval': {'type': 'string', 'enum': list(TICKER_INTERVAL_MINUTES.keys())},
|
||||||
|
'stake_currency': {'type': 'string', 'enum': ['BTC', 'ETH', 'USDT', 'EUR', 'USD']},
|
||||||
|
'stake_amount': {
|
||||||
|
"type": ["number", "string"],
|
||||||
|
"minimum": 0.0005,
|
||||||
|
"pattern": UNLIMITED_STAKE_AMOUNT
|
||||||
|
},
|
||||||
|
'fiat_display_currency': {'type': 'string', 'enum': SUPPORTED_FIAT},
|
||||||
|
'dry_run': {'type': 'boolean'},
|
||||||
|
'minimal_roi': {
|
||||||
|
'type': 'object',
|
||||||
|
'patternProperties': {
|
||||||
|
'^[0-9.]+$': {'type': 'number'}
|
||||||
|
},
|
||||||
|
'minProperties': 1
|
||||||
|
},
|
||||||
|
'stoploss': {'type': 'number', 'maximum': 0, 'exclusiveMaximum': True},
|
||||||
|
'unfilledtimeout': {'type': 'integer', 'minimum': 0},
|
||||||
|
'bid_strategy': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'ask_last_balance': {
|
||||||
|
'type': 'number',
|
||||||
|
'minimum': 0,
|
||||||
|
'maximum': 1,
|
||||||
|
'exclusiveMaximum': False
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'required': ['ask_last_balance']
|
||||||
|
},
|
||||||
|
'exchange': {'$ref': '#/definitions/exchange'},
|
||||||
|
'experimental': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'use_sell_signal': {'type': 'boolean'},
|
||||||
|
'sell_profit_only': {'type': 'boolean'},
|
||||||
|
"ignore_roi_if_buy_signal_true": {'type': 'boolean'}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'telegram': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'enabled': {'type': 'boolean'},
|
||||||
|
'token': {'type': 'string'},
|
||||||
|
'chat_id': {'type': 'string'},
|
||||||
|
},
|
||||||
|
'required': ['enabled', 'token', 'chat_id']
|
||||||
|
},
|
||||||
|
'db_url': {'type': 'string'},
|
||||||
|
'initial_state': {'type': 'string', 'enum': ['running', 'stopped']},
|
||||||
|
'internals': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'process_throttle_secs': {'type': 'number'},
|
||||||
|
'interval': {'type': 'integer'}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'definitions': {
|
||||||
|
'exchange': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'name': {'type': 'string'},
|
||||||
|
'key': {'type': 'string'},
|
||||||
|
'secret': {'type': 'string'},
|
||||||
|
'pair_whitelist': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {
|
||||||
|
'type': 'string',
|
||||||
|
'pattern': '^[0-9A-Z]+/[0-9A-Z]+$'
|
||||||
|
},
|
||||||
|
'uniqueItems': True
|
||||||
|
},
|
||||||
|
'pair_blacklist': {
|
||||||
|
'type': 'array',
|
||||||
|
'items': {
|
||||||
|
'type': 'string',
|
||||||
|
'pattern': '^[0-9A-Z]+/[0-9A-Z]+$'
|
||||||
|
},
|
||||||
|
'uniqueItems': True
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'required': ['name', 'key', 'secret', 'pair_whitelist']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'anyOf': [
|
||||||
|
{'required': ['exchange']}
|
||||||
|
],
|
||||||
|
'required': [
|
||||||
|
'max_open_trades',
|
||||||
|
'stake_currency',
|
||||||
|
'stake_amount',
|
||||||
|
'fiat_display_currency',
|
||||||
|
'dry_run',
|
||||||
|
'bid_strategy',
|
||||||
|
'telegram'
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,115 +1,417 @@
|
|||||||
import enum
|
# pragma pylint: disable=W0603
|
||||||
|
""" Cryptocurrency Exchanges support """
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from random import randint
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
import ccxt
|
||||||
import arrow
|
import arrow
|
||||||
|
|
||||||
from freqtrade.exchange.bittrex import Bittrex
|
from freqtrade import constants, OperationalException, DependencyException, TemporaryError
|
||||||
from freqtrade.exchange.interface import Exchange
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Current selected exchange
|
API_RETRY_COUNT = 4
|
||||||
EXCHANGE: Exchange = None
|
|
||||||
_CONF: dict = {}
|
|
||||||
|
|
||||||
|
|
||||||
class Exchanges(enum.Enum):
|
# Urls to exchange markets, insert quote and base with .format()
|
||||||
"""
|
_EXCHANGE_URLS = {
|
||||||
Maps supported exchange names to correspondent classes.
|
ccxt.bittrex.__name__: '/Market/Index?MarketName={quote}-{base}',
|
||||||
"""
|
ccxt.binance.__name__: '/tradeDetail.html?symbol={base}_{quote}'
|
||||||
BITTREX = Bittrex
|
}
|
||||||
|
|
||||||
|
|
||||||
def init(config: dict) -> None:
|
def retrier(f):
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
count = kwargs.pop('count', API_RETRY_COUNT)
|
||||||
|
try:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
except (TemporaryError, DependencyException) as ex:
|
||||||
|
logger.warning('%s() returned exception: "%s"', f.__name__, ex)
|
||||||
|
if count > 0:
|
||||||
|
count -= 1
|
||||||
|
kwargs.update({'count': count})
|
||||||
|
logger.warning('retrying %s() still for %s times', f.__name__, count)
|
||||||
|
return wrapper(*args, **kwargs)
|
||||||
|
else:
|
||||||
|
logger.warning('Giving up retrying: %s()', f.__name__)
|
||||||
|
raise ex
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class Exchange(object):
|
||||||
|
|
||||||
|
# Current selected exchange
|
||||||
|
_api: ccxt.Exchange = None
|
||||||
|
_conf: Dict = {}
|
||||||
|
_cached_ticker: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Holds all open sell orders for dry_run
|
||||||
|
_dry_run_open_orders: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
def __init__(self, config: dict) -> None:
|
||||||
"""
|
"""
|
||||||
Initializes this module with the given config,
|
Initializes this module with the given config,
|
||||||
it does basic validation whether the specified
|
it does basic validation whether the specified
|
||||||
exchange and pairs are valid.
|
exchange and pairs are valid.
|
||||||
:param config: config to use
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
global _CONF, EXCHANGE
|
self._conf.update(config)
|
||||||
|
|
||||||
_CONF.update(config)
|
|
||||||
|
|
||||||
if config['dry_run']:
|
if config['dry_run']:
|
||||||
logger.info('Instance is running with dry_run enabled')
|
logger.info('Instance is running with dry_run enabled')
|
||||||
|
|
||||||
exchange_config = config['exchange']
|
exchange_config = config['exchange']
|
||||||
|
self._api = self._init_ccxt(exchange_config)
|
||||||
|
|
||||||
# Find matching class for the given exchange name
|
logger.info('Using Exchange "%s"', self.name)
|
||||||
name = exchange_config['name']
|
|
||||||
try:
|
|
||||||
exchange_class = Exchanges[name.upper()].value
|
|
||||||
except KeyError:
|
|
||||||
raise RuntimeError('Exchange {} is not supported'.format(name))
|
|
||||||
|
|
||||||
EXCHANGE = exchange_class(exchange_config)
|
|
||||||
|
|
||||||
# Check if all pairs are available
|
# Check if all pairs are available
|
||||||
validate_pairs(config['exchange']['pair_whitelist'])
|
self.validate_pairs(config['exchange']['pair_whitelist'])
|
||||||
|
|
||||||
|
def _init_ccxt(self, exchange_config: dict) -> ccxt.Exchange:
|
||||||
|
"""
|
||||||
|
Initialize ccxt with given config and return valid
|
||||||
|
ccxt instance.
|
||||||
|
"""
|
||||||
|
# Find matching class for the given exchange name
|
||||||
|
name = exchange_config['name']
|
||||||
|
|
||||||
def validate_pairs(pairs: List[str]) -> None:
|
if name not in ccxt.exchanges:
|
||||||
|
raise OperationalException(f'Exchange {name} is not supported')
|
||||||
|
try:
|
||||||
|
api = getattr(ccxt, name.lower())({
|
||||||
|
'apiKey': exchange_config.get('key'),
|
||||||
|
'secret': exchange_config.get('secret'),
|
||||||
|
'password': exchange_config.get('password'),
|
||||||
|
'uid': exchange_config.get('uid', ''),
|
||||||
|
'enableRateLimit': True,
|
||||||
|
})
|
||||||
|
except (KeyError, AttributeError):
|
||||||
|
raise OperationalException(f'Exchange {name} is not supported')
|
||||||
|
|
||||||
|
return api
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
"""exchange Name (from ccxt)"""
|
||||||
|
return self._api.name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
"""exchange ccxt id"""
|
||||||
|
return self._api.id
|
||||||
|
|
||||||
|
def validate_pairs(self, pairs: List[str]) -> None:
|
||||||
"""
|
"""
|
||||||
Checks if all given pairs are tradable on the current exchange.
|
Checks if all given pairs are tradable on the current exchange.
|
||||||
Raises RuntimeError if one pair is not available.
|
Raises OperationalException if one pair is not available.
|
||||||
:param pairs: list of pairs
|
:param pairs: list of pairs
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
markets = EXCHANGE.get_markets()
|
|
||||||
for pair in pairs:
|
|
||||||
if pair not in markets:
|
|
||||||
raise RuntimeError('Pair {} is not available at {}'.format(pair, EXCHANGE.name.lower()))
|
|
||||||
|
|
||||||
|
try:
|
||||||
def buy(pair: str, rate: float, amount: float) -> str:
|
markets = self._api.load_markets()
|
||||||
if _CONF['dry_run']:
|
except ccxt.BaseError as e:
|
||||||
return 'dry_run'
|
logger.warning('Unable to validate pairs (assuming they are correct). Reason: %s', e)
|
||||||
|
|
||||||
return EXCHANGE.buy(pair, rate, amount)
|
|
||||||
|
|
||||||
|
|
||||||
def sell(pair: str, rate: float, amount: float) -> str:
|
|
||||||
if _CONF['dry_run']:
|
|
||||||
return 'dry_run'
|
|
||||||
|
|
||||||
return EXCHANGE.sell(pair, rate, amount)
|
|
||||||
|
|
||||||
|
|
||||||
def get_balance(currency: str) -> float:
|
|
||||||
if _CONF['dry_run']:
|
|
||||||
return 999.9
|
|
||||||
|
|
||||||
return EXCHANGE.get_balance(currency)
|
|
||||||
|
|
||||||
|
|
||||||
def get_ticker(pair: str) -> dict:
|
|
||||||
return EXCHANGE.get_ticker(pair)
|
|
||||||
|
|
||||||
|
|
||||||
def get_ticker_history(pair: str, minimum_date: arrow.Arrow):
|
|
||||||
return EXCHANGE.get_ticker_history(pair, minimum_date)
|
|
||||||
|
|
||||||
|
|
||||||
def cancel_order(order_id: str) -> None:
|
|
||||||
if _CONF['dry_run']:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
return EXCHANGE.cancel_order(order_id)
|
stake_cur = self._conf['stake_currency']
|
||||||
|
for pair in pairs:
|
||||||
|
# Note: ccxt has BaseCurrency/QuoteCurrency format for pairs
|
||||||
|
# TODO: add a support for having coins in BTC/USDT format
|
||||||
|
if not pair.endswith(stake_cur):
|
||||||
|
raise OperationalException(
|
||||||
|
f'Pair {pair} not compatible with stake_currency: {stake_cur}')
|
||||||
|
if pair not in markets:
|
||||||
|
raise OperationalException(
|
||||||
|
f'Pair {pair} is not available at {self.name}')
|
||||||
|
|
||||||
|
def exchange_has(self, endpoint: str) -> bool:
|
||||||
|
"""
|
||||||
|
Checks if exchange implements a specific API endpoint.
|
||||||
|
Wrapper around ccxt 'has' attribute
|
||||||
|
:param endpoint: Name of endpoint (e.g. 'fetchOHLCV', 'fetchTickers')
|
||||||
|
:return: bool
|
||||||
|
"""
|
||||||
|
return endpoint in self._api.has and self._api.has[endpoint]
|
||||||
|
|
||||||
def get_open_orders(pair: str) -> List[dict]:
|
def buy(self, pair: str, rate: float, amount: float) -> Dict:
|
||||||
if _CONF['dry_run']:
|
if self._conf['dry_run']:
|
||||||
|
order_id = f'dry_run_buy_{randint(0, 10**6)}'
|
||||||
|
self._dry_run_open_orders[order_id] = {
|
||||||
|
'pair': pair,
|
||||||
|
'price': rate,
|
||||||
|
'amount': amount,
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'buy',
|
||||||
|
'remaining': 0.0,
|
||||||
|
'datetime': arrow.utcnow().isoformat(),
|
||||||
|
'status': 'closed',
|
||||||
|
'fee': None
|
||||||
|
}
|
||||||
|
return {'id': order_id}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._api.create_limit_buy_order(pair, amount, rate)
|
||||||
|
except ccxt.InsufficientFunds as e:
|
||||||
|
raise DependencyException(
|
||||||
|
f'Insufficient funds to create limit buy order on market {pair}.'
|
||||||
|
f'Tried to buy amount {amount} at rate {rate} (total {rate*amount}).'
|
||||||
|
f'Message: {e}')
|
||||||
|
except ccxt.InvalidOrder as e:
|
||||||
|
raise DependencyException(
|
||||||
|
f'Could not create limit buy order on market {pair}.'
|
||||||
|
f'Tried to buy amount {amount} at rate {rate} (total {rate*amount}).'
|
||||||
|
f'Message: {e}')
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not place buy order due to {e.__class__.__name__}. Message: {e}')
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e)
|
||||||
|
|
||||||
|
def sell(self, pair: str, rate: float, amount: float) -> Dict:
|
||||||
|
if self._conf['dry_run']:
|
||||||
|
order_id = f'dry_run_sell_{randint(0, 10**6)}'
|
||||||
|
self._dry_run_open_orders[order_id] = {
|
||||||
|
'pair': pair,
|
||||||
|
'price': rate,
|
||||||
|
'amount': amount,
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'sell',
|
||||||
|
'remaining': 0.0,
|
||||||
|
'datetime': arrow.utcnow().isoformat(),
|
||||||
|
'status': 'closed'
|
||||||
|
}
|
||||||
|
return {'id': order_id}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._api.create_limit_sell_order(pair, amount, rate)
|
||||||
|
except ccxt.InsufficientFunds as e:
|
||||||
|
raise DependencyException(
|
||||||
|
f'Insufficient funds to create limit sell order on market {pair}.'
|
||||||
|
f'Tried to sell amount {amount} at rate {rate} (total {rate*amount}).'
|
||||||
|
f'Message: {e}')
|
||||||
|
except ccxt.InvalidOrder as e:
|
||||||
|
raise DependencyException(
|
||||||
|
f'Could not create limit sell order on market {pair}.'
|
||||||
|
f'Tried to sell amount {amount} at rate {rate} (total {rate*amount}).'
|
||||||
|
f'Message: {e}')
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not place sell order due to {e.__class__.__name__}. Message: {e}')
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e)
|
||||||
|
|
||||||
|
@retrier
|
||||||
|
def get_balance(self, currency: str) -> float:
|
||||||
|
if self._conf['dry_run']:
|
||||||
|
return 999.9
|
||||||
|
|
||||||
|
# ccxt exception is already handled by get_balances
|
||||||
|
balances = self.get_balances()
|
||||||
|
balance = balances.get(currency)
|
||||||
|
if balance is None:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not get {currency} balance due to malformed exchange response: {balances}')
|
||||||
|
return balance['free']
|
||||||
|
|
||||||
|
@retrier
|
||||||
|
def get_balances(self) -> dict:
|
||||||
|
if self._conf['dry_run']:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
balances = self._api.fetch_balance()
|
||||||
|
# Remove additional info from ccxt results
|
||||||
|
balances.pop("info", None)
|
||||||
|
balances.pop("free", None)
|
||||||
|
balances.pop("total", None)
|
||||||
|
balances.pop("used", None)
|
||||||
|
|
||||||
|
return balances
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not get balance due to {e.__class__.__name__}. Message: {e}')
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e)
|
||||||
|
|
||||||
|
@retrier
|
||||||
|
def get_tickers(self) -> Dict:
|
||||||
|
try:
|
||||||
|
return self._api.fetch_tickers()
|
||||||
|
except ccxt.NotSupported as e:
|
||||||
|
raise OperationalException(
|
||||||
|
f'Exchange {self._api.name} does not support fetching tickers in batch.'
|
||||||
|
f'Message: {e}')
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not load tickers due to {e.__class__.__name__}. Message: {e}')
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e)
|
||||||
|
|
||||||
|
@retrier
|
||||||
|
def get_ticker(self, pair: str, refresh: Optional[bool] = True) -> dict:
|
||||||
|
if refresh or pair not in self._cached_ticker.keys():
|
||||||
|
try:
|
||||||
|
data = self._api.fetch_ticker(pair)
|
||||||
|
try:
|
||||||
|
self._cached_ticker[pair] = {
|
||||||
|
'bid': float(data['bid']),
|
||||||
|
'ask': float(data['ask']),
|
||||||
|
}
|
||||||
|
except KeyError:
|
||||||
|
logger.debug("Could not cache ticker data for %s", pair)
|
||||||
|
return data
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not load ticker history due to {e.__class__.__name__}. Message: {e}')
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e)
|
||||||
|
else:
|
||||||
|
logger.info("returning cached ticker-data for %s", pair)
|
||||||
|
return self._cached_ticker[pair]
|
||||||
|
|
||||||
|
@retrier
|
||||||
|
def get_ticker_history(self, pair: str, tick_interval: str,
|
||||||
|
since_ms: Optional[int] = None) -> List[Dict]:
|
||||||
|
try:
|
||||||
|
# last item should be in the time interval [now - tick_interval, now]
|
||||||
|
till_time_ms = arrow.utcnow().shift(
|
||||||
|
minutes=-constants.TICKER_INTERVAL_MINUTES[tick_interval]
|
||||||
|
).timestamp * 1000
|
||||||
|
# it looks as if some exchanges return cached data
|
||||||
|
# and they update it one in several minute, so 10 mins interval
|
||||||
|
# is necessary to skeep downloading of an empty array when all
|
||||||
|
# chached data was already downloaded
|
||||||
|
till_time_ms = min(till_time_ms, arrow.utcnow().shift(minutes=-10).timestamp * 1000)
|
||||||
|
|
||||||
|
data: List[Dict[Any, Any]] = []
|
||||||
|
while not since_ms or since_ms < till_time_ms:
|
||||||
|
data_part = self._api.fetch_ohlcv(pair, timeframe=tick_interval, since=since_ms)
|
||||||
|
|
||||||
|
# Because some exchange sort Tickers ASC and other DESC.
|
||||||
|
# Ex: Bittrex returns a list of tickers ASC (oldest first, newest last)
|
||||||
|
# when GDAX returns a list of tickers DESC (newest first, oldest last)
|
||||||
|
data_part = sorted(data_part, key=lambda x: x[0])
|
||||||
|
|
||||||
|
if not data_part:
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.debug('Downloaded data for %s time range [%s, %s]',
|
||||||
|
pair,
|
||||||
|
arrow.get(data_part[0][0] / 1000).format(),
|
||||||
|
arrow.get(data_part[-1][0] / 1000).format())
|
||||||
|
|
||||||
|
data.extend(data_part)
|
||||||
|
since_ms = data[-1][0] + 1
|
||||||
|
|
||||||
|
return data
|
||||||
|
except ccxt.NotSupported as e:
|
||||||
|
raise OperationalException(
|
||||||
|
f'Exchange {self._api.name} does not support fetching historical candlestick data.'
|
||||||
|
f'Message: {e}')
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not load ticker history due to {e.__class__.__name__}. Message: {e}')
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(f'Could not fetch ticker data. Msg: {e}')
|
||||||
|
|
||||||
|
@retrier
|
||||||
|
def cancel_order(self, order_id: str, pair: str) -> None:
|
||||||
|
if self._conf['dry_run']:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._api.cancel_order(order_id, pair)
|
||||||
|
except ccxt.InvalidOrder as e:
|
||||||
|
raise DependencyException(
|
||||||
|
f'Could not cancel order. Message: {e}')
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not cancel order due to {e.__class__.__name__}. Message: {e}')
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e)
|
||||||
|
|
||||||
|
@retrier
|
||||||
|
def get_order(self, order_id: str, pair: str) -> Dict:
|
||||||
|
if self._conf['dry_run']:
|
||||||
|
order = self._dry_run_open_orders[order_id]
|
||||||
|
order.update({
|
||||||
|
'id': order_id
|
||||||
|
})
|
||||||
|
return order
|
||||||
|
try:
|
||||||
|
return self._api.fetch_order(order_id, pair)
|
||||||
|
except ccxt.InvalidOrder as e:
|
||||||
|
raise DependencyException(
|
||||||
|
f'Could not get order. Message: {e}')
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not get order due to {e.__class__.__name__}. Message: {e}')
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e)
|
||||||
|
|
||||||
|
@retrier
|
||||||
|
def get_trades_for_order(self, order_id: str, pair: str, since: datetime) -> List:
|
||||||
|
if self._conf['dry_run']:
|
||||||
return []
|
return []
|
||||||
|
if not self.exchange_has('fetchMyTrades'):
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
my_trades = self._api.fetch_my_trades(pair, since.timestamp())
|
||||||
|
matched_trades = [trade for trade in my_trades if trade['order'] == order_id]
|
||||||
|
|
||||||
return EXCHANGE.get_open_orders(pair)
|
return matched_trades
|
||||||
|
|
||||||
|
except ccxt.NetworkError as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not get trades due to networking error. Message: {e}')
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e)
|
||||||
|
|
||||||
def get_pair_detail_url(pair: str) -> str:
|
def get_pair_detail_url(self, pair: str) -> str:
|
||||||
return EXCHANGE.get_pair_detail_url(pair)
|
try:
|
||||||
|
url_base = self._api.urls.get('www')
|
||||||
|
base, quote = pair.split('/')
|
||||||
|
|
||||||
|
return url_base + _EXCHANGE_URLS[self._api.id].format(base=base, quote=quote)
|
||||||
|
except KeyError:
|
||||||
|
logger.warning('Could not get exchange url for %s', self.name)
|
||||||
|
return ""
|
||||||
|
|
||||||
def get_markets() -> List[str]:
|
@retrier
|
||||||
return EXCHANGE.get_markets()
|
def get_markets(self) -> List[dict]:
|
||||||
|
try:
|
||||||
|
return self._api.fetch_markets()
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not load markets due to {e.__class__.__name__}. Message: {e}')
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e)
|
||||||
|
|
||||||
|
@retrier
|
||||||
|
def get_fee(self, symbol='ETH/BTC', type='', side='', amount=1,
|
||||||
|
price=1, taker_or_maker='maker') -> float:
|
||||||
|
try:
|
||||||
|
# validate that markets are loaded before trying to get fee
|
||||||
|
if self._api.markets is None or len(self._api.markets) == 0:
|
||||||
|
self._api.load_markets()
|
||||||
|
|
||||||
|
return self._api.calculate_fee(symbol=symbol, type=type, side=side, amount=amount,
|
||||||
|
price=price, takerOrMaker=taker_or_maker)['rate']
|
||||||
|
except (ccxt.NetworkError, ccxt.ExchangeError) as e:
|
||||||
|
raise TemporaryError(
|
||||||
|
f'Could not get fee info due to {e.__class__.__name__}. Message: {e}')
|
||||||
|
except ccxt.BaseError as e:
|
||||||
|
raise OperationalException(e)
|
||||||
|
|
||||||
|
def get_amount_lots(self, pair: str, amount: float) -> float:
|
||||||
|
"""
|
||||||
|
get buyable amount rounding, ..
|
||||||
|
"""
|
||||||
|
# validate that markets are loaded before trying to get fee
|
||||||
|
if not self._api.markets:
|
||||||
|
self._api.load_markets()
|
||||||
|
return self._api.amount_to_lots(pair, amount)
|
||||||
|
|||||||
@@ -1,120 +0,0 @@
|
|||||||
import logging
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
import arrow
|
|
||||||
import requests
|
|
||||||
from bittrex.bittrex import Bittrex as _Bittrex
|
|
||||||
|
|
||||||
from freqtrade.exchange.interface import Exchange
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_API: _Bittrex = None
|
|
||||||
_EXCHANGE_CONF: dict = {}
|
|
||||||
|
|
||||||
|
|
||||||
class Bittrex(Exchange):
|
|
||||||
"""
|
|
||||||
Bittrex API wrapper.
|
|
||||||
"""
|
|
||||||
# Base URL and API endpoints
|
|
||||||
BASE_URL: str = 'https://www.bittrex.com'
|
|
||||||
TICKER_METHOD: str = BASE_URL + '/Api/v2.0/pub/market/GetTicks'
|
|
||||||
PAIR_DETAIL_METHOD: str = BASE_URL + '/Market/Index'
|
|
||||||
# Ticker inveral
|
|
||||||
TICKER_INTERVAL: str = 'fiveMin'
|
|
||||||
# Sleep time to avoid rate limits, used in the main loop
|
|
||||||
SLEEP_TIME: float = 25
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self.__class__.__name__
|
|
||||||
|
|
||||||
@property
|
|
||||||
def sleep_time(self) -> float:
|
|
||||||
return self.SLEEP_TIME
|
|
||||||
|
|
||||||
def __init__(self, config: dict) -> None:
|
|
||||||
global _API, _EXCHANGE_CONF
|
|
||||||
|
|
||||||
_EXCHANGE_CONF.update(config)
|
|
||||||
_API = _Bittrex(api_key=_EXCHANGE_CONF['key'], api_secret=_EXCHANGE_CONF['secret'])
|
|
||||||
|
|
||||||
# Check if all pairs are available
|
|
||||||
markets = self.get_markets()
|
|
||||||
exchange_name = self.name
|
|
||||||
for pair in _EXCHANGE_CONF['pair_whitelist']:
|
|
||||||
if pair not in markets:
|
|
||||||
raise RuntimeError('Pair {} is not available at {}'.format(pair, exchange_name))
|
|
||||||
|
|
||||||
def buy(self, pair: str, rate: float, amount: float) -> str:
|
|
||||||
data = _API.buy_limit(pair.replace('_', '-'), amount, rate)
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
|
||||||
return data['result']['uuid']
|
|
||||||
|
|
||||||
def sell(self, pair: str, rate: float, amount: float) -> str:
|
|
||||||
data = _API.sell_limit(pair.replace('_', '-'), amount, rate)
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
|
||||||
return data['result']['uuid']
|
|
||||||
|
|
||||||
def get_balance(self, currency: str) -> float:
|
|
||||||
data = _API.get_balance(currency)
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
|
||||||
return float(data['result']['Balance'] or 0.0)
|
|
||||||
|
|
||||||
def get_ticker(self, pair: str) -> dict:
|
|
||||||
data = _API.get_ticker(pair.replace('_', '-'))
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
|
||||||
return {
|
|
||||||
'bid': float(data['result']['Bid']),
|
|
||||||
'ask': float(data['result']['Ask']),
|
|
||||||
'last': float(data['result']['Last']),
|
|
||||||
}
|
|
||||||
|
|
||||||
def get_ticker_history(self, pair: str, minimum_date: Optional[arrow.Arrow] = None):
|
|
||||||
url = self.TICKER_METHOD
|
|
||||||
headers = {
|
|
||||||
# TODO: Set as global setting
|
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/61.0.3163.100 Safari/537.36'
|
|
||||||
}
|
|
||||||
params = {
|
|
||||||
'marketName': pair.replace('_', '-'),
|
|
||||||
'tickInterval': self.TICKER_INTERVAL,
|
|
||||||
# TODO: Timestamp has no effect on API response
|
|
||||||
'_': minimum_date.timestamp * 1000
|
|
||||||
}
|
|
||||||
data = requests.get(url, params=params, headers=headers).json()
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
|
||||||
return data
|
|
||||||
|
|
||||||
def cancel_order(self, order_id: str) -> None:
|
|
||||||
data = _API.cancel(order_id)
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
|
||||||
|
|
||||||
def get_open_orders(self, pair: str) -> List[dict]:
|
|
||||||
data = _API.get_open_orders(pair.replace('_', '-'))
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
|
||||||
return [{
|
|
||||||
'id': entry['OrderUuid'],
|
|
||||||
'type': entry['OrderType'],
|
|
||||||
'opened': entry['Opened'],
|
|
||||||
'rate': entry['PricePerUnit'],
|
|
||||||
'amount': entry['Quantity'],
|
|
||||||
'remaining': entry['QuantityRemaining'],
|
|
||||||
} for entry in data['result']]
|
|
||||||
|
|
||||||
def get_pair_detail_url(self, pair: str) -> str:
|
|
||||||
return self.PAIR_DETAIL_METHOD + '?MarketName={}'.format(pair.replace('_', '-'))
|
|
||||||
|
|
||||||
def get_markets(self) -> List[str]:
|
|
||||||
data = _API.get_markets()
|
|
||||||
if not data['success']:
|
|
||||||
raise RuntimeError('{}: {}'.format(self.name.upper(), data['message']))
|
|
||||||
return [m['MarketName'].replace('-', '_') for m in data['result']]
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
from abc import ABC, abstractmethod
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
import arrow
|
|
||||||
|
|
||||||
|
|
||||||
class Exchange(ABC):
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
"""
|
|
||||||
Name of the exchange.
|
|
||||||
:return: str representation of the class name
|
|
||||||
"""
|
|
||||||
return self.__class__.__name__
|
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
|
||||||
def sleep_time(self) -> float:
|
|
||||||
"""
|
|
||||||
Sleep time in seconds for the main loop to avoid API rate limits.
|
|
||||||
:return: float
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def buy(self, pair: str, rate: float, amount: float) -> str:
|
|
||||||
"""
|
|
||||||
Places a limit buy order.
|
|
||||||
:param pair: Pair as str, format: BTC_ETH
|
|
||||||
:param rate: Rate limit for order
|
|
||||||
:param amount: The amount to purchase
|
|
||||||
:return: order_id of the placed buy order
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def sell(self, pair: str, rate: float, amount: float) -> str:
|
|
||||||
"""
|
|
||||||
Places a limit sell order.
|
|
||||||
:param pair: Pair as str, format: BTC_ETH
|
|
||||||
:param rate: Rate limit for order
|
|
||||||
:param amount: The amount to sell
|
|
||||||
:return: order_id of the placed sell order
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_balance(self, currency: str) -> float:
|
|
||||||
"""
|
|
||||||
Gets account balance.
|
|
||||||
:param currency: Currency as str, format: BTC
|
|
||||||
:return: float
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_ticker(self, pair: str) -> dict:
|
|
||||||
"""
|
|
||||||
Gets ticker for given pair.
|
|
||||||
:param pair: Pair as str, format: BTC_ETC
|
|
||||||
:return: dict, format: {
|
|
||||||
'bid': float,
|
|
||||||
'ask': float,
|
|
||||||
'last': float
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_ticker_history(self, pair: str, minimum_date: Optional[arrow.Arrow] = None) -> dict:
|
|
||||||
"""
|
|
||||||
Gets ticker history for given pair.
|
|
||||||
:param pair: Pair as str, format: BTC_ETC
|
|
||||||
:param minimum_date: Minimum date (optional)
|
|
||||||
:return: dict, format: {
|
|
||||||
'success': bool,
|
|
||||||
'message': str,
|
|
||||||
'result': [
|
|
||||||
{
|
|
||||||
'O': float, (Open)
|
|
||||||
'H': float, (High)
|
|
||||||
'L': float, (Low)
|
|
||||||
'C': float, (Close)
|
|
||||||
'V': float, (Volume)
|
|
||||||
'T': datetime, (Time)
|
|
||||||
'BV': float, (Base Volume)
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def cancel_order(self, order_id: str) -> None:
|
|
||||||
"""
|
|
||||||
Cancels order for given order_id.
|
|
||||||
:param order_id: ID as str
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_open_orders(self, pair: str) -> List[dict]:
|
|
||||||
"""
|
|
||||||
Gets all open orders for given pair.
|
|
||||||
:param pair: Pair as str, format: BTC_ETC
|
|
||||||
:return: List of dicts, format: [
|
|
||||||
{
|
|
||||||
'id': str,
|
|
||||||
'type': str,
|
|
||||||
'opened': datetime,
|
|
||||||
'rate': float,
|
|
||||||
'amount': float,
|
|
||||||
'remaining': int,
|
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_pair_detail_url(self, pair: str) -> str:
|
|
||||||
"""
|
|
||||||
Returns the market detail url for the given pair.
|
|
||||||
:param pair: Pair as str, format: BTC_ETC
|
|
||||||
:return: URL as str
|
|
||||||
"""
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_markets(self) -> List[str]:
|
|
||||||
"""
|
|
||||||
Returns all available markets.
|
|
||||||
:return: List of all available pairs
|
|
||||||
"""
|
|
||||||
205
freqtrade/fiat_convert.py
Normal file
205
freqtrade/fiat_convert.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""
|
||||||
|
Module that define classes to convert Crypto-currency to FIAT
|
||||||
|
e.g BTC to USD
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Dict, List
|
||||||
|
|
||||||
|
from coinmarketcap import Market
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
from freqtrade.constants import SUPPORTED_FIAT
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoFiat(object):
|
||||||
|
"""
|
||||||
|
Object to describe what is the price of Crypto-currency in a FIAT
|
||||||
|
"""
|
||||||
|
# Constants
|
||||||
|
CACHE_DURATION = 6 * 60 * 60 # 6 hours
|
||||||
|
|
||||||
|
def __init__(self, crypto_symbol: str, fiat_symbol: str, price: float) -> None:
|
||||||
|
"""
|
||||||
|
Create an object that will contains the price for a crypto-currency in fiat
|
||||||
|
:param crypto_symbol: Crypto-currency you want to convert (e.g BTC)
|
||||||
|
:param fiat_symbol: FIAT currency you want to convert to (e.g USD)
|
||||||
|
:param price: Price in FIAT
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Public attributes
|
||||||
|
self.crypto_symbol = None
|
||||||
|
self.fiat_symbol = None
|
||||||
|
self.price = 0.0
|
||||||
|
|
||||||
|
# Private attributes
|
||||||
|
self._expiration = 0.0
|
||||||
|
|
||||||
|
self.crypto_symbol = crypto_symbol.upper()
|
||||||
|
self.fiat_symbol = fiat_symbol.upper()
|
||||||
|
self.set_price(price=price)
|
||||||
|
|
||||||
|
def set_price(self, price: float) -> None:
|
||||||
|
"""
|
||||||
|
Set the price of the Crypto-currency in FIAT and set the expiration time
|
||||||
|
:param price: Price of the current Crypto currency in the fiat
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
self.price = price
|
||||||
|
self._expiration = time.time() + self.CACHE_DURATION
|
||||||
|
|
||||||
|
def is_expired(self) -> bool:
|
||||||
|
"""
|
||||||
|
Return if the current price is still valid or needs to be refreshed
|
||||||
|
:return: bool, true the price is expired and needs to be refreshed, false the price is
|
||||||
|
still valid
|
||||||
|
"""
|
||||||
|
return self._expiration - time.time() <= 0
|
||||||
|
|
||||||
|
|
||||||
|
class CryptoToFiatConverter(object):
|
||||||
|
"""
|
||||||
|
Main class to initiate Crypto to FIAT.
|
||||||
|
This object contains a list of pair Crypto, FIAT
|
||||||
|
This object is also a Singleton
|
||||||
|
"""
|
||||||
|
__instance = None
|
||||||
|
_coinmarketcap: Market = None
|
||||||
|
|
||||||
|
_cryptomap: Dict = {}
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if CryptoToFiatConverter.__instance is None:
|
||||||
|
CryptoToFiatConverter.__instance = object.__new__(cls)
|
||||||
|
try:
|
||||||
|
CryptoToFiatConverter._coinmarketcap = Market()
|
||||||
|
except BaseException:
|
||||||
|
CryptoToFiatConverter._coinmarketcap = None
|
||||||
|
return CryptoToFiatConverter.__instance
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._pairs: List[CryptoFiat] = []
|
||||||
|
self._load_cryptomap()
|
||||||
|
|
||||||
|
def _load_cryptomap(self) -> None:
|
||||||
|
try:
|
||||||
|
coinlistings = self._coinmarketcap.listings()
|
||||||
|
self._cryptomap = dict(map(lambda coin: (coin["symbol"], str(coin["id"])),
|
||||||
|
coinlistings["data"]))
|
||||||
|
except (ValueError, RequestException) as exception:
|
||||||
|
logger.error(
|
||||||
|
"Could not load FIAT Cryptocurrency map for the following problem: %s",
|
||||||
|
exception
|
||||||
|
)
|
||||||
|
|
||||||
|
def convert_amount(self, crypto_amount: float, crypto_symbol: str, fiat_symbol: str) -> float:
|
||||||
|
"""
|
||||||
|
Convert an amount of crypto-currency to fiat
|
||||||
|
:param crypto_amount: amount of crypto-currency to convert
|
||||||
|
:param crypto_symbol: crypto-currency used
|
||||||
|
:param fiat_symbol: fiat to convert to
|
||||||
|
:return: float, value in fiat of the crypto-currency amount
|
||||||
|
"""
|
||||||
|
if crypto_symbol == fiat_symbol:
|
||||||
|
return crypto_amount
|
||||||
|
price = self.get_price(crypto_symbol=crypto_symbol, fiat_symbol=fiat_symbol)
|
||||||
|
return float(crypto_amount) * float(price)
|
||||||
|
|
||||||
|
def get_price(self, crypto_symbol: str, fiat_symbol: str) -> float:
|
||||||
|
"""
|
||||||
|
Return the price of the Crypto-currency in Fiat
|
||||||
|
:param crypto_symbol: Crypto-currency you want to convert (e.g BTC)
|
||||||
|
:param fiat_symbol: FIAT currency you want to convert to (e.g USD)
|
||||||
|
:return: Price in FIAT
|
||||||
|
"""
|
||||||
|
crypto_symbol = crypto_symbol.upper()
|
||||||
|
fiat_symbol = fiat_symbol.upper()
|
||||||
|
|
||||||
|
# Check if the fiat convertion you want is supported
|
||||||
|
if not self._is_supported_fiat(fiat=fiat_symbol):
|
||||||
|
raise ValueError(f'The fiat {fiat_symbol} is not supported.')
|
||||||
|
|
||||||
|
# Get the pair that interest us and return the price in fiat
|
||||||
|
for pair in self._pairs:
|
||||||
|
if pair.crypto_symbol == crypto_symbol and pair.fiat_symbol == fiat_symbol:
|
||||||
|
# If the price is expired we refresh it, avoid to call the API all the time
|
||||||
|
if pair.is_expired():
|
||||||
|
pair.set_price(
|
||||||
|
price=self._find_price(
|
||||||
|
crypto_symbol=pair.crypto_symbol,
|
||||||
|
fiat_symbol=pair.fiat_symbol
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# return the last price we have for this pair
|
||||||
|
return pair.price
|
||||||
|
|
||||||
|
# The pair does not exist, so we create it and return the price
|
||||||
|
return self._add_pair(
|
||||||
|
crypto_symbol=crypto_symbol,
|
||||||
|
fiat_symbol=fiat_symbol,
|
||||||
|
price=self._find_price(
|
||||||
|
crypto_symbol=crypto_symbol,
|
||||||
|
fiat_symbol=fiat_symbol
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _add_pair(self, crypto_symbol: str, fiat_symbol: str, price: float) -> float:
|
||||||
|
"""
|
||||||
|
:param crypto_symbol: Crypto-currency you want to convert (e.g BTC)
|
||||||
|
:param fiat_symbol: FIAT currency you want to convert to (e.g USD)
|
||||||
|
:return: price in FIAT
|
||||||
|
"""
|
||||||
|
self._pairs.append(
|
||||||
|
CryptoFiat(
|
||||||
|
crypto_symbol=crypto_symbol,
|
||||||
|
fiat_symbol=fiat_symbol,
|
||||||
|
price=price
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return price
|
||||||
|
|
||||||
|
def _is_supported_fiat(self, fiat: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if the FIAT your want to convert to is supported
|
||||||
|
:param fiat: FIAT to check (e.g USD)
|
||||||
|
:return: bool, True supported, False not supported
|
||||||
|
"""
|
||||||
|
|
||||||
|
fiat = fiat.upper()
|
||||||
|
|
||||||
|
return fiat in SUPPORTED_FIAT
|
||||||
|
|
||||||
|
def _find_price(self, crypto_symbol: str, fiat_symbol: str) -> float:
|
||||||
|
"""
|
||||||
|
Call CoinMarketCap API to retrieve the price in the FIAT
|
||||||
|
:param crypto_symbol: Crypto-currency you want to convert (e.g BTC)
|
||||||
|
:param fiat_symbol: FIAT currency you want to convert to (e.g USD)
|
||||||
|
:return: float, price of the crypto-currency in Fiat
|
||||||
|
"""
|
||||||
|
# Check if the fiat convertion you want is supported
|
||||||
|
if not self._is_supported_fiat(fiat=fiat_symbol):
|
||||||
|
raise ValueError(f'The fiat {fiat_symbol} is not supported.')
|
||||||
|
|
||||||
|
# No need to convert if both crypto and fiat are the same
|
||||||
|
if crypto_symbol == fiat_symbol:
|
||||||
|
return 1.0
|
||||||
|
|
||||||
|
if crypto_symbol not in self._cryptomap:
|
||||||
|
# return 0 for unsupported stake currencies (fiat-convert should not break the bot)
|
||||||
|
logger.warning("unsupported crypto-symbol %s - returning 0.0", crypto_symbol)
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
return float(
|
||||||
|
self._coinmarketcap.ticker(
|
||||||
|
currency=self._cryptomap[crypto_symbol],
|
||||||
|
convert=fiat_symbol
|
||||||
|
)['data']['quotes'][fiat_symbol.upper()]['price']
|
||||||
|
)
|
||||||
|
except BaseException as exception:
|
||||||
|
logger.error("Error in _find_price: %s", exception)
|
||||||
|
return 0.0
|
||||||
631
freqtrade/freqtradebot.py
Normal file
631
freqtrade/freqtradebot.py
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
"""
|
||||||
|
Freqtrade is the main module of this bot. It contains the class Freqtrade()
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, List, Optional, Any, Callable
|
||||||
|
|
||||||
|
import arrow
|
||||||
|
import requests
|
||||||
|
from cachetools import TTLCache, cached
|
||||||
|
|
||||||
|
from freqtrade import (
|
||||||
|
DependencyException, OperationalException, TemporaryError, persistence, __version__,
|
||||||
|
)
|
||||||
|
from freqtrade import constants
|
||||||
|
from freqtrade.analyze import Analyze
|
||||||
|
from freqtrade.exchange import Exchange
|
||||||
|
from freqtrade.fiat_convert import CryptoToFiatConverter
|
||||||
|
from freqtrade.persistence import Trade
|
||||||
|
from freqtrade.rpc.rpc_manager import RPCManager
|
||||||
|
from freqtrade.state import State
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class FreqtradeBot(object):
|
||||||
|
"""
|
||||||
|
Freqtrade is the main class of the bot.
|
||||||
|
This is from here the bot start its logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Dict[str, Any])-> None:
|
||||||
|
"""
|
||||||
|
Init all variables and object the bot need to work
|
||||||
|
:param config: configuration dict, you can use the Configuration.get_config()
|
||||||
|
method to get the config dict.
|
||||||
|
"""
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Starting freqtrade %s',
|
||||||
|
__version__,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Init bot states
|
||||||
|
self.state = State.STOPPED
|
||||||
|
|
||||||
|
# Init objects
|
||||||
|
self.config = config
|
||||||
|
self.analyze = Analyze(self.config)
|
||||||
|
self.fiat_converter = CryptoToFiatConverter()
|
||||||
|
self.rpc: RPCManager = RPCManager(self)
|
||||||
|
self.persistence = None
|
||||||
|
self.exchange = Exchange(self.config)
|
||||||
|
|
||||||
|
self._init_modules()
|
||||||
|
|
||||||
|
def _init_modules(self) -> None:
|
||||||
|
"""
|
||||||
|
Initializes all modules and updates the config
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
# Initialize all modules
|
||||||
|
|
||||||
|
persistence.init(self.config)
|
||||||
|
|
||||||
|
# Set initial application state
|
||||||
|
initial_state = self.config.get('initial_state')
|
||||||
|
|
||||||
|
if initial_state:
|
||||||
|
self.state = State[initial_state.upper()]
|
||||||
|
else:
|
||||||
|
self.state = State.STOPPED
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
"""
|
||||||
|
Cleanup pending resources on an already stopped bot
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
logger.info('Cleaning up modules ...')
|
||||||
|
self.rpc.cleanup()
|
||||||
|
persistence.cleanup()
|
||||||
|
|
||||||
|
def worker(self, old_state: State = None) -> State:
|
||||||
|
"""
|
||||||
|
Trading routine that must be run at each loop
|
||||||
|
:param old_state: the previous service state from the previous call
|
||||||
|
:return: current service state
|
||||||
|
"""
|
||||||
|
# Log state transition
|
||||||
|
state = self.state
|
||||||
|
if state != old_state:
|
||||||
|
self.rpc.send_msg(f'*Status:* `{state.name.lower()}`')
|
||||||
|
logger.info('Changing state to: %s', state.name)
|
||||||
|
|
||||||
|
if state == State.STOPPED:
|
||||||
|
time.sleep(1)
|
||||||
|
elif state == State.RUNNING:
|
||||||
|
min_secs = self.config.get('internals', {}).get(
|
||||||
|
'process_throttle_secs',
|
||||||
|
constants.PROCESS_THROTTLE_SECS
|
||||||
|
)
|
||||||
|
|
||||||
|
nb_assets = self.config.get('dynamic_whitelist', None)
|
||||||
|
|
||||||
|
self._throttle(func=self._process,
|
||||||
|
min_secs=min_secs,
|
||||||
|
nb_assets=nb_assets)
|
||||||
|
return state
|
||||||
|
|
||||||
|
def _throttle(self, func: Callable[..., Any], min_secs: float, *args, **kwargs) -> Any:
|
||||||
|
"""
|
||||||
|
Throttles the given callable that it
|
||||||
|
takes at least `min_secs` to finish execution.
|
||||||
|
:param func: Any callable
|
||||||
|
:param min_secs: minimum execution time in seconds
|
||||||
|
:return: Any
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
end = time.time()
|
||||||
|
duration = max(min_secs - (end - start), 0.0)
|
||||||
|
logger.debug('Throttling %s for %.2f seconds', func.__name__, duration)
|
||||||
|
time.sleep(duration)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _process(self, nb_assets: Optional[int] = 0) -> bool:
|
||||||
|
"""
|
||||||
|
Queries the persistence layer for open trades and handles them,
|
||||||
|
otherwise a new trade is created.
|
||||||
|
:param: nb_assets: the maximum number of pairs to be traded at the same time
|
||||||
|
:return: True if one or more trades has been created or closed, False otherwise
|
||||||
|
"""
|
||||||
|
state_changed = False
|
||||||
|
try:
|
||||||
|
# Refresh whitelist based on wallet maintenance
|
||||||
|
sanitized_list = self._refresh_whitelist(
|
||||||
|
self._gen_pair_whitelist(
|
||||||
|
self.config['stake_currency']
|
||||||
|
) if nb_assets else self.config['exchange']['pair_whitelist']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep only the subsets of pairs wanted (up to nb_assets)
|
||||||
|
final_list = sanitized_list[:nb_assets] if nb_assets else sanitized_list
|
||||||
|
self.config['exchange']['pair_whitelist'] = final_list
|
||||||
|
|
||||||
|
# Query trades from persistence layer
|
||||||
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
|
|
||||||
|
# First process current opened trades
|
||||||
|
for trade in trades:
|
||||||
|
state_changed |= self.process_maybe_execute_sell(trade)
|
||||||
|
|
||||||
|
# Then looking for buy opportunities
|
||||||
|
if len(trades) < self.config['max_open_trades']:
|
||||||
|
state_changed = self.process_maybe_execute_buy()
|
||||||
|
|
||||||
|
if 'unfilledtimeout' in self.config:
|
||||||
|
# Check and handle any timed out open orders
|
||||||
|
self.check_handle_timedout(self.config['unfilledtimeout'])
|
||||||
|
Trade.session.flush()
|
||||||
|
|
||||||
|
except TemporaryError as error:
|
||||||
|
logger.warning('%s, retrying in 30 seconds...', error)
|
||||||
|
time.sleep(constants.RETRY_TIMEOUT)
|
||||||
|
except OperationalException:
|
||||||
|
tb = traceback.format_exc()
|
||||||
|
hint = 'Issue `/start` if you think it is safe to restart.'
|
||||||
|
self.rpc.send_msg(
|
||||||
|
f'*Status:* OperationalException:\n```\n{tb}```{hint}'
|
||||||
|
)
|
||||||
|
logger.exception('OperationalException. Stopping trader ...')
|
||||||
|
self.state = State.STOPPED
|
||||||
|
return state_changed
|
||||||
|
|
||||||
|
@cached(TTLCache(maxsize=1, ttl=1800))
|
||||||
|
def _gen_pair_whitelist(self, base_currency: str, key: str = 'quoteVolume') -> List[str]:
|
||||||
|
"""
|
||||||
|
Updates the whitelist with with a dynamically generated list
|
||||||
|
:param base_currency: base currency as str
|
||||||
|
:param key: sort key (defaults to 'quoteVolume')
|
||||||
|
:return: List of pairs
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self.exchange.exchange_has('fetchTickers'):
|
||||||
|
raise OperationalException(
|
||||||
|
'Exchange does not support dynamic whitelist.'
|
||||||
|
'Please edit your config and restart the bot'
|
||||||
|
)
|
||||||
|
|
||||||
|
tickers = self.exchange.get_tickers()
|
||||||
|
# check length so that we make sure that '/' is actually in the string
|
||||||
|
tickers = [v for k, v in tickers.items()
|
||||||
|
if len(k.split('/')) == 2 and k.split('/')[1] == base_currency]
|
||||||
|
|
||||||
|
sorted_tickers = sorted(tickers, reverse=True, key=lambda t: t[key])
|
||||||
|
pairs = [s['symbol'] for s in sorted_tickers]
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
def _refresh_whitelist(self, whitelist: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Check available markets and remove pair from whitelist if necessary
|
||||||
|
:param whitelist: the sorted list (based on BaseVolume) of pairs the user might want to
|
||||||
|
trade
|
||||||
|
:return: the list of pairs the user wants to trade without the one unavailable or
|
||||||
|
black_listed
|
||||||
|
"""
|
||||||
|
sanitized_whitelist = whitelist
|
||||||
|
markets = self.exchange.get_markets()
|
||||||
|
|
||||||
|
markets = [m for m in markets if m['quote'] == self.config['stake_currency']]
|
||||||
|
known_pairs = set()
|
||||||
|
for market in markets:
|
||||||
|
pair = market['symbol']
|
||||||
|
# pair is not int the generated dynamic market, or in the blacklist ... ignore it
|
||||||
|
if pair not in whitelist or pair in self.config['exchange'].get('pair_blacklist', []):
|
||||||
|
continue
|
||||||
|
# else the pair is valid
|
||||||
|
known_pairs.add(pair)
|
||||||
|
# Market is not active
|
||||||
|
if not market['active']:
|
||||||
|
sanitized_whitelist.remove(pair)
|
||||||
|
logger.info(
|
||||||
|
'Ignoring %s from whitelist. Market is not active.',
|
||||||
|
pair
|
||||||
|
)
|
||||||
|
|
||||||
|
# We need to remove pairs that are unknown
|
||||||
|
final_list = [x for x in sanitized_whitelist if x in known_pairs]
|
||||||
|
|
||||||
|
return final_list
|
||||||
|
|
||||||
|
def get_target_bid(self, ticker: Dict[str, float]) -> float:
|
||||||
|
"""
|
||||||
|
Calculates bid target between current ask price and last price
|
||||||
|
:param ticker: Ticker to use for getting Ask and Last Price
|
||||||
|
:return: float: Price
|
||||||
|
"""
|
||||||
|
if ticker['ask'] < ticker['last']:
|
||||||
|
return ticker['ask']
|
||||||
|
balance = self.config['bid_strategy']['ask_last_balance']
|
||||||
|
return ticker['ask'] + balance * (ticker['last'] - ticker['ask'])
|
||||||
|
|
||||||
|
def _get_trade_stake_amount(self) -> Optional[float]:
|
||||||
|
stake_amount = self.config['stake_amount']
|
||||||
|
avaliable_amount = self.exchange.get_balance(self.config['stake_currency'])
|
||||||
|
|
||||||
|
if stake_amount == constants.UNLIMITED_STAKE_AMOUNT:
|
||||||
|
open_trades = len(Trade.query.filter(Trade.is_open.is_(True)).all())
|
||||||
|
if open_trades >= self.config['max_open_trades']:
|
||||||
|
logger.warning('Can\'t open a new trade: max number of trades is reached')
|
||||||
|
return None
|
||||||
|
return avaliable_amount / (self.config['max_open_trades'] - open_trades)
|
||||||
|
|
||||||
|
# Check if stake_amount is fulfilled
|
||||||
|
if avaliable_amount < stake_amount:
|
||||||
|
raise DependencyException(
|
||||||
|
'Available balance(%f %s) is lower than stake amount(%f %s)' % (
|
||||||
|
avaliable_amount, self.config['stake_currency'],
|
||||||
|
stake_amount, self.config['stake_currency'])
|
||||||
|
)
|
||||||
|
|
||||||
|
return stake_amount
|
||||||
|
|
||||||
|
def _get_min_pair_stake_amount(self, pair: str, price: float) -> Optional[float]:
|
||||||
|
markets = self.exchange.get_markets()
|
||||||
|
markets = [m for m in markets if m['symbol'] == pair]
|
||||||
|
if not markets:
|
||||||
|
raise ValueError(f'Can\'t get market information for symbol {pair}')
|
||||||
|
|
||||||
|
market = markets[0]
|
||||||
|
|
||||||
|
if 'limits' not in market:
|
||||||
|
return None
|
||||||
|
|
||||||
|
min_stake_amounts = []
|
||||||
|
if 'cost' in market['limits'] and 'min' in market['limits']['cost']:
|
||||||
|
min_stake_amounts.append(market['limits']['cost']['min'])
|
||||||
|
|
||||||
|
if 'amount' in market['limits'] and 'min' in market['limits']['amount']:
|
||||||
|
min_stake_amounts.append(market['limits']['amount']['min'] * price)
|
||||||
|
|
||||||
|
if not min_stake_amounts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
amount_reserve_percent = 1 - 0.05 # reserve 5% + stoploss
|
||||||
|
if self.analyze.get_stoploss() is not None:
|
||||||
|
amount_reserve_percent += self.analyze.get_stoploss()
|
||||||
|
# it should not be more than 50%
|
||||||
|
amount_reserve_percent = max(amount_reserve_percent, 0.5)
|
||||||
|
return min(min_stake_amounts)/amount_reserve_percent
|
||||||
|
|
||||||
|
def create_trade(self) -> bool:
|
||||||
|
"""
|
||||||
|
Checks the implemented trading indicator(s) for a randomly picked pair,
|
||||||
|
if one pair triggers the buy_signal a new trade record gets created
|
||||||
|
:return: True if a trade object has been created and persisted, False otherwise
|
||||||
|
"""
|
||||||
|
interval = self.analyze.get_ticker_interval()
|
||||||
|
stake_amount = self._get_trade_stake_amount()
|
||||||
|
|
||||||
|
if not stake_amount:
|
||||||
|
return False
|
||||||
|
stake_currency = self.config['stake_currency']
|
||||||
|
fiat_currency = self.config['fiat_display_currency']
|
||||||
|
exc_name = self.exchange.name
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Checking buy signals to create a new trade with stake_amount: %f ...',
|
||||||
|
stake_amount
|
||||||
|
)
|
||||||
|
whitelist = copy.deepcopy(self.config['exchange']['pair_whitelist'])
|
||||||
|
|
||||||
|
# Remove currently opened and latest pairs from whitelist
|
||||||
|
for trade in Trade.query.filter(Trade.is_open.is_(True)).all():
|
||||||
|
if trade.pair in whitelist:
|
||||||
|
whitelist.remove(trade.pair)
|
||||||
|
logger.debug('Ignoring %s in pair whitelist', trade.pair)
|
||||||
|
|
||||||
|
if not whitelist:
|
||||||
|
raise DependencyException('No currency pairs in whitelist')
|
||||||
|
|
||||||
|
# Pick pair based on buy signals
|
||||||
|
for _pair in whitelist:
|
||||||
|
(buy, sell) = self.analyze.get_signal(self.exchange, _pair, interval)
|
||||||
|
if buy and not sell:
|
||||||
|
pair = _pair
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
pair_s = pair.replace('_', '/')
|
||||||
|
pair_url = self.exchange.get_pair_detail_url(pair)
|
||||||
|
|
||||||
|
# Calculate amount
|
||||||
|
buy_limit = self.get_target_bid(self.exchange.get_ticker(pair))
|
||||||
|
|
||||||
|
min_stake_amount = self._get_min_pair_stake_amount(pair_s, buy_limit)
|
||||||
|
if min_stake_amount is not None and min_stake_amount > stake_amount:
|
||||||
|
logger.warning(
|
||||||
|
f'Can\'t open a new trade for {pair_s}: stake amount'
|
||||||
|
f' is too small ({stake_amount} < {min_stake_amount})'
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
amount = stake_amount / buy_limit
|
||||||
|
|
||||||
|
order_id = self.exchange.buy(pair, buy_limit, amount)['id']
|
||||||
|
|
||||||
|
stake_amount_fiat = self.fiat_converter.convert_amount(
|
||||||
|
stake_amount,
|
||||||
|
stake_currency,
|
||||||
|
fiat_currency
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create trade entity and return
|
||||||
|
self.rpc.send_msg(
|
||||||
|
f"""*{exc_name}:* Buying [{pair_s}]({pair_url}) \
|
||||||
|
with limit `{buy_limit:.8f} ({stake_amount:.6f} \
|
||||||
|
{stake_currency}, {stake_amount_fiat:.3f} {fiat_currency})`"""
|
||||||
|
)
|
||||||
|
# Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL
|
||||||
|
fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker')
|
||||||
|
trade = Trade(
|
||||||
|
pair=pair,
|
||||||
|
stake_amount=stake_amount,
|
||||||
|
amount=amount,
|
||||||
|
fee_open=fee,
|
||||||
|
fee_close=fee,
|
||||||
|
open_rate=buy_limit,
|
||||||
|
open_rate_requested=buy_limit,
|
||||||
|
open_date=datetime.utcnow(),
|
||||||
|
exchange=self.exchange.id,
|
||||||
|
open_order_id=order_id
|
||||||
|
)
|
||||||
|
Trade.session.add(trade)
|
||||||
|
Trade.session.flush()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def process_maybe_execute_buy(self) -> bool:
|
||||||
|
"""
|
||||||
|
Tries to execute a buy trade in a safe way
|
||||||
|
:return: True if executed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Create entity and execute trade
|
||||||
|
if self.create_trade():
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.info('Found no buy signals for whitelisted currencies. Trying again..')
|
||||||
|
return False
|
||||||
|
except DependencyException as exception:
|
||||||
|
logger.warning('Unable to create trade: %s', exception)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def process_maybe_execute_sell(self, trade: Trade) -> bool:
|
||||||
|
"""
|
||||||
|
Tries to execute a sell trade
|
||||||
|
:return: True if executed
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get order details for actual price per unit
|
||||||
|
if trade.open_order_id:
|
||||||
|
# Update trade with order values
|
||||||
|
logger.info('Found open order for %s', trade)
|
||||||
|
order = self.exchange.get_order(trade.open_order_id, trade.pair)
|
||||||
|
# Try update amount (binance-fix)
|
||||||
|
try:
|
||||||
|
new_amount = self.get_real_amount(trade, order)
|
||||||
|
if order['amount'] != new_amount:
|
||||||
|
order['amount'] = new_amount
|
||||||
|
# Fee was applied, so set to 0
|
||||||
|
trade.fee_open = 0
|
||||||
|
|
||||||
|
except OperationalException as exception:
|
||||||
|
logger.warning("could not update trade amount: %s", exception)
|
||||||
|
|
||||||
|
trade.update(order)
|
||||||
|
|
||||||
|
if trade.is_open and trade.open_order_id is None:
|
||||||
|
# Check if we can sell our current pair
|
||||||
|
return self.handle_trade(trade)
|
||||||
|
except DependencyException as exception:
|
||||||
|
logger.warning('Unable to sell trade: %s', exception)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_real_amount(self, trade: Trade, order: Dict) -> float:
|
||||||
|
"""
|
||||||
|
Get real amount for the trade
|
||||||
|
Necessary for self.exchanges which charge fees in base currency (e.g. binance)
|
||||||
|
"""
|
||||||
|
order_amount = order['amount']
|
||||||
|
# Only run for closed orders
|
||||||
|
if trade.fee_open == 0 or order['status'] == 'open':
|
||||||
|
return order_amount
|
||||||
|
|
||||||
|
# use fee from order-dict if possible
|
||||||
|
if 'fee' in order and order['fee'] and (order['fee'].keys() >= {'currency', 'cost'}):
|
||||||
|
if trade.pair.startswith(order['fee']['currency']):
|
||||||
|
new_amount = order_amount - order['fee']['cost']
|
||||||
|
logger.info("Applying fee on amount for %s (from %s to %s) from Order",
|
||||||
|
trade, order['amount'], new_amount)
|
||||||
|
return new_amount
|
||||||
|
|
||||||
|
# Fallback to Trades
|
||||||
|
trades = self.exchange.get_trades_for_order(trade.open_order_id, trade.pair,
|
||||||
|
trade.open_date)
|
||||||
|
|
||||||
|
if len(trades) == 0:
|
||||||
|
logger.info("Applying fee on amount for %s failed: myTrade-Dict empty found", trade)
|
||||||
|
return order_amount
|
||||||
|
amount = 0
|
||||||
|
fee_abs = 0
|
||||||
|
for exectrade in trades:
|
||||||
|
amount += exectrade['amount']
|
||||||
|
if "fee" in exectrade and (exectrade['fee'].keys() >= {'currency', 'cost'}):
|
||||||
|
# only applies if fee is in quote currency!
|
||||||
|
if trade.pair.startswith(exectrade['fee']['currency']):
|
||||||
|
fee_abs += exectrade['fee']['cost']
|
||||||
|
|
||||||
|
if amount != order_amount:
|
||||||
|
logger.warning(f"amount {amount} does not match amount {trade.amount}")
|
||||||
|
raise OperationalException("Half bought? Amounts don't match")
|
||||||
|
real_amount = amount - fee_abs
|
||||||
|
if fee_abs != 0:
|
||||||
|
logger.info(f"""Applying fee on amount for {trade} \
|
||||||
|
(from {order_amount} to {real_amount}) from Trades""")
|
||||||
|
return real_amount
|
||||||
|
|
||||||
|
def handle_trade(self, trade: Trade) -> bool:
|
||||||
|
"""
|
||||||
|
Sells the current pair if the threshold is reached and updates the trade record.
|
||||||
|
:return: True if trade has been sold, False otherwise
|
||||||
|
"""
|
||||||
|
if not trade.is_open:
|
||||||
|
raise ValueError(f'attempt to handle closed trade: {trade}')
|
||||||
|
|
||||||
|
logger.debug('Handling %s ...', trade)
|
||||||
|
current_rate = self.exchange.get_ticker(trade.pair)['bid']
|
||||||
|
|
||||||
|
(buy, sell) = (False, False)
|
||||||
|
experimental = self.config.get('experimental', {})
|
||||||
|
if experimental.get('use_sell_signal') or experimental.get('ignore_roi_if_buy_signal'):
|
||||||
|
(buy, sell) = self.analyze.get_signal(self.exchange,
|
||||||
|
trade.pair, self.analyze.get_ticker_interval())
|
||||||
|
|
||||||
|
if self.analyze.should_sell(trade, current_rate, datetime.utcnow(), buy, sell):
|
||||||
|
self.execute_sell(trade, current_rate)
|
||||||
|
return True
|
||||||
|
logger.info('Found no sell signals for whitelisted currencies. Trying again..')
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_handle_timedout(self, timeoutvalue: int) -> None:
|
||||||
|
"""
|
||||||
|
Check if any orders are timed out and cancel if neccessary
|
||||||
|
:param timeoutvalue: Number of minutes until order is considered timed out
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
timeoutthreashold = arrow.utcnow().shift(minutes=-timeoutvalue).datetime
|
||||||
|
|
||||||
|
for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all():
|
||||||
|
try:
|
||||||
|
# FIXME: Somehow the query above returns results
|
||||||
|
# where the open_order_id is in fact None.
|
||||||
|
# This is probably because the record got
|
||||||
|
# updated via /forcesell in a different thread.
|
||||||
|
if not trade.open_order_id:
|
||||||
|
continue
|
||||||
|
order = self.exchange.get_order(trade.open_order_id, trade.pair)
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
logger.info(
|
||||||
|
'Cannot query order for %s due to %s',
|
||||||
|
trade,
|
||||||
|
traceback.format_exc())
|
||||||
|
continue
|
||||||
|
ordertime = arrow.get(order['datetime']).datetime
|
||||||
|
|
||||||
|
# Check if trade is still actually open
|
||||||
|
if int(order['remaining']) == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if order['side'] == 'buy' and ordertime < timeoutthreashold:
|
||||||
|
self.handle_timedout_limit_buy(trade, order)
|
||||||
|
elif order['side'] == 'sell' and ordertime < timeoutthreashold:
|
||||||
|
self.handle_timedout_limit_sell(trade, order)
|
||||||
|
|
||||||
|
# FIX: 20180110, why is cancel.order unconditionally here, whereas
|
||||||
|
# it is conditionally called in the
|
||||||
|
# handle_timedout_limit_sell()?
|
||||||
|
def handle_timedout_limit_buy(self, trade: Trade, order: Dict) -> bool:
|
||||||
|
"""Buy timeout - cancel order
|
||||||
|
:return: True if order was fully cancelled
|
||||||
|
"""
|
||||||
|
pair_s = trade.pair.replace('_', '/')
|
||||||
|
self.exchange.cancel_order(trade.open_order_id, trade.pair)
|
||||||
|
if order['remaining'] == order['amount']:
|
||||||
|
# if trade is not partially completed, just delete the trade
|
||||||
|
Trade.session.delete(trade)
|
||||||
|
Trade.session.flush()
|
||||||
|
logger.info('Buy order timeout for %s.', trade)
|
||||||
|
self.rpc.send_msg(f'*Timeout:* Unfilled buy order for {pair_s} cancelled')
|
||||||
|
return True
|
||||||
|
|
||||||
|
# if trade is partially complete, edit the stake details for the trade
|
||||||
|
# and close the order
|
||||||
|
trade.amount = order['amount'] - order['remaining']
|
||||||
|
trade.stake_amount = trade.amount * trade.open_rate
|
||||||
|
trade.open_order_id = None
|
||||||
|
logger.info('Partial buy order timeout for %s.', trade)
|
||||||
|
self.rpc.send_msg(f'*Timeout:* Remaining buy order for {pair_s} cancelled')
|
||||||
|
return False
|
||||||
|
|
||||||
|
# FIX: 20180110, should cancel_order() be cond. or unconditionally called?
|
||||||
|
def handle_timedout_limit_sell(self, trade: Trade, order: Dict) -> bool:
|
||||||
|
"""
|
||||||
|
Sell timeout - cancel order and update trade
|
||||||
|
:return: True if order was fully cancelled
|
||||||
|
"""
|
||||||
|
pair_s = trade.pair.replace('_', '/')
|
||||||
|
if order['remaining'] == order['amount']:
|
||||||
|
# if trade is not partially completed, just cancel the trade
|
||||||
|
self.exchange.cancel_order(trade.open_order_id, trade.pair)
|
||||||
|
trade.close_rate = None
|
||||||
|
trade.close_profit = None
|
||||||
|
trade.close_date = None
|
||||||
|
trade.is_open = True
|
||||||
|
trade.open_order_id = None
|
||||||
|
self.rpc.send_msg(f'*Timeout:* Unfilled sell order for {pair_s} cancelled')
|
||||||
|
logger.info('Sell order timeout for %s.', trade)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# TODO: figure out how to handle partially complete sell orders
|
||||||
|
return False
|
||||||
|
|
||||||
|
def execute_sell(self, trade: Trade, limit: float) -> None:
|
||||||
|
"""
|
||||||
|
Executes a limit sell for the given trade and limit
|
||||||
|
:param trade: Trade instance
|
||||||
|
:param limit: limit rate for the sell order
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
exc = trade.exchange
|
||||||
|
pair = trade.pair
|
||||||
|
# Execute sell and update trade record
|
||||||
|
order_id = self.exchange.sell(str(trade.pair), limit, trade.amount)['id']
|
||||||
|
trade.open_order_id = order_id
|
||||||
|
trade.close_rate_requested = limit
|
||||||
|
|
||||||
|
fmt_exp_profit = round(trade.calc_profit_percent(rate=limit) * 100, 2)
|
||||||
|
profit_trade = trade.calc_profit(rate=limit)
|
||||||
|
current_rate = self.exchange.get_ticker(trade.pair)['bid']
|
||||||
|
profit = trade.calc_profit_percent(limit)
|
||||||
|
pair_url = self.exchange.get_pair_detail_url(trade.pair)
|
||||||
|
gain = "profit" if fmt_exp_profit > 0 else "loss"
|
||||||
|
|
||||||
|
message = f"*{exc}:* Selling\n" \
|
||||||
|
f"*Current Pair:* [{pair}]({pair_url})\n" \
|
||||||
|
f"*Limit:* `{limit}`\n" \
|
||||||
|
f"*Amount:* `{round(trade.amount, 8)}`\n" \
|
||||||
|
f"*Open Rate:* `{trade.open_rate:.8f}`\n" \
|
||||||
|
f"*Current Rate:* `{current_rate:.8f}`\n" \
|
||||||
|
f"*Profit:* `{round(profit * 100, 2):.2f}%`" \
|
||||||
|
""
|
||||||
|
|
||||||
|
# For regular case, when the configuration exists
|
||||||
|
if 'stake_currency' in self.config and 'fiat_display_currency' in self.config:
|
||||||
|
stake = self.config['stake_currency']
|
||||||
|
fiat = self.config['fiat_display_currency']
|
||||||
|
fiat_converter = CryptoToFiatConverter()
|
||||||
|
profit_fiat = fiat_converter.convert_amount(
|
||||||
|
profit_trade,
|
||||||
|
stake,
|
||||||
|
fiat
|
||||||
|
)
|
||||||
|
message += f'` ({gain}: {fmt_exp_profit:.2f}%, {profit_trade:.8f} {stake}`' \
|
||||||
|
f'` / {profit_fiat:.3f} {fiat})`'\
|
||||||
|
''
|
||||||
|
# Because telegram._forcesell does not have the configuration
|
||||||
|
# Ignore the FIAT value and does not show the stake_currency as well
|
||||||
|
else:
|
||||||
|
message += '` ({gain}: {profit_percent:.2f}%, {profit_coin:.8f})`'.format(
|
||||||
|
gain="profit" if fmt_exp_profit > 0 else "loss",
|
||||||
|
profit_percent=fmt_exp_profit,
|
||||||
|
profit_coin=profit_trade
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send the message
|
||||||
|
self.rpc.send_msg(message)
|
||||||
|
Trade.session.flush()
|
||||||
40
freqtrade/indicator_helpers.py
Normal file
40
freqtrade/indicator_helpers.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from math import exp, pi, sqrt, cos
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import talib as ta
|
||||||
|
from pandas import Series
|
||||||
|
|
||||||
|
|
||||||
|
def went_up(series: Series) -> bool:
|
||||||
|
return series > series.shift(1)
|
||||||
|
|
||||||
|
|
||||||
|
def went_down(series: Series) -> bool:
|
||||||
|
return series < series.shift(1)
|
||||||
|
|
||||||
|
|
||||||
|
def ehlers_super_smoother(series: Series, smoothing: float = 6) -> Series:
|
||||||
|
magic = pi * sqrt(2) / smoothing
|
||||||
|
a1 = exp(-magic)
|
||||||
|
coeff2 = 2 * a1 * cos(magic)
|
||||||
|
coeff3 = -a1 * a1
|
||||||
|
coeff1 = (1 - coeff2 - coeff3) / 2
|
||||||
|
|
||||||
|
filtered = series.copy()
|
||||||
|
|
||||||
|
for i in range(2, len(series)):
|
||||||
|
filtered.iloc[i] = coeff1 * (series.iloc[i] + series.iloc[i-1]) + \
|
||||||
|
coeff2 * filtered.iloc[i-1] + coeff3 * filtered.iloc[i-2]
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def fishers_inverse(series: Series, smoothing: float = 0) -> np.ndarray:
|
||||||
|
""" Does a smoothed fishers inverse transformation.
|
||||||
|
Can be used with any oscillator that goes from 0 to 100 like RSI or MFI """
|
||||||
|
v1 = 0.1 * (series - 50)
|
||||||
|
if smoothing > 0:
|
||||||
|
v2 = ta.WMA(v1.values, timeperiod=smoothing)
|
||||||
|
else:
|
||||||
|
v2 = v1
|
||||||
|
return (np.exp(2 * v2)-1) / (np.exp(2 * v2) + 1)
|
||||||
@@ -1,273 +1,96 @@
|
|||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
import copy
|
"""
|
||||||
import json
|
Main Freqtrade bot script.
|
||||||
|
Read the documentation to know what cli arguments you need.
|
||||||
|
"""
|
||||||
import logging
|
import logging
|
||||||
import time
|
import sys
|
||||||
import traceback
|
from argparse import Namespace
|
||||||
from datetime import datetime
|
from typing import List
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
from jsonschema import validate
|
from freqtrade import OperationalException
|
||||||
|
from freqtrade.arguments import Arguments
|
||||||
|
from freqtrade.configuration import Configuration
|
||||||
|
from freqtrade.freqtradebot import FreqtradeBot
|
||||||
|
from freqtrade.state import State
|
||||||
|
|
||||||
from freqtrade import __version__, exchange, persistence
|
logger = logging.getLogger('freqtrade')
|
||||||
from freqtrade.analyze import get_buy_signal
|
|
||||||
from freqtrade.misc import CONF_SCHEMA, State, get_state, update_state
|
|
||||||
from freqtrade.persistence import Trade
|
|
||||||
from freqtrade.rpc import telegram
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_CONF = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _process() -> None:
|
def main(sysargv: List[str]) -> None:
|
||||||
"""
|
"""
|
||||||
Queries the persistence layer for open trades and handles them,
|
This function will initiate the bot and start the trading loop.
|
||||||
otherwise a new trade is created.
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
try:
|
arguments = Arguments(
|
||||||
# Query trades from persistence layer
|
sysargv,
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
'Simple High Frequency Trading Bot for crypto currencies'
|
||||||
if len(trades) < _CONF['max_open_trades']:
|
|
||||||
try:
|
|
||||||
# Create entity and execute trade
|
|
||||||
trade = create_trade(float(_CONF['stake_amount']))
|
|
||||||
if trade:
|
|
||||||
Trade.session.add(trade)
|
|
||||||
else:
|
|
||||||
logging.info('Got no buy signal...')
|
|
||||||
except ValueError:
|
|
||||||
logger.exception('Unable to create trade')
|
|
||||||
|
|
||||||
for trade in trades:
|
|
||||||
# Check if there is already an open order for this trade
|
|
||||||
orders = exchange.get_open_orders(trade.pair)
|
|
||||||
orders = [o for o in orders if o['id'] == trade.open_order_id]
|
|
||||||
if orders:
|
|
||||||
logger.info('There is an open order for: %s', orders[0])
|
|
||||||
else:
|
|
||||||
# Update state
|
|
||||||
trade.open_order_id = None
|
|
||||||
# Check if this trade can be closed
|
|
||||||
if not close_trade_if_fulfilled(trade):
|
|
||||||
# Check if we can sell our current pair
|
|
||||||
handle_trade(trade)
|
|
||||||
Trade.session.flush()
|
|
||||||
except (ConnectionError, json.JSONDecodeError) as error:
|
|
||||||
msg = 'Got {} in _process()'.format(error.__class__.__name__)
|
|
||||||
logger.exception(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def close_trade_if_fulfilled(trade: Trade) -> bool:
|
|
||||||
"""
|
|
||||||
Checks if the trade is closable, and if so it is being closed.
|
|
||||||
:param trade: Trade
|
|
||||||
:return: True if trade has been closed else False
|
|
||||||
"""
|
|
||||||
# If we don't have an open order and the close rate is already set,
|
|
||||||
# we can close this trade.
|
|
||||||
if trade.close_profit is not None \
|
|
||||||
and trade.close_date is not None \
|
|
||||||
and trade.close_rate is not None \
|
|
||||||
and trade.open_order_id is None:
|
|
||||||
trade.is_open = False
|
|
||||||
logger.info('No open orders found and trade is fulfilled. Marking %s as closed ...', trade)
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def execute_sell(trade: Trade, current_rate: float) -> None:
|
|
||||||
"""
|
|
||||||
Executes a sell for the given trade and current rate
|
|
||||||
:param trade: Trade instance
|
|
||||||
:param current_rate: current rate
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
# Get available balance
|
|
||||||
currency = trade.pair.split('_')[1]
|
|
||||||
balance = exchange.get_balance(currency)
|
|
||||||
profit = trade.exec_sell_order(current_rate, balance)
|
|
||||||
message = '*{}:* Selling [{}]({}) at rate `{:f} (profit: {}%)`'.format(
|
|
||||||
trade.exchange,
|
|
||||||
trade.pair.replace('_', '/'),
|
|
||||||
exchange.get_pair_detail_url(trade.pair),
|
|
||||||
trade.close_rate,
|
|
||||||
round(profit, 2)
|
|
||||||
)
|
)
|
||||||
logger.info(message)
|
args = arguments.get_parsed_arg()
|
||||||
telegram.send_msg(message)
|
|
||||||
|
|
||||||
|
# A subcommand has been issued.
|
||||||
def should_sell(trade: Trade, current_rate: float, current_time: datetime) -> bool:
|
# Means if Backtesting or Hyperopt have been called we exit the bot
|
||||||
"""
|
if hasattr(args, 'func'):
|
||||||
Based an earlier trade and current price and configuration, decides whether bot should sell
|
args.func(args)
|
||||||
:return True if bot should sell at current rate
|
|
||||||
"""
|
|
||||||
current_profit = (current_rate - trade.open_rate) / trade.open_rate
|
|
||||||
|
|
||||||
if 'stoploss' in _CONF and current_profit < float(_CONF['stoploss']):
|
|
||||||
logger.debug('Stop loss hit.')
|
|
||||||
return True
|
|
||||||
|
|
||||||
for duration, threshold in sorted(_CONF['minimal_roi'].items()):
|
|
||||||
duration, threshold = float(duration), float(threshold)
|
|
||||||
# Check if time matches and current rate is above threshold
|
|
||||||
time_diff = (current_time - trade.open_date).total_seconds() / 60
|
|
||||||
if time_diff > duration and current_profit > threshold:
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.debug('Threshold not reached. (cur_profit: %1.2f%%)', current_profit * 100.0)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def handle_trade(trade: Trade) -> None:
|
|
||||||
"""
|
|
||||||
Sells the current pair if the threshold is reached and updates the trade record.
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
if not trade.is_open:
|
|
||||||
raise ValueError('attempt to handle closed trade: {}'.format(trade))
|
|
||||||
|
|
||||||
logger.debug('Handling open trade %s ...', trade)
|
|
||||||
|
|
||||||
current_rate = exchange.get_ticker(trade.pair)['bid']
|
|
||||||
if should_sell(trade, current_rate, datetime.utcnow()):
|
|
||||||
execute_sell(trade, current_rate)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
except ValueError:
|
freqtrade = None
|
||||||
logger.exception('Unable to handle open order')
|
return_code = 1
|
||||||
|
|
||||||
|
|
||||||
def get_target_bid(ticker: Dict[str, float]) -> float:
|
|
||||||
""" Calculates bid target between current ask price and last price """
|
|
||||||
if ticker['ask'] < ticker['last']:
|
|
||||||
return ticker['ask']
|
|
||||||
balance = _CONF['bid_strategy']['ask_last_balance']
|
|
||||||
return ticker['ask'] + balance * (ticker['last'] - ticker['ask'])
|
|
||||||
|
|
||||||
|
|
||||||
def create_trade(stake_amount: float) -> Optional[Trade]:
|
|
||||||
"""
|
|
||||||
Checks the implemented trading indicator(s) for a randomly picked pair,
|
|
||||||
if one pair triggers the buy_signal a new trade record gets created
|
|
||||||
:param stake_amount: amount of btc to spend
|
|
||||||
"""
|
|
||||||
logger.info('Creating new trade with stake_amount: %f ...', stake_amount)
|
|
||||||
whitelist = copy.deepcopy(_CONF['exchange']['pair_whitelist'])
|
|
||||||
# Check if stake_amount is fulfilled
|
|
||||||
if exchange.get_balance(_CONF['stake_currency']) < stake_amount:
|
|
||||||
raise ValueError(
|
|
||||||
'stake amount is not fulfilled (currency={}'.format(_CONF['stake_currency'])
|
|
||||||
)
|
|
||||||
|
|
||||||
# Remove currently opened and latest pairs from whitelist
|
|
||||||
for trade in Trade.query.filter(Trade.is_open.is_(True)).all():
|
|
||||||
if trade.pair in whitelist:
|
|
||||||
whitelist.remove(trade.pair)
|
|
||||||
logger.debug('Ignoring %s in pair whitelist', trade.pair)
|
|
||||||
if not whitelist:
|
|
||||||
raise ValueError('No pair in whitelist')
|
|
||||||
|
|
||||||
# Pick pair based on StochRSI buy signals
|
|
||||||
for _pair in whitelist:
|
|
||||||
if get_buy_signal(_pair):
|
|
||||||
pair = _pair
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
open_rate = get_target_bid(exchange.get_ticker(pair))
|
|
||||||
amount = stake_amount / open_rate
|
|
||||||
order_id = exchange.buy(pair, open_rate, amount)
|
|
||||||
|
|
||||||
# Create trade entity and return
|
|
||||||
message = '*{}:* Buying [{}]({}) at rate `{:f}`'.format(
|
|
||||||
exchange.EXCHANGE.name.upper(),
|
|
||||||
pair.replace('_', '/'),
|
|
||||||
exchange.get_pair_detail_url(pair),
|
|
||||||
open_rate
|
|
||||||
)
|
|
||||||
logger.info(message)
|
|
||||||
telegram.send_msg(message)
|
|
||||||
return Trade(pair=pair,
|
|
||||||
stake_amount=stake_amount,
|
|
||||||
open_rate=open_rate,
|
|
||||||
open_date=datetime.utcnow(),
|
|
||||||
amount=amount,
|
|
||||||
exchange=exchange.EXCHANGE.name.upper(),
|
|
||||||
open_order_id=order_id,
|
|
||||||
is_open=True)
|
|
||||||
|
|
||||||
|
|
||||||
def init(config: dict, db_url: Optional[str] = None) -> None:
|
|
||||||
"""
|
|
||||||
Initializes all modules and updates the config
|
|
||||||
:param config: config as dict
|
|
||||||
:param db_url: database connector string for sqlalchemy (Optional)
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
# Initialize all modules
|
|
||||||
telegram.init(config)
|
|
||||||
persistence.init(config, db_url)
|
|
||||||
exchange.init(config)
|
|
||||||
|
|
||||||
# Set initial application state
|
|
||||||
initial_state = config.get('initial_state')
|
|
||||||
if initial_state:
|
|
||||||
update_state(State[initial_state.upper()])
|
|
||||||
else:
|
|
||||||
update_state(State.STOPPED)
|
|
||||||
|
|
||||||
|
|
||||||
def app(config: dict) -> None:
|
|
||||||
"""
|
|
||||||
Main loop which handles the application state
|
|
||||||
:param config: config as dict
|
|
||||||
:return: None
|
|
||||||
"""
|
|
||||||
logger.info('Starting freqtrade %s', __version__)
|
|
||||||
init(config)
|
|
||||||
try:
|
try:
|
||||||
old_state = get_state()
|
# Load and validate configuration
|
||||||
logger.info('Initial State: %s', old_state)
|
config = Configuration(args).get_config()
|
||||||
telegram.send_msg('*Status:* `{}`'.format(old_state.name.lower()))
|
|
||||||
while True:
|
|
||||||
new_state = get_state()
|
|
||||||
# Log state transition
|
|
||||||
if new_state != old_state:
|
|
||||||
telegram.send_msg('*Status:* `{}`'.format(new_state.name.lower()))
|
|
||||||
logging.info('Changing state to: %s', new_state.name)
|
|
||||||
|
|
||||||
if new_state == State.STOPPED:
|
# Init the bot
|
||||||
time.sleep(1)
|
freqtrade = FreqtradeBot(config)
|
||||||
elif new_state == State.RUNNING:
|
|
||||||
_process()
|
state = None
|
||||||
# We need to sleep here because otherwise we would run into bittrex rate limit
|
while 1:
|
||||||
time.sleep(exchange.EXCHANGE.sleep_time)
|
state = freqtrade.worker(old_state=state)
|
||||||
old_state = new_state
|
if state == State.RELOAD_CONF:
|
||||||
except RuntimeError:
|
freqtrade = reconfigure(freqtrade, args)
|
||||||
telegram.send_msg('*Status:* Got RuntimeError: ```\n{}\n```'.format(traceback.format_exc()))
|
|
||||||
logger.exception('RuntimeError. Trader stopped!')
|
except KeyboardInterrupt:
|
||||||
|
logger.info('SIGINT received, aborting ...')
|
||||||
|
return_code = 0
|
||||||
|
except OperationalException as e:
|
||||||
|
logger.error(str(e))
|
||||||
|
return_code = 2
|
||||||
|
except BaseException:
|
||||||
|
logger.exception('Fatal exception!')
|
||||||
finally:
|
finally:
|
||||||
telegram.send_msg('*Status:* `Trader has stopped`')
|
if freqtrade:
|
||||||
|
freqtrade.rpc.send_msg('*Status:* `Process died ...`')
|
||||||
|
freqtrade.cleanup()
|
||||||
|
sys.exit(return_code)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def reconfigure(freqtrade: FreqtradeBot, args: Namespace) -> FreqtradeBot:
|
||||||
"""
|
"""
|
||||||
Loads and validates the config and starts the main loop
|
Cleans up current instance, reloads the configuration and returns the new instance
|
||||||
|
"""
|
||||||
|
# Clean up current modules
|
||||||
|
freqtrade.cleanup()
|
||||||
|
|
||||||
|
# Create new instance
|
||||||
|
freqtrade = FreqtradeBot(Configuration(args).get_config())
|
||||||
|
freqtrade.rpc.send_msg(
|
||||||
|
'*Status:* `Config reloaded ...`'.format(
|
||||||
|
freqtrade.state.name.lower()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return freqtrade
|
||||||
|
|
||||||
|
|
||||||
|
def set_loggers() -> None:
|
||||||
|
"""
|
||||||
|
Set the logger level for Third party libs
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
global _CONF
|
logging.getLogger('requests.packages.urllib3').setLevel(logging.INFO)
|
||||||
with open('config.json') as file:
|
logging.getLogger('ccxt.base.exchange').setLevel(logging.INFO)
|
||||||
_CONF = json.load(file)
|
logging.getLogger('telegram').setLevel(logging.INFO)
|
||||||
validate(_CONF, CONF_SCHEMA)
|
|
||||||
app(_CONF)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
main()
|
set_loggers()
|
||||||
|
main(sys.argv[1:])
|
||||||
|
|||||||
@@ -1,103 +1,91 @@
|
|||||||
import enum
|
"""
|
||||||
|
Various tool function for Freqtrade and scripts
|
||||||
|
"""
|
||||||
|
|
||||||
from wrapt import synchronized
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import gzip
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class State(enum.Enum):
|
def shorten_date(_date: str) -> str:
|
||||||
RUNNING = 0
|
|
||||||
STOPPED = 1
|
|
||||||
|
|
||||||
|
|
||||||
# Current application state
|
|
||||||
_STATE = State.STOPPED
|
|
||||||
|
|
||||||
|
|
||||||
@synchronized
|
|
||||||
def update_state(state: State) -> None:
|
|
||||||
"""
|
"""
|
||||||
Updates the application state
|
Trim the date so it fits on small screens
|
||||||
:param state: new state
|
|
||||||
:return: None
|
|
||||||
"""
|
"""
|
||||||
global _STATE
|
new_date = re.sub('seconds?', 'sec', _date)
|
||||||
_STATE = state
|
new_date = re.sub('minutes?', 'min', new_date)
|
||||||
|
new_date = re.sub('hours?', 'h', new_date)
|
||||||
|
new_date = re.sub('days?', 'd', new_date)
|
||||||
|
new_date = re.sub('^an?', '1', new_date)
|
||||||
|
return new_date
|
||||||
|
|
||||||
|
|
||||||
@synchronized
|
############################################
|
||||||
def get_state() -> State:
|
# Used by scripts #
|
||||||
|
# Matplotlib doesn't support ::datetime64, #
|
||||||
|
# so we need to convert it into ::datetime #
|
||||||
|
############################################
|
||||||
|
def datesarray_to_datetimearray(dates: np.ndarray) -> np.ndarray:
|
||||||
"""
|
"""
|
||||||
Gets the current application state
|
Convert an pandas-array of timestamps into
|
||||||
|
An numpy-array of datetimes
|
||||||
|
:return: numpy-array of datetime
|
||||||
|
"""
|
||||||
|
times = []
|
||||||
|
dates = dates.astype(datetime)
|
||||||
|
for index in range(0, dates.size):
|
||||||
|
date = dates[index].to_pydatetime()
|
||||||
|
times.append(date)
|
||||||
|
return np.array(times)
|
||||||
|
|
||||||
|
|
||||||
|
def common_datearray(dfs: Dict[str, DataFrame]) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Return dates from Dataframe
|
||||||
|
:param dfs: Dict with format pair: pair_data
|
||||||
|
:return: List of dates
|
||||||
|
"""
|
||||||
|
alldates = {}
|
||||||
|
for pair, pair_data in dfs.items():
|
||||||
|
dates = datesarray_to_datetimearray(pair_data['date'])
|
||||||
|
for date in dates:
|
||||||
|
alldates[date] = 1
|
||||||
|
lst = []
|
||||||
|
for date, _ in alldates.items():
|
||||||
|
lst.append(date)
|
||||||
|
arr = np.array(lst)
|
||||||
|
return np.sort(arr, axis=0)
|
||||||
|
|
||||||
|
|
||||||
|
def file_dump_json(filename, data, is_zip=False) -> None:
|
||||||
|
"""
|
||||||
|
Dump JSON data into a file
|
||||||
|
:param filename: file to create
|
||||||
|
:param data: JSON Data to save
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
return _STATE
|
print(f'dumping json to "{filename}"')
|
||||||
|
|
||||||
|
if is_zip:
|
||||||
|
if not filename.endswith('.gz'):
|
||||||
|
filename = filename + '.gz'
|
||||||
|
with gzip.open(filename, 'w') as fp:
|
||||||
|
json.dump(data, fp, default=str)
|
||||||
|
else:
|
||||||
|
with open(filename, 'w') as fp:
|
||||||
|
json.dump(data, fp, default=str)
|
||||||
|
|
||||||
|
|
||||||
# Required json-schema for user specified config
|
def format_ms_time(date: int) -> str:
|
||||||
CONF_SCHEMA = {
|
"""
|
||||||
'type': 'object',
|
convert MS date to readable format.
|
||||||
'properties': {
|
: epoch-string in ms
|
||||||
'max_open_trades': {'type': 'integer', 'minimum': 1},
|
"""
|
||||||
'stake_currency': {'type': 'string', 'enum': ['BTC', 'ETH', 'USDT']},
|
return datetime.fromtimestamp(date/1000.0).strftime('%Y-%m-%dT%H:%M:%S')
|
||||||
'stake_amount': {'type': 'number', 'minimum': 0.0005},
|
|
||||||
'dry_run': {'type': 'boolean'},
|
|
||||||
'minimal_roi': {
|
|
||||||
'type': 'object',
|
|
||||||
'patternProperties': {
|
|
||||||
'^[0-9.]+$': {'type': 'number'}
|
|
||||||
},
|
|
||||||
'minProperties': 1
|
|
||||||
},
|
|
||||||
'stoploss': {'type': 'number', 'maximum': 0, 'exclusiveMaximum': True},
|
|
||||||
'bid_strategy': {
|
|
||||||
'type': 'object',
|
|
||||||
'properties': {
|
|
||||||
'ask_last_balance': {
|
|
||||||
'type': 'number',
|
|
||||||
'minimum': 0,
|
|
||||||
'maximum': 1,
|
|
||||||
'exclusiveMaximum': False
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'required': ['ask_last_balance']
|
|
||||||
},
|
|
||||||
'exchange': {'$ref': '#/definitions/exchange'},
|
|
||||||
'telegram': {
|
|
||||||
'type': 'object',
|
|
||||||
'properties': {
|
|
||||||
'enabled': {'type': 'boolean'},
|
|
||||||
'token': {'type': 'string'},
|
|
||||||
'chat_id': {'type': 'string'},
|
|
||||||
},
|
|
||||||
'required': ['enabled', 'token', 'chat_id']
|
|
||||||
},
|
|
||||||
'initial_state': {'type': 'string', 'enum': ['running', 'stopped']},
|
|
||||||
},
|
|
||||||
'definitions': {
|
|
||||||
'exchange': {
|
|
||||||
'type': 'object',
|
|
||||||
'properties': {
|
|
||||||
'name': {'type': 'string'},
|
|
||||||
'key': {'type': 'string'},
|
|
||||||
'secret': {'type': 'string'},
|
|
||||||
'pair_whitelist': {
|
|
||||||
'type': 'array',
|
|
||||||
'items': {'type': 'string'},
|
|
||||||
'uniqueItems': True
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'required': ['name', 'key', 'secret', 'pair_whitelist']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'anyOf': [
|
|
||||||
{'required': ['exchange']}
|
|
||||||
],
|
|
||||||
'required': [
|
|
||||||
'max_open_trades',
|
|
||||||
'stake_currency',
|
|
||||||
'stake_amount',
|
|
||||||
'dry_run',
|
|
||||||
'minimal_roi',
|
|
||||||
'bid_strategy',
|
|
||||||
'telegram'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|||||||
232
freqtrade/optimize/__init__.py
Normal file
232
freqtrade/optimize/__init__.py
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring
|
||||||
|
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional, List, Dict, Tuple, Any
|
||||||
|
import arrow
|
||||||
|
|
||||||
|
from freqtrade import misc, constants, OperationalException
|
||||||
|
from freqtrade.exchange import Exchange
|
||||||
|
from freqtrade.arguments import TimeRange
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def trim_tickerlist(tickerlist: List[Dict], timerange: TimeRange) -> List[Dict]:
|
||||||
|
if not tickerlist:
|
||||||
|
return tickerlist
|
||||||
|
|
||||||
|
start_index = 0
|
||||||
|
stop_index = len(tickerlist)
|
||||||
|
|
||||||
|
if timerange.starttype == 'line':
|
||||||
|
stop_index = timerange.startts
|
||||||
|
if timerange.starttype == 'index':
|
||||||
|
start_index = timerange.startts
|
||||||
|
elif timerange.starttype == 'date':
|
||||||
|
while (start_index < len(tickerlist) and
|
||||||
|
tickerlist[start_index][0] < timerange.startts * 1000):
|
||||||
|
start_index += 1
|
||||||
|
|
||||||
|
if timerange.stoptype == 'line':
|
||||||
|
start_index = len(tickerlist) + timerange.stopts
|
||||||
|
if timerange.stoptype == 'index':
|
||||||
|
stop_index = timerange.stopts
|
||||||
|
elif timerange.stoptype == 'date':
|
||||||
|
while (stop_index > 0 and
|
||||||
|
tickerlist[stop_index-1][0] > timerange.stopts * 1000):
|
||||||
|
stop_index -= 1
|
||||||
|
|
||||||
|
if start_index > stop_index:
|
||||||
|
raise ValueError(f'The timerange [{timerange.startts},{timerange.stopts}] is incorrect')
|
||||||
|
|
||||||
|
return tickerlist[start_index:stop_index]
|
||||||
|
|
||||||
|
|
||||||
|
def load_tickerdata_file(
|
||||||
|
datadir: str, pair: str,
|
||||||
|
ticker_interval: str,
|
||||||
|
timerange: Optional[TimeRange] = None) -> Optional[List[Dict]]:
|
||||||
|
"""
|
||||||
|
Load a pair from file,
|
||||||
|
:return dict OR empty if unsuccesful
|
||||||
|
"""
|
||||||
|
path = make_testdata_path(datadir)
|
||||||
|
pair_file_string = pair.replace('/', '_')
|
||||||
|
file = os.path.join(path, '{pair}-{ticker_interval}.json'.format(
|
||||||
|
pair=pair_file_string,
|
||||||
|
ticker_interval=ticker_interval,
|
||||||
|
))
|
||||||
|
gzipfile = file + '.gz'
|
||||||
|
|
||||||
|
# If the file does not exist we download it when None is returned.
|
||||||
|
# If file exists, read the file, load the json
|
||||||
|
if os.path.isfile(gzipfile):
|
||||||
|
logger.debug('Loading ticker data from file %s', gzipfile)
|
||||||
|
with gzip.open(gzipfile) as tickerdata:
|
||||||
|
pairdata = json.load(tickerdata)
|
||||||
|
elif os.path.isfile(file):
|
||||||
|
logger.debug('Loading ticker data from file %s', file)
|
||||||
|
with open(file) as tickerdata:
|
||||||
|
pairdata = json.load(tickerdata)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if timerange:
|
||||||
|
pairdata = trim_tickerlist(pairdata, timerange)
|
||||||
|
return pairdata
|
||||||
|
|
||||||
|
|
||||||
|
def load_data(datadir: str,
|
||||||
|
ticker_interval: str,
|
||||||
|
pairs: List[str],
|
||||||
|
refresh_pairs: Optional[bool] = False,
|
||||||
|
exchange: Optional[Exchange] = None,
|
||||||
|
timerange: TimeRange = TimeRange(None, None, 0, 0)) -> Dict[str, List]:
|
||||||
|
"""
|
||||||
|
Loads ticker history data for the given parameters
|
||||||
|
:return: dict
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# If the user force the refresh of pairs
|
||||||
|
if refresh_pairs:
|
||||||
|
logger.info('Download data for all pairs and store them in %s', datadir)
|
||||||
|
if not exchange:
|
||||||
|
raise OperationalException("Exchange needs to be initialized when "
|
||||||
|
"calling load_data with refresh_pairs=True")
|
||||||
|
download_pairs(datadir, exchange, pairs, ticker_interval, timerange=timerange)
|
||||||
|
|
||||||
|
for pair in pairs:
|
||||||
|
pairdata = load_tickerdata_file(datadir, pair, ticker_interval, timerange=timerange)
|
||||||
|
if pairdata:
|
||||||
|
result[pair] = pairdata
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
'No data for pair: "%s", Interval: %s. '
|
||||||
|
'Use --refresh-pairs-cached to download the data',
|
||||||
|
pair,
|
||||||
|
ticker_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def make_testdata_path(datadir: str) -> str:
|
||||||
|
"""Return the path where testdata files are stored"""
|
||||||
|
return datadir or os.path.abspath(
|
||||||
|
os.path.join(
|
||||||
|
os.path.dirname(__file__), '..', 'tests', 'testdata'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_pairs(datadir, exchange: Exchange, pairs: List[str],
|
||||||
|
ticker_interval: str,
|
||||||
|
timerange: TimeRange = TimeRange(None, None, 0, 0)) -> bool:
|
||||||
|
"""For each pairs passed in parameters, download the ticker intervals"""
|
||||||
|
for pair in pairs:
|
||||||
|
try:
|
||||||
|
download_backtesting_testdata(datadir,
|
||||||
|
exchange=exchange,
|
||||||
|
pair=pair,
|
||||||
|
tick_interval=ticker_interval,
|
||||||
|
timerange=timerange)
|
||||||
|
except BaseException:
|
||||||
|
logger.info(
|
||||||
|
'Failed to download the pair: "%s", Interval: %s',
|
||||||
|
pair,
|
||||||
|
ticker_interval
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def load_cached_data_for_updating(filename: str,
|
||||||
|
tick_interval: str,
|
||||||
|
timerange: Optional[TimeRange]) -> Tuple[
|
||||||
|
List[Any],
|
||||||
|
Optional[int]]:
|
||||||
|
"""
|
||||||
|
Load cached data and choose what part of the data should be updated
|
||||||
|
"""
|
||||||
|
|
||||||
|
since_ms = None
|
||||||
|
|
||||||
|
# user sets timerange, so find the start time
|
||||||
|
if timerange:
|
||||||
|
if timerange.starttype == 'date':
|
||||||
|
since_ms = timerange.startts * 1000
|
||||||
|
elif timerange.stoptype == 'line':
|
||||||
|
num_minutes = timerange.stopts * constants.TICKER_INTERVAL_MINUTES[tick_interval]
|
||||||
|
since_ms = arrow.utcnow().shift(minutes=num_minutes).timestamp * 1000
|
||||||
|
|
||||||
|
# read the cached file
|
||||||
|
if os.path.isfile(filename):
|
||||||
|
with open(filename, "rt") as file:
|
||||||
|
data = json.load(file)
|
||||||
|
# remove the last item, because we are not sure if it is correct
|
||||||
|
# it could be fetched when the candle was incompleted
|
||||||
|
if data:
|
||||||
|
data.pop()
|
||||||
|
else:
|
||||||
|
data = []
|
||||||
|
|
||||||
|
if data:
|
||||||
|
if since_ms and since_ms < data[0][0]:
|
||||||
|
# the data is requested for earlier period than the cache has
|
||||||
|
# so fully redownload all the data
|
||||||
|
data = []
|
||||||
|
else:
|
||||||
|
# a part of the data was already downloaded, so
|
||||||
|
# download unexist data only
|
||||||
|
since_ms = data[-1][0] + 1
|
||||||
|
|
||||||
|
return (data, since_ms)
|
||||||
|
|
||||||
|
|
||||||
|
def download_backtesting_testdata(datadir: str,
|
||||||
|
exchange: Exchange,
|
||||||
|
pair: str,
|
||||||
|
tick_interval: str = '5m',
|
||||||
|
timerange: Optional[TimeRange] = None) -> None:
|
||||||
|
|
||||||
|
"""
|
||||||
|
Download the latest ticker intervals from the exchange for the pairs passed in parameters
|
||||||
|
The data is downloaded starting from the last correct ticker interval data that
|
||||||
|
esists in a cache. If timerange starts earlier than the data in the cache,
|
||||||
|
the full data will be redownloaded
|
||||||
|
|
||||||
|
Based on @Rybolov work: https://github.com/rybolov/freqtrade-data
|
||||||
|
:param pairs: list of pairs to download
|
||||||
|
:param tick_interval: ticker interval
|
||||||
|
:param timerange: range of time to download
|
||||||
|
:return: None
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
path = make_testdata_path(datadir)
|
||||||
|
filepair = pair.replace("/", "_")
|
||||||
|
filename = os.path.join(path, f'{filepair}-{tick_interval}.json')
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Download the pair: "%s", Interval: %s',
|
||||||
|
pair,
|
||||||
|
tick_interval
|
||||||
|
)
|
||||||
|
|
||||||
|
data, since_ms = load_cached_data_for_updating(filename, tick_interval, timerange)
|
||||||
|
|
||||||
|
logger.debug("Current Start: %s", misc.format_ms_time(data[1][0]) if data else 'None')
|
||||||
|
logger.debug("Current End: %s", misc.format_ms_time(data[-1][0]) if data else 'None')
|
||||||
|
|
||||||
|
new_data = exchange.get_ticker_history(pair=pair, tick_interval=tick_interval,
|
||||||
|
since_ms=since_ms)
|
||||||
|
data.extend(new_data)
|
||||||
|
|
||||||
|
logger.debug("New Start: %s", misc.format_ms_time(data[0][0]))
|
||||||
|
logger.debug("New End: %s", misc.format_ms_time(data[-1][0]))
|
||||||
|
|
||||||
|
misc.file_dump_json(filename, data)
|
||||||
364
freqtrade/optimize/backtesting.py
Normal file
364
freqtrade/optimize/backtesting.py
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring, W0212, too-many-arguments
|
||||||
|
|
||||||
|
"""
|
||||||
|
This module contains the backtesting logic
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import operator
|
||||||
|
from argparse import Namespace
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Tuple, Any, List, Optional, NamedTuple
|
||||||
|
|
||||||
|
import arrow
|
||||||
|
from pandas import DataFrame
|
||||||
|
from tabulate import tabulate
|
||||||
|
|
||||||
|
import freqtrade.optimize as optimize
|
||||||
|
from freqtrade import constants, DependencyException
|
||||||
|
from freqtrade.exchange import Exchange
|
||||||
|
from freqtrade.analyze import Analyze
|
||||||
|
from freqtrade.arguments import Arguments
|
||||||
|
from freqtrade.configuration import Configuration
|
||||||
|
from freqtrade.misc import file_dump_json
|
||||||
|
from freqtrade.persistence import Trade
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BacktestResult(NamedTuple):
|
||||||
|
"""
|
||||||
|
NamedTuple Defining BacktestResults inputs.
|
||||||
|
"""
|
||||||
|
pair: str
|
||||||
|
profit_percent: float
|
||||||
|
profit_abs: float
|
||||||
|
open_time: datetime
|
||||||
|
close_time: datetime
|
||||||
|
open_index: int
|
||||||
|
close_index: int
|
||||||
|
trade_duration: float
|
||||||
|
open_at_end: bool
|
||||||
|
|
||||||
|
|
||||||
|
class Backtesting(object):
|
||||||
|
"""
|
||||||
|
Backtesting class, this class contains all the logic to run a backtest
|
||||||
|
|
||||||
|
To run a backtest:
|
||||||
|
backtesting = Backtesting(config)
|
||||||
|
backtesting.start()
|
||||||
|
"""
|
||||||
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.analyze = Analyze(self.config)
|
||||||
|
self.ticker_interval = self.analyze.strategy.ticker_interval
|
||||||
|
self.tickerdata_to_dataframe = self.analyze.tickerdata_to_dataframe
|
||||||
|
self.populate_buy_trend = self.analyze.populate_buy_trend
|
||||||
|
self.populate_sell_trend = self.analyze.populate_sell_trend
|
||||||
|
|
||||||
|
# Reset keys for backtesting
|
||||||
|
self.config['exchange']['key'] = ''
|
||||||
|
self.config['exchange']['secret'] = ''
|
||||||
|
self.config['exchange']['password'] = ''
|
||||||
|
self.config['exchange']['uid'] = ''
|
||||||
|
self.config['dry_run'] = True
|
||||||
|
self.exchange = Exchange(self.config)
|
||||||
|
self.fee = self.exchange.get_fee()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_timeframe(data: Dict[str, DataFrame]) -> Tuple[arrow.Arrow, arrow.Arrow]:
|
||||||
|
"""
|
||||||
|
Get the maximum timeframe for the given backtest data
|
||||||
|
:param data: dictionary with preprocessed backtesting data
|
||||||
|
:return: tuple containing min_date, max_date
|
||||||
|
"""
|
||||||
|
timeframe = [
|
||||||
|
(arrow.get(min(frame.date)), arrow.get(max(frame.date)))
|
||||||
|
for frame in data.values()
|
||||||
|
]
|
||||||
|
return min(timeframe, key=operator.itemgetter(0))[0], \
|
||||||
|
max(timeframe, key=operator.itemgetter(1))[1]
|
||||||
|
|
||||||
|
def _generate_text_table(self, data: Dict[str, Dict], results: DataFrame) -> str:
|
||||||
|
"""
|
||||||
|
Generates and returns a text table for the given backtest data and the results dataframe
|
||||||
|
:return: pretty printed table with tabulate as str
|
||||||
|
"""
|
||||||
|
stake_currency = str(self.config.get('stake_currency'))
|
||||||
|
|
||||||
|
floatfmt = ('s', 'd', '.2f', '.8f', '.1f')
|
||||||
|
tabular_data = []
|
||||||
|
headers = ['pair', 'buy count', 'avg profit %',
|
||||||
|
'total profit ' + stake_currency, 'avg duration', 'profit', 'loss']
|
||||||
|
for pair in data:
|
||||||
|
result = results[results.pair == pair]
|
||||||
|
tabular_data.append([
|
||||||
|
pair,
|
||||||
|
len(result.index),
|
||||||
|
result.profit_percent.mean() * 100.0,
|
||||||
|
result.profit_abs.sum(),
|
||||||
|
result.trade_duration.mean(),
|
||||||
|
len(result[result.profit_abs > 0]),
|
||||||
|
len(result[result.profit_abs < 0])
|
||||||
|
])
|
||||||
|
|
||||||
|
# Append Total
|
||||||
|
tabular_data.append([
|
||||||
|
'TOTAL',
|
||||||
|
len(results.index),
|
||||||
|
results.profit_percent.mean() * 100.0,
|
||||||
|
results.profit_abs.sum(),
|
||||||
|
results.trade_duration.mean(),
|
||||||
|
len(results[results.profit_abs > 0]),
|
||||||
|
len(results[results.profit_abs < 0])
|
||||||
|
])
|
||||||
|
return tabulate(tabular_data, headers=headers, floatfmt=floatfmt, tablefmt="pipe")
|
||||||
|
|
||||||
|
def _store_backtest_result(self, recordfilename: Optional[str], results: DataFrame) -> None:
|
||||||
|
|
||||||
|
records = [(trade_entry.pair, trade_entry.profit_percent,
|
||||||
|
trade_entry.open_time.timestamp(),
|
||||||
|
trade_entry.close_time.timestamp(),
|
||||||
|
trade_entry.open_index - 1, trade_entry.trade_duration)
|
||||||
|
for index, trade_entry in results.iterrows()]
|
||||||
|
|
||||||
|
if records:
|
||||||
|
logger.info('Dumping backtest results to %s', recordfilename)
|
||||||
|
file_dump_json(recordfilename, records)
|
||||||
|
|
||||||
|
def _get_sell_trade_entry(
|
||||||
|
self, pair: str, buy_row: DataFrame,
|
||||||
|
partial_ticker: List, trade_count_lock: Dict, args: Dict) -> Optional[BacktestResult]:
|
||||||
|
|
||||||
|
stake_amount = args['stake_amount']
|
||||||
|
max_open_trades = args.get('max_open_trades', 0)
|
||||||
|
trade = Trade(
|
||||||
|
open_rate=buy_row.close,
|
||||||
|
open_date=buy_row.date,
|
||||||
|
stake_amount=stake_amount,
|
||||||
|
amount=stake_amount / buy_row.open,
|
||||||
|
fee_open=self.fee,
|
||||||
|
fee_close=self.fee
|
||||||
|
)
|
||||||
|
|
||||||
|
# calculate win/lose forwards from buy point
|
||||||
|
for sell_row in partial_ticker:
|
||||||
|
if max_open_trades > 0:
|
||||||
|
# Increase trade_count_lock for every iteration
|
||||||
|
trade_count_lock[sell_row.date] = trade_count_lock.get(sell_row.date, 0) + 1
|
||||||
|
|
||||||
|
buy_signal = sell_row.buy
|
||||||
|
if self.analyze.should_sell(trade, sell_row.close, sell_row.date, buy_signal,
|
||||||
|
sell_row.sell):
|
||||||
|
|
||||||
|
return BacktestResult(pair=pair,
|
||||||
|
profit_percent=trade.calc_profit_percent(rate=sell_row.close),
|
||||||
|
profit_abs=trade.calc_profit(rate=sell_row.close),
|
||||||
|
open_time=buy_row.date,
|
||||||
|
close_time=sell_row.date,
|
||||||
|
trade_duration=(sell_row.date - buy_row.date).seconds // 60,
|
||||||
|
open_index=buy_row.Index,
|
||||||
|
close_index=sell_row.Index,
|
||||||
|
open_at_end=False
|
||||||
|
)
|
||||||
|
if partial_ticker:
|
||||||
|
# no sell condition found - trade stil open at end of backtest period
|
||||||
|
sell_row = partial_ticker[-1]
|
||||||
|
btr = BacktestResult(pair=pair,
|
||||||
|
profit_percent=trade.calc_profit_percent(rate=sell_row.close),
|
||||||
|
profit_abs=trade.calc_profit(rate=sell_row.close),
|
||||||
|
open_time=buy_row.date,
|
||||||
|
close_time=sell_row.date,
|
||||||
|
trade_duration=(sell_row.date - buy_row.date).seconds // 60,
|
||||||
|
open_index=buy_row.Index,
|
||||||
|
close_index=sell_row.Index,
|
||||||
|
open_at_end=True
|
||||||
|
)
|
||||||
|
logger.debug('Force_selling still open trade %s with %s perc - %s', btr.pair,
|
||||||
|
btr.profit_percent, btr.profit_abs)
|
||||||
|
return btr
|
||||||
|
return None
|
||||||
|
|
||||||
|
def backtest(self, args: Dict) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Implements backtesting functionality
|
||||||
|
|
||||||
|
NOTE: This method is used by Hyperopt at each iteration. Please keep it optimized.
|
||||||
|
Of course try to not have ugly code. By some accessor are sometime slower than functions.
|
||||||
|
Avoid, logging on this method
|
||||||
|
|
||||||
|
:param args: a dict containing:
|
||||||
|
stake_amount: btc amount to use for each trade
|
||||||
|
processed: a processed dictionary with format {pair, data}
|
||||||
|
max_open_trades: maximum number of concurrent trades (default: 0, disabled)
|
||||||
|
realistic: do we try to simulate realistic trades? (default: True)
|
||||||
|
:return: DataFrame
|
||||||
|
"""
|
||||||
|
headers = ['date', 'buy', 'open', 'close', 'sell']
|
||||||
|
processed = args['processed']
|
||||||
|
max_open_trades = args.get('max_open_trades', 0)
|
||||||
|
realistic = args.get('realistic', False)
|
||||||
|
trades = []
|
||||||
|
trade_count_lock: Dict = {}
|
||||||
|
for pair, pair_data in processed.items():
|
||||||
|
pair_data['buy'], pair_data['sell'] = 0, 0 # cleanup from previous run
|
||||||
|
|
||||||
|
ticker_data = self.populate_sell_trend(
|
||||||
|
self.populate_buy_trend(pair_data))[headers].copy()
|
||||||
|
|
||||||
|
# to avoid using data from future, we buy/sell with signal from previous candle
|
||||||
|
ticker_data.loc[:, 'buy'] = ticker_data['buy'].shift(1)
|
||||||
|
ticker_data.loc[:, 'sell'] = ticker_data['sell'].shift(1)
|
||||||
|
|
||||||
|
ticker_data.drop(ticker_data.head(1).index, inplace=True)
|
||||||
|
|
||||||
|
# Convert from Pandas to list for performance reasons
|
||||||
|
# (Looping Pandas is slow.)
|
||||||
|
ticker = [x for x in ticker_data.itertuples()]
|
||||||
|
|
||||||
|
lock_pair_until = None
|
||||||
|
for index, row in enumerate(ticker):
|
||||||
|
if row.buy == 0 or row.sell == 1:
|
||||||
|
continue # skip rows where no buy signal or that would immediately sell off
|
||||||
|
|
||||||
|
if realistic:
|
||||||
|
if lock_pair_until is not None and row.date <= lock_pair_until:
|
||||||
|
continue
|
||||||
|
if max_open_trades > 0:
|
||||||
|
# Check if max_open_trades has already been reached for the given date
|
||||||
|
if not trade_count_lock.get(row.date, 0) < max_open_trades:
|
||||||
|
continue
|
||||||
|
|
||||||
|
trade_count_lock[row.date] = trade_count_lock.get(row.date, 0) + 1
|
||||||
|
|
||||||
|
trade_entry = self._get_sell_trade_entry(pair, row, ticker[index + 1:],
|
||||||
|
trade_count_lock, args)
|
||||||
|
|
||||||
|
if trade_entry:
|
||||||
|
lock_pair_until = trade_entry.close_time
|
||||||
|
trades.append(trade_entry)
|
||||||
|
else:
|
||||||
|
# Set lock_pair_until to end of testing period if trade could not be closed
|
||||||
|
# This happens only if the buy-signal was with the last candle
|
||||||
|
lock_pair_until = ticker_data.iloc[-1].date
|
||||||
|
|
||||||
|
return DataFrame.from_records(trades, columns=BacktestResult._fields)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""
|
||||||
|
Run a backtesting end-to-end
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
data = {}
|
||||||
|
pairs = self.config['exchange']['pair_whitelist']
|
||||||
|
logger.info('Using stake_currency: %s ...', self.config['stake_currency'])
|
||||||
|
logger.info('Using stake_amount: %s ...', self.config['stake_amount'])
|
||||||
|
|
||||||
|
if self.config.get('live'):
|
||||||
|
logger.info('Downloading data for all pairs in whitelist ...')
|
||||||
|
for pair in pairs:
|
||||||
|
data[pair] = self.exchange.get_ticker_history(pair, self.ticker_interval)
|
||||||
|
else:
|
||||||
|
logger.info('Using local backtesting data (using whitelist in given config) ...')
|
||||||
|
|
||||||
|
timerange = Arguments.parse_timerange(None if self.config.get(
|
||||||
|
'timerange') is None else str(self.config.get('timerange')))
|
||||||
|
data = optimize.load_data(
|
||||||
|
self.config['datadir'],
|
||||||
|
pairs=pairs,
|
||||||
|
ticker_interval=self.ticker_interval,
|
||||||
|
refresh_pairs=self.config.get('refresh_pairs', False),
|
||||||
|
exchange=self.exchange,
|
||||||
|
timerange=timerange
|
||||||
|
)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
logger.critical("No data found. Terminating.")
|
||||||
|
return
|
||||||
|
# Ignore max_open_trades in backtesting, except realistic flag was passed
|
||||||
|
if self.config.get('realistic_simulation', False):
|
||||||
|
max_open_trades = self.config['max_open_trades']
|
||||||
|
else:
|
||||||
|
logger.info('Ignoring max_open_trades (realistic_simulation not set) ...')
|
||||||
|
max_open_trades = 0
|
||||||
|
|
||||||
|
preprocessed = self.tickerdata_to_dataframe(data)
|
||||||
|
|
||||||
|
# Print timeframe
|
||||||
|
min_date, max_date = self.get_timeframe(preprocessed)
|
||||||
|
logger.info(
|
||||||
|
'Measuring data from %s up to %s (%s days)..',
|
||||||
|
min_date.isoformat(),
|
||||||
|
max_date.isoformat(),
|
||||||
|
(max_date - min_date).days
|
||||||
|
)
|
||||||
|
|
||||||
|
# Execute backtest and print results
|
||||||
|
results = self.backtest(
|
||||||
|
{
|
||||||
|
'stake_amount': self.config.get('stake_amount'),
|
||||||
|
'processed': preprocessed,
|
||||||
|
'max_open_trades': max_open_trades,
|
||||||
|
'realistic': self.config.get('realistic_simulation', False),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.config.get('export', False):
|
||||||
|
self._store_backtest_result(self.config.get('exportfilename'), results)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'\n======================================== '
|
||||||
|
'BACKTESTING REPORT'
|
||||||
|
' =========================================\n'
|
||||||
|
'%s',
|
||||||
|
self._generate_text_table(
|
||||||
|
data,
|
||||||
|
results
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'\n====================================== '
|
||||||
|
'LEFT OPEN TRADES REPORT'
|
||||||
|
' ======================================\n'
|
||||||
|
'%s',
|
||||||
|
self._generate_text_table(
|
||||||
|
data,
|
||||||
|
results.loc[results.open_at_end]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_configuration(args: Namespace) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Prepare the configuration for the backtesting
|
||||||
|
:param args: Cli args from Arguments()
|
||||||
|
:return: Configuration
|
||||||
|
"""
|
||||||
|
configuration = Configuration(args)
|
||||||
|
config = configuration.get_config()
|
||||||
|
|
||||||
|
# Ensure we do not use Exchange credentials
|
||||||
|
config['exchange']['key'] = ''
|
||||||
|
config['exchange']['secret'] = ''
|
||||||
|
|
||||||
|
if config['stake_amount'] == constants.UNLIMITED_STAKE_AMOUNT:
|
||||||
|
raise DependencyException('stake amount could not be "%s" for backtesting' %
|
||||||
|
constants.UNLIMITED_STAKE_AMOUNT)
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def start(args: Namespace) -> None:
|
||||||
|
"""
|
||||||
|
Start Backtesting script
|
||||||
|
:param args: Cli args from Arguments()
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
# Initialize configuration
|
||||||
|
config = setup_configuration(args)
|
||||||
|
logger.info('Starting freqtrade in Backtesting mode')
|
||||||
|
|
||||||
|
# Initialize backtesting object
|
||||||
|
backtesting = Backtesting(config)
|
||||||
|
backtesting.start()
|
||||||
590
freqtrade/optimize/hyperopt.py
Normal file
590
freqtrade/optimize/hyperopt.py
Normal file
@@ -0,0 +1,590 @@
|
|||||||
|
# pragma pylint: disable=too-many-instance-attributes, pointless-string-statement
|
||||||
|
|
||||||
|
"""
|
||||||
|
This module contains the hyperopt logic
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from argparse import Namespace
|
||||||
|
from functools import reduce
|
||||||
|
from math import exp
|
||||||
|
from operator import itemgetter
|
||||||
|
from typing import Dict, Any, Callable, Optional
|
||||||
|
|
||||||
|
import numpy
|
||||||
|
import talib.abstract as ta
|
||||||
|
from hyperopt import STATUS_FAIL, STATUS_OK, Trials, fmin, hp, space_eval, tpe
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
import freqtrade.vendor.qtpylib.indicators as qtpylib
|
||||||
|
from freqtrade.arguments import Arguments
|
||||||
|
from freqtrade.configuration import Configuration
|
||||||
|
from freqtrade.optimize import load_data
|
||||||
|
from freqtrade.optimize.backtesting import Backtesting
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Hyperopt(Backtesting):
|
||||||
|
"""
|
||||||
|
Hyperopt class, this class contains all the logic to run a hyperopt simulation
|
||||||
|
|
||||||
|
To run a backtest:
|
||||||
|
hyperopt = Hyperopt(config)
|
||||||
|
hyperopt.start()
|
||||||
|
"""
|
||||||
|
def __init__(self, config: Dict[str, Any]) -> None:
|
||||||
|
super().__init__(config)
|
||||||
|
# set TARGET_TRADES to suit your number concurrent trades so its realistic
|
||||||
|
# to the number of days
|
||||||
|
self.target_trades = 600
|
||||||
|
self.total_tries = config.get('epochs', 0)
|
||||||
|
self.current_tries = 0
|
||||||
|
self.current_best_loss = 100
|
||||||
|
|
||||||
|
# max average trade duration in minutes
|
||||||
|
# if eval ends with higher value, we consider it a failed eval
|
||||||
|
self.max_accepted_trade_duration = 300
|
||||||
|
|
||||||
|
# this is expexted avg profit * expected trade count
|
||||||
|
# for example 3.5%, 1100 trades, self.expected_max_profit = 3.85
|
||||||
|
# check that the reported Σ% values do not exceed this!
|
||||||
|
self.expected_max_profit = 3.0
|
||||||
|
|
||||||
|
# Configuration and data used by hyperopt
|
||||||
|
self.processed: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
# Hyperopt Trials
|
||||||
|
self.trials_file = os.path.join('user_data', 'hyperopt_trials.pickle')
|
||||||
|
self.trials = Trials()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def populate_indicators(dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Adds several different TA indicators to the given DataFrame
|
||||||
|
"""
|
||||||
|
dataframe['adx'] = ta.ADX(dataframe)
|
||||||
|
dataframe['ao'] = qtpylib.awesome_oscillator(dataframe)
|
||||||
|
dataframe['cci'] = ta.CCI(dataframe)
|
||||||
|
macd = ta.MACD(dataframe)
|
||||||
|
dataframe['macd'] = macd['macd']
|
||||||
|
dataframe['macdsignal'] = macd['macdsignal']
|
||||||
|
dataframe['macdhist'] = macd['macdhist']
|
||||||
|
dataframe['mfi'] = ta.MFI(dataframe)
|
||||||
|
dataframe['minus_dm'] = ta.MINUS_DM(dataframe)
|
||||||
|
dataframe['minus_di'] = ta.MINUS_DI(dataframe)
|
||||||
|
dataframe['plus_dm'] = ta.PLUS_DM(dataframe)
|
||||||
|
dataframe['plus_di'] = ta.PLUS_DI(dataframe)
|
||||||
|
dataframe['roc'] = ta.ROC(dataframe)
|
||||||
|
dataframe['rsi'] = ta.RSI(dataframe)
|
||||||
|
# Inverse Fisher transform on RSI, values [-1.0, 1.0] (https://goo.gl/2JGGoy)
|
||||||
|
rsi = 0.1 * (dataframe['rsi'] - 50)
|
||||||
|
dataframe['fisher_rsi'] = (numpy.exp(2 * rsi) - 1) / (numpy.exp(2 * rsi) + 1)
|
||||||
|
# Inverse Fisher transform on RSI normalized, value [0.0, 100.0] (https://goo.gl/2JGGoy)
|
||||||
|
dataframe['fisher_rsi_norma'] = 50 * (dataframe['fisher_rsi'] + 1)
|
||||||
|
# Stoch
|
||||||
|
stoch = ta.STOCH(dataframe)
|
||||||
|
dataframe['slowd'] = stoch['slowd']
|
||||||
|
dataframe['slowk'] = stoch['slowk']
|
||||||
|
# Stoch fast
|
||||||
|
stoch_fast = ta.STOCHF(dataframe)
|
||||||
|
dataframe['fastd'] = stoch_fast['fastd']
|
||||||
|
dataframe['fastk'] = stoch_fast['fastk']
|
||||||
|
# Stoch RSI
|
||||||
|
stoch_rsi = ta.STOCHRSI(dataframe)
|
||||||
|
dataframe['fastd_rsi'] = stoch_rsi['fastd']
|
||||||
|
dataframe['fastk_rsi'] = stoch_rsi['fastk']
|
||||||
|
# Bollinger bands
|
||||||
|
bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=20, stds=2)
|
||||||
|
dataframe['bb_lowerband'] = bollinger['lower']
|
||||||
|
dataframe['bb_middleband'] = bollinger['mid']
|
||||||
|
dataframe['bb_upperband'] = bollinger['upper']
|
||||||
|
# EMA - Exponential Moving Average
|
||||||
|
dataframe['ema3'] = ta.EMA(dataframe, timeperiod=3)
|
||||||
|
dataframe['ema5'] = ta.EMA(dataframe, timeperiod=5)
|
||||||
|
dataframe['ema10'] = ta.EMA(dataframe, timeperiod=10)
|
||||||
|
dataframe['ema50'] = ta.EMA(dataframe, timeperiod=50)
|
||||||
|
dataframe['ema100'] = ta.EMA(dataframe, timeperiod=100)
|
||||||
|
# SAR Parabolic
|
||||||
|
dataframe['sar'] = ta.SAR(dataframe)
|
||||||
|
# SMA - Simple Moving Average
|
||||||
|
dataframe['sma'] = ta.SMA(dataframe, timeperiod=40)
|
||||||
|
# TEMA - Triple Exponential Moving Average
|
||||||
|
dataframe['tema'] = ta.TEMA(dataframe, timeperiod=9)
|
||||||
|
# Hilbert Transform Indicator - SineWave
|
||||||
|
hilbert = ta.HT_SINE(dataframe)
|
||||||
|
dataframe['htsine'] = hilbert['sine']
|
||||||
|
dataframe['htleadsine'] = hilbert['leadsine']
|
||||||
|
|
||||||
|
# Pattern Recognition - Bullish candlestick patterns
|
||||||
|
# ------------------------------------
|
||||||
|
"""
|
||||||
|
# Hammer: values [0, 100]
|
||||||
|
dataframe['CDLHAMMER'] = ta.CDLHAMMER(dataframe)
|
||||||
|
# Inverted Hammer: values [0, 100]
|
||||||
|
dataframe['CDLINVERTEDHAMMER'] = ta.CDLINVERTEDHAMMER(dataframe)
|
||||||
|
# Dragonfly Doji: values [0, 100]
|
||||||
|
dataframe['CDLDRAGONFLYDOJI'] = ta.CDLDRAGONFLYDOJI(dataframe)
|
||||||
|
# Piercing Line: values [0, 100]
|
||||||
|
dataframe['CDLPIERCING'] = ta.CDLPIERCING(dataframe) # values [0, 100]
|
||||||
|
# Morningstar: values [0, 100]
|
||||||
|
dataframe['CDLMORNINGSTAR'] = ta.CDLMORNINGSTAR(dataframe) # values [0, 100]
|
||||||
|
# Three White Soldiers: values [0, 100]
|
||||||
|
dataframe['CDL3WHITESOLDIERS'] = ta.CDL3WHITESOLDIERS(dataframe) # values [0, 100]
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Pattern Recognition - Bearish candlestick patterns
|
||||||
|
# ------------------------------------
|
||||||
|
"""
|
||||||
|
# Hanging Man: values [0, 100]
|
||||||
|
dataframe['CDLHANGINGMAN'] = ta.CDLHANGINGMAN(dataframe)
|
||||||
|
# Shooting Star: values [0, 100]
|
||||||
|
dataframe['CDLSHOOTINGSTAR'] = ta.CDLSHOOTINGSTAR(dataframe)
|
||||||
|
# Gravestone Doji: values [0, 100]
|
||||||
|
dataframe['CDLGRAVESTONEDOJI'] = ta.CDLGRAVESTONEDOJI(dataframe)
|
||||||
|
# Dark Cloud Cover: values [0, 100]
|
||||||
|
dataframe['CDLDARKCLOUDCOVER'] = ta.CDLDARKCLOUDCOVER(dataframe)
|
||||||
|
# Evening Doji Star: values [0, 100]
|
||||||
|
dataframe['CDLEVENINGDOJISTAR'] = ta.CDLEVENINGDOJISTAR(dataframe)
|
||||||
|
# Evening Star: values [0, 100]
|
||||||
|
dataframe['CDLEVENINGSTAR'] = ta.CDLEVENINGSTAR(dataframe)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Pattern Recognition - Bullish/Bearish candlestick patterns
|
||||||
|
# ------------------------------------
|
||||||
|
"""
|
||||||
|
# Three Line Strike: values [0, -100, 100]
|
||||||
|
dataframe['CDL3LINESTRIKE'] = ta.CDL3LINESTRIKE(dataframe)
|
||||||
|
# Spinning Top: values [0, -100, 100]
|
||||||
|
dataframe['CDLSPINNINGTOP'] = ta.CDLSPINNINGTOP(dataframe) # values [0, -100, 100]
|
||||||
|
# Engulfing: values [0, -100, 100]
|
||||||
|
dataframe['CDLENGULFING'] = ta.CDLENGULFING(dataframe) # values [0, -100, 100]
|
||||||
|
# Harami: values [0, -100, 100]
|
||||||
|
dataframe['CDLHARAMI'] = ta.CDLHARAMI(dataframe) # values [0, -100, 100]
|
||||||
|
# Three Outside Up/Down: values [0, -100, 100]
|
||||||
|
dataframe['CDL3OUTSIDE'] = ta.CDL3OUTSIDE(dataframe) # values [0, -100, 100]
|
||||||
|
# Three Inside Up/Down: values [0, -100, 100]
|
||||||
|
dataframe['CDL3INSIDE'] = ta.CDL3INSIDE(dataframe) # values [0, -100, 100]
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Chart type
|
||||||
|
# ------------------------------------
|
||||||
|
# Heikinashi stategy
|
||||||
|
heikinashi = qtpylib.heikinashi(dataframe)
|
||||||
|
dataframe['ha_open'] = heikinashi['open']
|
||||||
|
dataframe['ha_close'] = heikinashi['close']
|
||||||
|
dataframe['ha_high'] = heikinashi['high']
|
||||||
|
dataframe['ha_low'] = heikinashi['low']
|
||||||
|
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
def save_trials(self) -> None:
|
||||||
|
"""
|
||||||
|
Save hyperopt trials to file
|
||||||
|
"""
|
||||||
|
logger.info('Saving Trials to \'%s\'', self.trials_file)
|
||||||
|
pickle.dump(self.trials, open(self.trials_file, 'wb'))
|
||||||
|
|
||||||
|
def read_trials(self) -> Trials:
|
||||||
|
"""
|
||||||
|
Read hyperopt trials file
|
||||||
|
"""
|
||||||
|
logger.info('Reading Trials from \'%s\'', self.trials_file)
|
||||||
|
trials = pickle.load(open(self.trials_file, 'rb'))
|
||||||
|
os.remove(self.trials_file)
|
||||||
|
return trials
|
||||||
|
|
||||||
|
def log_trials_result(self) -> None:
|
||||||
|
"""
|
||||||
|
Display Best hyperopt result
|
||||||
|
"""
|
||||||
|
vals = json.dumps(self.trials.best_trial['misc']['vals'], indent=4)
|
||||||
|
results = self.trials.best_trial['result']['result']
|
||||||
|
logger.info('Best result:\n%s\nwith values:\n%s', results, vals)
|
||||||
|
|
||||||
|
def log_results(self, results) -> None:
|
||||||
|
"""
|
||||||
|
Log results if it is better than any previous evaluation
|
||||||
|
"""
|
||||||
|
if results['loss'] < self.current_best_loss:
|
||||||
|
self.current_best_loss = results['loss']
|
||||||
|
log_msg = '\n{:5d}/{}: {}. Loss {:.5f}'.format(
|
||||||
|
results['current_tries'],
|
||||||
|
results['total_tries'],
|
||||||
|
results['result'],
|
||||||
|
results['loss']
|
||||||
|
)
|
||||||
|
print(log_msg)
|
||||||
|
else:
|
||||||
|
print('.', end='')
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
def calculate_loss(self, total_profit: float, trade_count: int, trade_duration: float) -> float:
|
||||||
|
"""
|
||||||
|
Objective function, returns smaller number for more optimal results
|
||||||
|
"""
|
||||||
|
trade_loss = 1 - 0.25 * exp(-(trade_count - self.target_trades) ** 2 / 10 ** 5.8)
|
||||||
|
profit_loss = max(0, 1 - total_profit / self.expected_max_profit)
|
||||||
|
duration_loss = 0.4 * min(trade_duration / self.max_accepted_trade_duration, 1)
|
||||||
|
return trade_loss + profit_loss + duration_loss
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_roi_table(params: Dict) -> Dict[int, float]:
|
||||||
|
"""
|
||||||
|
Generate the ROI table thqt will be used by Hyperopt
|
||||||
|
"""
|
||||||
|
roi_table = {}
|
||||||
|
roi_table[0] = params['roi_p1'] + params['roi_p2'] + params['roi_p3']
|
||||||
|
roi_table[params['roi_t3']] = params['roi_p1'] + params['roi_p2']
|
||||||
|
roi_table[params['roi_t3'] + params['roi_t2']] = params['roi_p1']
|
||||||
|
roi_table[params['roi_t3'] + params['roi_t2'] + params['roi_t1']] = 0
|
||||||
|
|
||||||
|
return roi_table
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def roi_space() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Values to search for each ROI steps
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'roi_t1': hp.quniform('roi_t1', 10, 120, 20),
|
||||||
|
'roi_t2': hp.quniform('roi_t2', 10, 60, 15),
|
||||||
|
'roi_t3': hp.quniform('roi_t3', 10, 40, 10),
|
||||||
|
'roi_p1': hp.quniform('roi_p1', 0.01, 0.04, 0.01),
|
||||||
|
'roi_p2': hp.quniform('roi_p2', 0.01, 0.07, 0.01),
|
||||||
|
'roi_p3': hp.quniform('roi_p3', 0.01, 0.20, 0.01),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def stoploss_space() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Stoploss Value to search
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'stoploss': hp.quniform('stoploss', -0.5, -0.02, 0.02),
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def indicator_space() -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Define your Hyperopt space for searching strategy parameters
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'macd_below_zero': hp.choice('macd_below_zero', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True}
|
||||||
|
]),
|
||||||
|
'mfi': hp.choice('mfi', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True, 'value': hp.quniform('mfi-value', 10, 25, 5)}
|
||||||
|
]),
|
||||||
|
'fastd': hp.choice('fastd', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True, 'value': hp.quniform('fastd-value', 15, 45, 5)}
|
||||||
|
]),
|
||||||
|
'adx': hp.choice('adx', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True, 'value': hp.quniform('adx-value', 20, 50, 5)}
|
||||||
|
]),
|
||||||
|
'rsi': hp.choice('rsi', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True, 'value': hp.quniform('rsi-value', 20, 40, 5)}
|
||||||
|
]),
|
||||||
|
'uptrend_long_ema': hp.choice('uptrend_long_ema', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True}
|
||||||
|
]),
|
||||||
|
'uptrend_short_ema': hp.choice('uptrend_short_ema', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True}
|
||||||
|
]),
|
||||||
|
'over_sar': hp.choice('over_sar', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True}
|
||||||
|
]),
|
||||||
|
'green_candle': hp.choice('green_candle', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True}
|
||||||
|
]),
|
||||||
|
'uptrend_sma': hp.choice('uptrend_sma', [
|
||||||
|
{'enabled': False},
|
||||||
|
{'enabled': True}
|
||||||
|
]),
|
||||||
|
'trigger': hp.choice('trigger', [
|
||||||
|
{'type': 'lower_bb'},
|
||||||
|
{'type': 'lower_bb_tema'},
|
||||||
|
{'type': 'faststoch10'},
|
||||||
|
{'type': 'ao_cross_zero'},
|
||||||
|
{'type': 'ema3_cross_ema10'},
|
||||||
|
{'type': 'macd_cross_signal'},
|
||||||
|
{'type': 'sar_reversal'},
|
||||||
|
{'type': 'ht_sine'},
|
||||||
|
{'type': 'heiken_reversal_bull'},
|
||||||
|
{'type': 'di_cross'},
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_space(self, space: str) -> bool:
|
||||||
|
"""
|
||||||
|
Tell if a space value is contained in the configuration
|
||||||
|
"""
|
||||||
|
if space in self.config['spaces'] or 'all' in self.config['spaces']:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def hyperopt_space(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Return the space to use during Hyperopt
|
||||||
|
"""
|
||||||
|
spaces: Dict = {}
|
||||||
|
if self.has_space('buy'):
|
||||||
|
spaces = {**spaces, **Hyperopt.indicator_space()}
|
||||||
|
if self.has_space('roi'):
|
||||||
|
spaces = {**spaces, **Hyperopt.roi_space()}
|
||||||
|
if self.has_space('stoploss'):
|
||||||
|
spaces = {**spaces, **Hyperopt.stoploss_space()}
|
||||||
|
return spaces
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def buy_strategy_generator(params: Dict[str, Any]) -> Callable:
|
||||||
|
"""
|
||||||
|
Define the buy strategy parameters to be used by hyperopt
|
||||||
|
"""
|
||||||
|
def populate_buy_trend(dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Buy strategy Hyperopt will build and use
|
||||||
|
"""
|
||||||
|
conditions = []
|
||||||
|
# GUARDS AND TRENDS
|
||||||
|
if 'uptrend_long_ema' in params and params['uptrend_long_ema']['enabled']:
|
||||||
|
conditions.append(dataframe['ema50'] > dataframe['ema100'])
|
||||||
|
if 'macd_below_zero' in params and params['macd_below_zero']['enabled']:
|
||||||
|
conditions.append(dataframe['macd'] < 0)
|
||||||
|
if 'uptrend_short_ema' in params and params['uptrend_short_ema']['enabled']:
|
||||||
|
conditions.append(dataframe['ema5'] > dataframe['ema10'])
|
||||||
|
if 'mfi' in params and params['mfi']['enabled']:
|
||||||
|
conditions.append(dataframe['mfi'] < params['mfi']['value'])
|
||||||
|
if 'fastd' in params and params['fastd']['enabled']:
|
||||||
|
conditions.append(dataframe['fastd'] < params['fastd']['value'])
|
||||||
|
if 'adx' in params and params['adx']['enabled']:
|
||||||
|
conditions.append(dataframe['adx'] > params['adx']['value'])
|
||||||
|
if 'rsi' in params and params['rsi']['enabled']:
|
||||||
|
conditions.append(dataframe['rsi'] < params['rsi']['value'])
|
||||||
|
if 'over_sar' in params and params['over_sar']['enabled']:
|
||||||
|
conditions.append(dataframe['close'] > dataframe['sar'])
|
||||||
|
if 'green_candle' in params and params['green_candle']['enabled']:
|
||||||
|
conditions.append(dataframe['close'] > dataframe['open'])
|
||||||
|
if 'uptrend_sma' in params and params['uptrend_sma']['enabled']:
|
||||||
|
prevsma = dataframe['sma'].shift(1)
|
||||||
|
conditions.append(dataframe['sma'] > prevsma)
|
||||||
|
|
||||||
|
# TRIGGERS
|
||||||
|
triggers = {
|
||||||
|
'lower_bb': (
|
||||||
|
dataframe['close'] < dataframe['bb_lowerband']
|
||||||
|
),
|
||||||
|
'lower_bb_tema': (
|
||||||
|
dataframe['tema'] < dataframe['bb_lowerband']
|
||||||
|
),
|
||||||
|
'faststoch10': (qtpylib.crossed_above(
|
||||||
|
dataframe['fastd'], 10.0
|
||||||
|
)),
|
||||||
|
'ao_cross_zero': (qtpylib.crossed_above(
|
||||||
|
dataframe['ao'], 0.0
|
||||||
|
)),
|
||||||
|
'ema3_cross_ema10': (qtpylib.crossed_above(
|
||||||
|
dataframe['ema3'], dataframe['ema10']
|
||||||
|
)),
|
||||||
|
'macd_cross_signal': (qtpylib.crossed_above(
|
||||||
|
dataframe['macd'], dataframe['macdsignal']
|
||||||
|
)),
|
||||||
|
'sar_reversal': (qtpylib.crossed_above(
|
||||||
|
dataframe['close'], dataframe['sar']
|
||||||
|
)),
|
||||||
|
'ht_sine': (qtpylib.crossed_above(
|
||||||
|
dataframe['htleadsine'], dataframe['htsine']
|
||||||
|
)),
|
||||||
|
'heiken_reversal_bull': (
|
||||||
|
(qtpylib.crossed_above(dataframe['ha_close'], dataframe['ha_open'])) &
|
||||||
|
(dataframe['ha_low'] == dataframe['ha_open'])
|
||||||
|
),
|
||||||
|
'di_cross': (qtpylib.crossed_above(
|
||||||
|
dataframe['plus_di'], dataframe['minus_di']
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
conditions.append(triggers.get(params['trigger']['type']))
|
||||||
|
|
||||||
|
dataframe.loc[
|
||||||
|
reduce(lambda x, y: x & y, conditions),
|
||||||
|
'buy'] = 1
|
||||||
|
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
return populate_buy_trend
|
||||||
|
|
||||||
|
def generate_optimizer(self, params: Dict) -> Dict:
|
||||||
|
if self.has_space('roi'):
|
||||||
|
self.analyze.strategy.minimal_roi = self.generate_roi_table(params)
|
||||||
|
|
||||||
|
if self.has_space('buy'):
|
||||||
|
self.populate_buy_trend = self.buy_strategy_generator(params)
|
||||||
|
|
||||||
|
if self.has_space('stoploss'):
|
||||||
|
self.analyze.strategy.stoploss = params['stoploss']
|
||||||
|
|
||||||
|
results = self.backtest(
|
||||||
|
{
|
||||||
|
'stake_amount': self.config['stake_amount'],
|
||||||
|
'processed': self.processed,
|
||||||
|
'realistic': self.config.get('realistic_simulation', False),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result_explanation = self.format_results(results)
|
||||||
|
|
||||||
|
total_profit = results.profit_percent.sum()
|
||||||
|
trade_count = len(results.index)
|
||||||
|
trade_duration = results.trade_duration.mean()
|
||||||
|
|
||||||
|
if trade_count == 0 or trade_duration > self.max_accepted_trade_duration:
|
||||||
|
print('.', end='')
|
||||||
|
sys.stdout.flush()
|
||||||
|
return {
|
||||||
|
'status': STATUS_FAIL,
|
||||||
|
'loss': float('inf')
|
||||||
|
}
|
||||||
|
|
||||||
|
loss = self.calculate_loss(total_profit, trade_count, trade_duration)
|
||||||
|
|
||||||
|
self.current_tries += 1
|
||||||
|
|
||||||
|
self.log_results(
|
||||||
|
{
|
||||||
|
'loss': loss,
|
||||||
|
'current_tries': self.current_tries,
|
||||||
|
'total_tries': self.total_tries,
|
||||||
|
'result': result_explanation,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'loss': loss,
|
||||||
|
'status': STATUS_OK,
|
||||||
|
'result': result_explanation,
|
||||||
|
}
|
||||||
|
|
||||||
|
def format_results(self, results: DataFrame) -> str:
|
||||||
|
"""
|
||||||
|
Return the format result in a string
|
||||||
|
"""
|
||||||
|
return ('{:6d} trades. Avg profit {: 5.2f}%. '
|
||||||
|
'Total profit {: 11.8f} {} ({:.4f}Σ%). Avg duration {:5.1f} mins.').format(
|
||||||
|
len(results.index),
|
||||||
|
results.profit_percent.mean() * 100.0,
|
||||||
|
results.profit_abs.sum(),
|
||||||
|
self.config['stake_currency'],
|
||||||
|
results.profit_percent.sum(),
|
||||||
|
results.trade_duration.mean(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
timerange = Arguments.parse_timerange(None if self.config.get(
|
||||||
|
'timerange') is None else str(self.config.get('timerange')))
|
||||||
|
data = load_data(
|
||||||
|
datadir=str(self.config.get('datadir')),
|
||||||
|
pairs=self.config['exchange']['pair_whitelist'],
|
||||||
|
ticker_interval=self.ticker_interval,
|
||||||
|
timerange=timerange
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.has_space('buy'):
|
||||||
|
self.analyze.populate_indicators = Hyperopt.populate_indicators # type: ignore
|
||||||
|
self.processed = self.tickerdata_to_dataframe(data)
|
||||||
|
|
||||||
|
logger.info('Preparing Trials..')
|
||||||
|
signal.signal(signal.SIGINT, self.signal_handler)
|
||||||
|
# read trials file if we have one
|
||||||
|
if os.path.exists(self.trials_file) and os.path.getsize(self.trials_file) > 0:
|
||||||
|
self.trials = self.read_trials()
|
||||||
|
|
||||||
|
self.current_tries = len(self.trials.results)
|
||||||
|
self.total_tries += self.current_tries
|
||||||
|
logger.info(
|
||||||
|
'Continuing with trials. Current: %d, Total: %d',
|
||||||
|
self.current_tries,
|
||||||
|
self.total_tries
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
best_parameters = fmin(
|
||||||
|
fn=self.generate_optimizer,
|
||||||
|
space=self.hyperopt_space(),
|
||||||
|
algo=tpe.suggest,
|
||||||
|
max_evals=self.total_tries,
|
||||||
|
trials=self.trials
|
||||||
|
)
|
||||||
|
|
||||||
|
results = sorted(self.trials.results, key=itemgetter('loss'))
|
||||||
|
best_result = results[0]['result']
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
best_parameters = {}
|
||||||
|
best_result = 'Sorry, Hyperopt was not able to find good parameters. Please ' \
|
||||||
|
'try with more epochs (param: -e).'
|
||||||
|
|
||||||
|
# Improve best parameter logging display
|
||||||
|
if best_parameters:
|
||||||
|
best_parameters = space_eval(
|
||||||
|
self.hyperopt_space(),
|
||||||
|
best_parameters
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info('Best parameters:\n%s', json.dumps(best_parameters, indent=4))
|
||||||
|
if 'roi_t1' in best_parameters:
|
||||||
|
logger.info('ROI table:\n%s', self.generate_roi_table(best_parameters))
|
||||||
|
|
||||||
|
logger.info('Best Result:\n%s', best_result)
|
||||||
|
|
||||||
|
# Store trials result to file to resume next time
|
||||||
|
self.save_trials()
|
||||||
|
|
||||||
|
def signal_handler(self, sig, frame) -> None:
|
||||||
|
"""
|
||||||
|
Hyperopt SIGINT handler
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
'Hyperopt received %s',
|
||||||
|
signal.Signals(sig).name
|
||||||
|
)
|
||||||
|
|
||||||
|
self.save_trials()
|
||||||
|
self.log_trials_result()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def start(args: Namespace) -> None:
|
||||||
|
"""
|
||||||
|
Start Backtesting script
|
||||||
|
:param args: Cli args from Arguments()
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Remove noisy log messages
|
||||||
|
logging.getLogger('hyperopt.tpe').setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
# Initialize configuration
|
||||||
|
# Monkey patch the configuration with hyperopt_conf.py
|
||||||
|
configuration = Configuration(args)
|
||||||
|
logger.info('Starting freqtrade in Hyperopt mode')
|
||||||
|
config = configuration.load_config()
|
||||||
|
|
||||||
|
config['exchange']['key'] = ''
|
||||||
|
config['exchange']['secret'] = ''
|
||||||
|
|
||||||
|
# Initialize backtesting object
|
||||||
|
hyperopt = Hyperopt(config)
|
||||||
|
hyperopt.start()
|
||||||
@@ -1,87 +1,278 @@
|
|||||||
from datetime import datetime
|
"""
|
||||||
from typing import Optional
|
This module contains the class to persist trades into SQLite
|
||||||
|
"""
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Column, DateTime, Float, Integer, String, create_engine
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal, getcontext
|
||||||
|
from typing import Dict, Optional, Any
|
||||||
|
|
||||||
|
import arrow
|
||||||
|
from sqlalchemy import (Boolean, Column, DateTime, Float, Integer, String,
|
||||||
|
create_engine)
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
from sqlalchemy.exc import NoSuchModuleError
|
||||||
from sqlalchemy.ext.declarative import declarative_base
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
from sqlalchemy.orm.scoping import scoped_session
|
from sqlalchemy.orm.scoping import scoped_session
|
||||||
from sqlalchemy.orm.session import sessionmaker
|
from sqlalchemy.orm.session import sessionmaker
|
||||||
from sqlalchemy.types import Enum
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
from freqtrade import exchange
|
from freqtrade import OperationalException
|
||||||
|
|
||||||
_CONF = {}
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
Base = declarative_base()
|
_DECL_BASE: Any = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
def init(config: dict, db_url: Optional[str] = None) -> None:
|
def init(config: Dict) -> None:
|
||||||
"""
|
"""
|
||||||
Initializes this module with the given config,
|
Initializes this module with the given config,
|
||||||
registers all known command handlers
|
registers all known command handlers
|
||||||
and starts polling for message updates
|
and starts polling for message updates
|
||||||
:param config: config to use
|
:param config: config to use
|
||||||
:param db_url: database connector string for sqlalchemy (Optional)
|
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
_CONF.update(config)
|
db_url = config.get('db_url', None)
|
||||||
if not db_url:
|
kwargs = {}
|
||||||
if _CONF.get('dry_run', False):
|
|
||||||
db_url = 'sqlite:///tradesv2.dry_run.sqlite'
|
# Take care of thread ownership if in-memory db
|
||||||
else:
|
if db_url == 'sqlite://':
|
||||||
db_url = 'sqlite:///tradesv2.sqlite'
|
kwargs.update({
|
||||||
|
'connect_args': {'check_same_thread': False},
|
||||||
|
'poolclass': StaticPool,
|
||||||
|
'echo': False,
|
||||||
|
})
|
||||||
|
|
||||||
|
try:
|
||||||
|
engine = create_engine(db_url, **kwargs)
|
||||||
|
except NoSuchModuleError:
|
||||||
|
error = 'Given value for db_url: \'{}\' is no valid database URL! (See {}).'.format(
|
||||||
|
db_url, 'http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls'
|
||||||
|
)
|
||||||
|
raise OperationalException(error)
|
||||||
|
|
||||||
engine = create_engine(db_url, echo=False)
|
|
||||||
session = scoped_session(sessionmaker(bind=engine, autoflush=True, autocommit=True))
|
session = scoped_session(sessionmaker(bind=engine, autoflush=True, autocommit=True))
|
||||||
Trade.session = session()
|
Trade.session = session()
|
||||||
Trade.query = session.query_property()
|
Trade.query = session.query_property()
|
||||||
Base.metadata.create_all(engine)
|
_DECL_BASE.metadata.create_all(engine)
|
||||||
|
check_migrate(engine)
|
||||||
|
|
||||||
|
# Clean dry_run DB if the db is not in-memory
|
||||||
|
if config.get('dry_run', False) and db_url != 'sqlite://':
|
||||||
|
clean_dry_run_db()
|
||||||
|
|
||||||
|
|
||||||
class Trade(Base):
|
def has_column(columns, searchname: str) -> bool:
|
||||||
|
return len(list(filter(lambda x: x["name"] == searchname, columns))) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def check_migrate(engine) -> None:
|
||||||
|
"""
|
||||||
|
Checks if migration is necessary and migrates if necessary
|
||||||
|
"""
|
||||||
|
inspector = inspect(engine)
|
||||||
|
|
||||||
|
cols = inspector.get_columns('trades')
|
||||||
|
|
||||||
|
if not has_column(cols, 'fee_open'):
|
||||||
|
# Schema migration necessary
|
||||||
|
engine.execute("alter table trades rename to trades_bak")
|
||||||
|
# let SQLAlchemy create the schema as required
|
||||||
|
_DECL_BASE.metadata.create_all(engine)
|
||||||
|
|
||||||
|
# Copy data back - following the correct schema
|
||||||
|
engine.execute("""insert into trades
|
||||||
|
(id, exchange, pair, is_open, fee_open, fee_close, open_rate,
|
||||||
|
open_rate_requested, close_rate, close_rate_requested, close_profit,
|
||||||
|
stake_amount, amount, open_date, close_date, open_order_id)
|
||||||
|
select id, lower(exchange),
|
||||||
|
case
|
||||||
|
when instr(pair, '_') != 0 then
|
||||||
|
substr(pair, instr(pair, '_') + 1) || '/' ||
|
||||||
|
substr(pair, 1, instr(pair, '_') - 1)
|
||||||
|
else pair
|
||||||
|
end
|
||||||
|
pair,
|
||||||
|
is_open, fee fee_open, fee fee_close,
|
||||||
|
open_rate, null open_rate_requested, close_rate,
|
||||||
|
null close_rate_requested, close_profit,
|
||||||
|
stake_amount, amount, open_date, close_date, open_order_id
|
||||||
|
from trades_bak
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Reread columns - the above recreated the table!
|
||||||
|
inspector = inspect(engine)
|
||||||
|
cols = inspector.get_columns('trades')
|
||||||
|
|
||||||
|
if not has_column(cols, 'open_rate_requested'):
|
||||||
|
engine.execute("alter table trades add open_rate_requested float")
|
||||||
|
if not has_column(cols, 'close_rate_requested'):
|
||||||
|
engine.execute("alter table trades add close_rate_requested float")
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup() -> None:
|
||||||
|
"""
|
||||||
|
Flushes all pending operations to disk.
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
Trade.session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def clean_dry_run_db() -> None:
|
||||||
|
"""
|
||||||
|
Remove open_order_id from a Dry_run DB
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all():
|
||||||
|
# Check we are updating only a dry_run order not a prod one
|
||||||
|
if 'dry_run' in trade.open_order_id:
|
||||||
|
trade.open_order_id = None
|
||||||
|
|
||||||
|
|
||||||
|
class Trade(_DECL_BASE):
|
||||||
|
"""
|
||||||
|
Class used to define a trade structure
|
||||||
|
"""
|
||||||
__tablename__ = 'trades'
|
__tablename__ = 'trades'
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
exchange = Column(String, nullable=False)
|
exchange = Column(String, nullable=False)
|
||||||
pair = Column(String, nullable=False)
|
pair = Column(String, nullable=False)
|
||||||
is_open = Column(Boolean, nullable=False, default=True)
|
is_open = Column(Boolean, nullable=False, default=True)
|
||||||
open_rate = Column(Float, nullable=False)
|
fee_open = Column(Float, nullable=False, default=0.0)
|
||||||
|
fee_close = Column(Float, nullable=False, default=0.0)
|
||||||
|
open_rate = Column(Float)
|
||||||
|
open_rate_requested = Column(Float)
|
||||||
close_rate = Column(Float)
|
close_rate = Column(Float)
|
||||||
|
close_rate_requested = Column(Float)
|
||||||
close_profit = Column(Float)
|
close_profit = Column(Float)
|
||||||
stake_amount = Column(Float, name='btc_amount', nullable=False)
|
stake_amount = Column(Float, nullable=False)
|
||||||
amount = Column(Float, nullable=False)
|
amount = Column(Float)
|
||||||
open_date = Column(DateTime, nullable=False, default=datetime.utcnow)
|
open_date = Column(DateTime, nullable=False, default=datetime.utcnow)
|
||||||
close_date = Column(DateTime)
|
close_date = Column(DateTime)
|
||||||
open_order_id = Column(String)
|
open_order_id = Column(String)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
if self.is_open:
|
return 'Trade(id={}, pair={}, amount={:.8f}, open_rate={:.8f}, open_since={})'.format(
|
||||||
open_since = 'closed'
|
|
||||||
else:
|
|
||||||
open_since = round((datetime.utcnow() - self.open_date).total_seconds() / 60, 2)
|
|
||||||
return 'Trade(id={}, pair={}, amount={}, open_rate={}, open_since={})'.format(
|
|
||||||
self.id,
|
self.id,
|
||||||
self.pair,
|
self.pair,
|
||||||
self.amount,
|
self.amount,
|
||||||
self.open_rate,
|
self.open_rate,
|
||||||
open_since
|
arrow.get(self.open_date).humanize() if self.is_open else 'closed'
|
||||||
)
|
)
|
||||||
|
|
||||||
def exec_sell_order(self, rate: float, amount: float) -> float:
|
def update(self, order: Dict) -> None:
|
||||||
"""
|
"""
|
||||||
Executes a sell for the given trade and updated the entity.
|
Updates this entity with amount and actual open/close rates.
|
||||||
:param rate: rate to sell for
|
:param order: order retrieved by exchange.get_order()
|
||||||
:param amount: amount to sell
|
:return: None
|
||||||
:return: current profit as percentage
|
|
||||||
"""
|
"""
|
||||||
profit = 100 * ((rate - self.open_rate) / self.open_rate)
|
# Ignore open and cancelled orders
|
||||||
|
if order['status'] == 'open' or order['price'] is None:
|
||||||
|
return
|
||||||
|
|
||||||
# Execute sell and update trade record
|
logger.info('Updating trade (id=%d) ...', self.id)
|
||||||
order_id = exchange.sell(str(self.pair), rate, amount)
|
|
||||||
self.close_rate = rate
|
getcontext().prec = 8 # Bittrex do not go above 8 decimal
|
||||||
self.close_profit = profit
|
if order['type'] == 'limit' and order['side'] == 'buy':
|
||||||
|
# Update open rate and actual amount
|
||||||
|
self.open_rate = Decimal(order['price'])
|
||||||
|
self.amount = Decimal(order['amount'])
|
||||||
|
logger.info('LIMIT_BUY has been fulfilled for %s.', self)
|
||||||
|
self.open_order_id = None
|
||||||
|
elif order['type'] == 'limit' and order['side'] == 'sell':
|
||||||
|
self.close(order['price'])
|
||||||
|
else:
|
||||||
|
raise ValueError('Unknown order type: {}'.format(order['type']))
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
def close(self, rate: float) -> None:
|
||||||
|
"""
|
||||||
|
Sets close_rate to the given rate, calculates total profit
|
||||||
|
and marks trade as closed
|
||||||
|
"""
|
||||||
|
self.close_rate = Decimal(rate)
|
||||||
|
self.close_profit = self.calc_profit_percent()
|
||||||
self.close_date = datetime.utcnow()
|
self.close_date = datetime.utcnow()
|
||||||
self.open_order_id = order_id
|
self.is_open = False
|
||||||
|
self.open_order_id = None
|
||||||
|
logger.info(
|
||||||
|
'Marking %s as closed as the trade is fulfilled and found no open orders for it.',
|
||||||
|
self
|
||||||
|
)
|
||||||
|
|
||||||
# Flush changes
|
def calc_open_trade_price(
|
||||||
Trade.session.flush()
|
self,
|
||||||
return profit
|
fee: Optional[float] = None) -> float:
|
||||||
|
"""
|
||||||
|
Calculate the open_rate in BTC
|
||||||
|
:param fee: fee to use on the open rate (optional).
|
||||||
|
If rate is not set self.fee will be used
|
||||||
|
:return: Price in BTC of the open trade
|
||||||
|
"""
|
||||||
|
getcontext().prec = 8
|
||||||
|
|
||||||
|
buy_trade = (Decimal(self.amount) * Decimal(self.open_rate))
|
||||||
|
fees = buy_trade * Decimal(fee or self.fee_open)
|
||||||
|
return float(buy_trade + fees)
|
||||||
|
|
||||||
|
def calc_close_trade_price(
|
||||||
|
self,
|
||||||
|
rate: Optional[float] = None,
|
||||||
|
fee: Optional[float] = None) -> float:
|
||||||
|
"""
|
||||||
|
Calculate the close_rate in BTC
|
||||||
|
:param fee: fee to use on the close rate (optional).
|
||||||
|
If rate is not set self.fee will be used
|
||||||
|
:param rate: rate to compare with (optional).
|
||||||
|
If rate is not set self.close_rate will be used
|
||||||
|
:return: Price in BTC of the open trade
|
||||||
|
"""
|
||||||
|
getcontext().prec = 8
|
||||||
|
|
||||||
|
if rate is None and not self.close_rate:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
sell_trade = (Decimal(self.amount) * Decimal(rate or self.close_rate))
|
||||||
|
fees = sell_trade * Decimal(fee or self.fee_close)
|
||||||
|
return float(sell_trade - fees)
|
||||||
|
|
||||||
|
def calc_profit(
|
||||||
|
self,
|
||||||
|
rate: Optional[float] = None,
|
||||||
|
fee: Optional[float] = None) -> float:
|
||||||
|
"""
|
||||||
|
Calculate the profit in BTC between Close and Open trade
|
||||||
|
:param fee: fee to use on the close rate (optional).
|
||||||
|
If rate is not set self.fee will be used
|
||||||
|
:param rate: close rate to compare with (optional).
|
||||||
|
If rate is not set self.close_rate will be used
|
||||||
|
:return: profit in BTC as float
|
||||||
|
"""
|
||||||
|
open_trade_price = self.calc_open_trade_price()
|
||||||
|
close_trade_price = self.calc_close_trade_price(
|
||||||
|
rate=(rate or self.close_rate),
|
||||||
|
fee=(fee or self.fee_close)
|
||||||
|
)
|
||||||
|
return float("{0:.8f}".format(close_trade_price - open_trade_price))
|
||||||
|
|
||||||
|
def calc_profit_percent(
|
||||||
|
self,
|
||||||
|
rate: Optional[float] = None,
|
||||||
|
fee: Optional[float] = None) -> float:
|
||||||
|
"""
|
||||||
|
Calculates the profit in percentage (including fee).
|
||||||
|
:param rate: rate to compare with (optional).
|
||||||
|
If rate is not set self.close_rate will be used
|
||||||
|
:param fee: fee to use on the close rate (optional).
|
||||||
|
:return: profit in percentage as float
|
||||||
|
"""
|
||||||
|
getcontext().prec = 8
|
||||||
|
|
||||||
|
open_trade_price = self.calc_open_trade_price()
|
||||||
|
close_trade_price = self.calc_close_trade_price(
|
||||||
|
rate=(rate or self.close_rate),
|
||||||
|
fee=(fee or self.fee_close)
|
||||||
|
)
|
||||||
|
|
||||||
|
return float("{0:.8f}".format((close_trade_price / open_trade_price) - 1))
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
from . import telegram
|
|
||||||
|
|||||||
392
freqtrade/rpc/rpc.py
Normal file
392
freqtrade/rpc/rpc.py
Normal file
@@ -0,0 +1,392 @@
|
|||||||
|
"""
|
||||||
|
This module contains class to define a RPC communications
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from abc import abstractmethod
|
||||||
|
from datetime import datetime, timedelta, date
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Dict, Tuple, Any, List
|
||||||
|
|
||||||
|
import arrow
|
||||||
|
import sqlalchemy as sql
|
||||||
|
from numpy import mean, nan_to_num
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from freqtrade.misc import shorten_date
|
||||||
|
from freqtrade.persistence import Trade
|
||||||
|
from freqtrade.state import State
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RPCException(Exception):
|
||||||
|
"""
|
||||||
|
Should be raised with a rpc-formatted message in an _rpc_* method
|
||||||
|
if the required state is wrong, i.e.:
|
||||||
|
|
||||||
|
raise RPCException('*Status:* `no active trade`')
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RPC(object):
|
||||||
|
"""
|
||||||
|
RPC class can be used to have extra feature, like bot data, and access to DB data
|
||||||
|
"""
|
||||||
|
def __init__(self, freqtrade) -> None:
|
||||||
|
"""
|
||||||
|
Initializes all enabled rpc modules
|
||||||
|
:param freqtrade: Instance of a freqtrade bot
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
self._freqtrade = freqtrade
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
""" Cleanup pending module resources """
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def name(self) -> str:
|
||||||
|
""" Returns the lowercase name of this module """
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def send_msg(self, msg: str) -> None:
|
||||||
|
""" Sends a message to all registered rpc modules """
|
||||||
|
|
||||||
|
def _rpc_trade_status(self) -> List[str]:
|
||||||
|
"""
|
||||||
|
Below follows the RPC backend it is prefixed with rpc_ to raise awareness that it is
|
||||||
|
a remotely exposed function
|
||||||
|
"""
|
||||||
|
# Fetch open trade
|
||||||
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
|
if self._freqtrade.state != State.RUNNING:
|
||||||
|
raise RPCException('*Status:* `trader is not running`')
|
||||||
|
elif not trades:
|
||||||
|
raise RPCException('*Status:* `no active trade`')
|
||||||
|
else:
|
||||||
|
result = []
|
||||||
|
for trade in trades:
|
||||||
|
order = None
|
||||||
|
if trade.open_order_id:
|
||||||
|
order = self._freqtrade.exchange.get_order(trade.open_order_id, trade.pair)
|
||||||
|
# calculate profit and send message to user
|
||||||
|
current_rate = self._freqtrade.exchange.get_ticker(trade.pair, False)['bid']
|
||||||
|
current_profit = trade.calc_profit_percent(current_rate)
|
||||||
|
fmt_close_profit = '{:.2f}%'.format(
|
||||||
|
round(trade.close_profit * 100, 2)
|
||||||
|
) if trade.close_profit else None
|
||||||
|
message = "*Trade ID:* `{trade_id}`\n" \
|
||||||
|
"*Current Pair:* [{pair}]({market_url})\n" \
|
||||||
|
"*Open Since:* `{date}`\n" \
|
||||||
|
"*Amount:* `{amount}`\n" \
|
||||||
|
"*Open Rate:* `{open_rate:.8f}`\n" \
|
||||||
|
"*Close Rate:* `{close_rate}`\n" \
|
||||||
|
"*Current Rate:* `{current_rate:.8f}`\n" \
|
||||||
|
"*Close Profit:* `{close_profit}`\n" \
|
||||||
|
"*Current Profit:* `{current_profit:.2f}%`\n" \
|
||||||
|
"*Open Order:* `{open_order}`"\
|
||||||
|
.format(
|
||||||
|
trade_id=trade.id,
|
||||||
|
pair=trade.pair,
|
||||||
|
market_url=self._freqtrade.exchange.get_pair_detail_url(trade.pair),
|
||||||
|
date=arrow.get(trade.open_date).humanize(),
|
||||||
|
open_rate=trade.open_rate,
|
||||||
|
close_rate=trade.close_rate,
|
||||||
|
current_rate=current_rate,
|
||||||
|
amount=round(trade.amount, 8),
|
||||||
|
close_profit=fmt_close_profit,
|
||||||
|
current_profit=round(current_profit * 100, 2),
|
||||||
|
open_order='({} {} rem={:.8f})'.format(
|
||||||
|
order['type'], order['side'], order['remaining']
|
||||||
|
) if order else None,
|
||||||
|
)
|
||||||
|
result.append(message)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _rpc_status_table(self) -> DataFrame:
|
||||||
|
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
|
if self._freqtrade.state != State.RUNNING:
|
||||||
|
raise RPCException('*Status:* `trader is not running`')
|
||||||
|
elif not trades:
|
||||||
|
raise RPCException('*Status:* `no active order`')
|
||||||
|
else:
|
||||||
|
trades_list = []
|
||||||
|
for trade in trades:
|
||||||
|
# calculate profit and send message to user
|
||||||
|
current_rate = self._freqtrade.exchange.get_ticker(trade.pair, False)['bid']
|
||||||
|
trades_list.append([
|
||||||
|
trade.id,
|
||||||
|
trade.pair,
|
||||||
|
shorten_date(arrow.get(trade.open_date).humanize(only_distance=True)),
|
||||||
|
'{:.2f}%'.format(100 * trade.calc_profit_percent(current_rate))
|
||||||
|
])
|
||||||
|
|
||||||
|
columns = ['ID', 'Pair', 'Since', 'Profit']
|
||||||
|
df_statuses = DataFrame.from_records(trades_list, columns=columns)
|
||||||
|
df_statuses = df_statuses.set_index(columns[0])
|
||||||
|
return df_statuses
|
||||||
|
|
||||||
|
def _rpc_daily_profit(
|
||||||
|
self, timescale: int,
|
||||||
|
stake_currency: str, fiat_display_currency: str) -> List[List[Any]]:
|
||||||
|
today = datetime.utcnow().date()
|
||||||
|
profit_days: Dict[date, Dict] = {}
|
||||||
|
|
||||||
|
if not (isinstance(timescale, int) and timescale > 0):
|
||||||
|
raise RPCException('*Daily [n]:* `must be an integer greater than 0`')
|
||||||
|
|
||||||
|
fiat = self._freqtrade.fiat_converter
|
||||||
|
for day in range(0, timescale):
|
||||||
|
profitday = today - timedelta(days=day)
|
||||||
|
trades = Trade.query \
|
||||||
|
.filter(Trade.is_open.is_(False)) \
|
||||||
|
.filter(Trade.close_date >= profitday)\
|
||||||
|
.filter(Trade.close_date < (profitday + timedelta(days=1)))\
|
||||||
|
.order_by(Trade.close_date)\
|
||||||
|
.all()
|
||||||
|
curdayprofit = sum(trade.calc_profit() for trade in trades)
|
||||||
|
profit_days[profitday] = {
|
||||||
|
'amount': format(curdayprofit, '.8f'),
|
||||||
|
'trades': len(trades)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
key,
|
||||||
|
'{value:.8f} {symbol}'.format(
|
||||||
|
value=float(value['amount']),
|
||||||
|
symbol=stake_currency
|
||||||
|
),
|
||||||
|
'{value:.3f} {symbol}'.format(
|
||||||
|
value=fiat.convert_amount(
|
||||||
|
value['amount'],
|
||||||
|
stake_currency,
|
||||||
|
fiat_display_currency
|
||||||
|
),
|
||||||
|
symbol=fiat_display_currency
|
||||||
|
),
|
||||||
|
'{value} trade{s}'.format(
|
||||||
|
value=value['trades'],
|
||||||
|
s='' if value['trades'] < 2 else 's'
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for key, value in profit_days.items()
|
||||||
|
]
|
||||||
|
|
||||||
|
def _rpc_trade_statistics(
|
||||||
|
self, stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
|
||||||
|
""" Returns cumulative profit statistics """
|
||||||
|
trades = Trade.query.order_by(Trade.id).all()
|
||||||
|
|
||||||
|
profit_all_coin = []
|
||||||
|
profit_all_percent = []
|
||||||
|
profit_closed_coin = []
|
||||||
|
profit_closed_percent = []
|
||||||
|
durations = []
|
||||||
|
|
||||||
|
for trade in trades:
|
||||||
|
current_rate: float = 0.0
|
||||||
|
|
||||||
|
if not trade.open_rate:
|
||||||
|
continue
|
||||||
|
if trade.close_date:
|
||||||
|
durations.append((trade.close_date - trade.open_date).total_seconds())
|
||||||
|
|
||||||
|
if not trade.is_open:
|
||||||
|
profit_percent = trade.calc_profit_percent()
|
||||||
|
profit_closed_coin.append(trade.calc_profit())
|
||||||
|
profit_closed_percent.append(profit_percent)
|
||||||
|
else:
|
||||||
|
# Get current rate
|
||||||
|
current_rate = self._freqtrade.exchange.get_ticker(trade.pair, False)['bid']
|
||||||
|
profit_percent = trade.calc_profit_percent(rate=current_rate)
|
||||||
|
|
||||||
|
profit_all_coin.append(
|
||||||
|
trade.calc_profit(rate=Decimal(trade.close_rate or current_rate))
|
||||||
|
)
|
||||||
|
profit_all_percent.append(profit_percent)
|
||||||
|
|
||||||
|
best_pair = Trade.session.query(
|
||||||
|
Trade.pair, sql.func.sum(Trade.close_profit).label('profit_sum')
|
||||||
|
).filter(Trade.is_open.is_(False)) \
|
||||||
|
.group_by(Trade.pair) \
|
||||||
|
.order_by(sql.text('profit_sum DESC')).first()
|
||||||
|
|
||||||
|
if not best_pair:
|
||||||
|
raise RPCException('*Status:* `no closed trade`')
|
||||||
|
|
||||||
|
bp_pair, bp_rate = best_pair
|
||||||
|
|
||||||
|
# FIX: we want to keep fiatconverter in a state/environment,
|
||||||
|
# doing this will utilize its caching functionallity, instead we reinitialize it here
|
||||||
|
fiat = self._freqtrade.fiat_converter
|
||||||
|
# Prepare data to display
|
||||||
|
profit_closed_coin = round(sum(profit_closed_coin), 8)
|
||||||
|
profit_closed_percent = round(nan_to_num(mean(profit_closed_percent)) * 100, 2)
|
||||||
|
profit_closed_fiat = fiat.convert_amount(
|
||||||
|
profit_closed_coin,
|
||||||
|
stake_currency,
|
||||||
|
fiat_display_currency
|
||||||
|
)
|
||||||
|
profit_all_coin = round(sum(profit_all_coin), 8)
|
||||||
|
profit_all_percent = round(nan_to_num(mean(profit_all_percent)) * 100, 2)
|
||||||
|
profit_all_fiat = fiat.convert_amount(
|
||||||
|
profit_all_coin,
|
||||||
|
stake_currency,
|
||||||
|
fiat_display_currency
|
||||||
|
)
|
||||||
|
num = float(len(durations) or 1)
|
||||||
|
return {
|
||||||
|
'profit_closed_coin': profit_closed_coin,
|
||||||
|
'profit_closed_percent': profit_closed_percent,
|
||||||
|
'profit_closed_fiat': profit_closed_fiat,
|
||||||
|
'profit_all_coin': profit_all_coin,
|
||||||
|
'profit_all_percent': profit_all_percent,
|
||||||
|
'profit_all_fiat': profit_all_fiat,
|
||||||
|
'trade_count': len(trades),
|
||||||
|
'first_trade_date': arrow.get(trades[0].open_date).humanize(),
|
||||||
|
'latest_trade_date': arrow.get(trades[-1].open_date).humanize(),
|
||||||
|
'avg_duration': str(timedelta(seconds=sum(durations) / num)).split('.')[0],
|
||||||
|
'best_pair': bp_pair,
|
||||||
|
'best_rate': round(bp_rate * 100, 2),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _rpc_balance(self, fiat_display_currency: str) -> Tuple[List[Dict], float, str, float]:
|
||||||
|
""" Returns current account balance per crypto """
|
||||||
|
output = []
|
||||||
|
total = 0.0
|
||||||
|
for coin, balance in self._freqtrade.exchange.get_balances().items():
|
||||||
|
if not balance['total']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if coin == 'BTC':
|
||||||
|
rate = 1.0
|
||||||
|
else:
|
||||||
|
if coin == 'USDT':
|
||||||
|
rate = 1.0 / self._freqtrade.exchange.get_ticker('BTC/USDT', False)['bid']
|
||||||
|
else:
|
||||||
|
rate = self._freqtrade.exchange.get_ticker(coin + '/BTC', False)['bid']
|
||||||
|
est_btc: float = rate * balance['total']
|
||||||
|
total = total + est_btc
|
||||||
|
output.append(
|
||||||
|
{
|
||||||
|
'currency': coin,
|
||||||
|
'available': balance['free'],
|
||||||
|
'balance': balance['total'],
|
||||||
|
'pending': balance['used'],
|
||||||
|
'est_btc': est_btc
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if total == 0.0:
|
||||||
|
raise RPCException('`All balances are zero.`')
|
||||||
|
|
||||||
|
fiat = self._freqtrade.fiat_converter
|
||||||
|
symbol = fiat_display_currency
|
||||||
|
value = fiat.convert_amount(total, 'BTC', symbol)
|
||||||
|
return output, total, symbol, value
|
||||||
|
|
||||||
|
def _rpc_start(self) -> str:
|
||||||
|
""" Handler for start """
|
||||||
|
if self._freqtrade.state == State.RUNNING:
|
||||||
|
return '*Status:* `already running`'
|
||||||
|
|
||||||
|
self._freqtrade.state = State.RUNNING
|
||||||
|
return '`Starting trader ...`'
|
||||||
|
|
||||||
|
def _rpc_stop(self) -> str:
|
||||||
|
""" Handler for stop """
|
||||||
|
if self._freqtrade.state == State.RUNNING:
|
||||||
|
self._freqtrade.state = State.STOPPED
|
||||||
|
return '`Stopping trader ...`'
|
||||||
|
|
||||||
|
return '*Status:* `already stopped`'
|
||||||
|
|
||||||
|
def _rpc_reload_conf(self) -> str:
|
||||||
|
""" Handler for reload_conf. """
|
||||||
|
self._freqtrade.state = State.RELOAD_CONF
|
||||||
|
return '*Status:* `Reloading config ...`'
|
||||||
|
|
||||||
|
# FIX: no test for this!!!!
|
||||||
|
def _rpc_forcesell(self, trade_id) -> None:
|
||||||
|
"""
|
||||||
|
Handler for forcesell <id>.
|
||||||
|
Sells the given trade at current price
|
||||||
|
"""
|
||||||
|
def _exec_forcesell(trade: Trade) -> None:
|
||||||
|
# Check if there is there is an open order
|
||||||
|
if trade.open_order_id:
|
||||||
|
order = self._freqtrade.exchange.get_order(trade.open_order_id, trade.pair)
|
||||||
|
|
||||||
|
# Cancel open LIMIT_BUY orders and close trade
|
||||||
|
if order and order['status'] == 'open' \
|
||||||
|
and order['type'] == 'limit' \
|
||||||
|
and order['side'] == 'buy':
|
||||||
|
self._freqtrade.exchange.cancel_order(trade.open_order_id, trade.pair)
|
||||||
|
trade.close(order.get('price') or trade.open_rate)
|
||||||
|
# Do the best effort, if we don't know 'filled' amount, don't try selling
|
||||||
|
if order['filled'] is None:
|
||||||
|
return
|
||||||
|
trade.amount = order['filled']
|
||||||
|
|
||||||
|
# Ignore trades with an attached LIMIT_SELL order
|
||||||
|
if order and order['status'] == 'open' \
|
||||||
|
and order['type'] == 'limit' \
|
||||||
|
and order['side'] == 'sell':
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get current rate and execute sell
|
||||||
|
current_rate = self._freqtrade.exchange.get_ticker(trade.pair, False)['bid']
|
||||||
|
self._freqtrade.execute_sell(trade, current_rate)
|
||||||
|
# ---- EOF def _exec_forcesell ----
|
||||||
|
|
||||||
|
if self._freqtrade.state != State.RUNNING:
|
||||||
|
raise RPCException('`trader is not running`')
|
||||||
|
|
||||||
|
if trade_id == 'all':
|
||||||
|
# Execute sell for all open orders
|
||||||
|
for trade in Trade.query.filter(Trade.is_open.is_(True)).all():
|
||||||
|
_exec_forcesell(trade)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Query for trade
|
||||||
|
trade = Trade.query.filter(
|
||||||
|
sql.and_(
|
||||||
|
Trade.id == trade_id,
|
||||||
|
Trade.is_open.is_(True)
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if not trade:
|
||||||
|
logger.warning('forcesell: Invalid argument received')
|
||||||
|
raise RPCException('Invalid argument.')
|
||||||
|
|
||||||
|
_exec_forcesell(trade)
|
||||||
|
Trade.session.flush()
|
||||||
|
|
||||||
|
def _rpc_performance(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Handler for performance.
|
||||||
|
Shows a performance statistic from finished trades
|
||||||
|
"""
|
||||||
|
if self._freqtrade.state != State.RUNNING:
|
||||||
|
raise RPCException('`trader is not running`')
|
||||||
|
|
||||||
|
pair_rates = Trade.session.query(Trade.pair,
|
||||||
|
sql.func.sum(Trade.close_profit).label('profit_sum'),
|
||||||
|
sql.func.count(Trade.pair).label('count')) \
|
||||||
|
.filter(Trade.is_open.is_(False)) \
|
||||||
|
.group_by(Trade.pair) \
|
||||||
|
.order_by(sql.text('profit_sum DESC')) \
|
||||||
|
.all()
|
||||||
|
return [
|
||||||
|
{'pair': pair, 'profit': round(rate * 100, 2), 'count': count}
|
||||||
|
for pair, rate, count in pair_rates
|
||||||
|
]
|
||||||
|
|
||||||
|
def _rpc_count(self) -> List[Trade]:
|
||||||
|
""" Returns the number of trades running """
|
||||||
|
if self._freqtrade.state != State.RUNNING:
|
||||||
|
raise RPCException('`trader is not running`')
|
||||||
|
|
||||||
|
return Trade.query.filter(Trade.is_open.is_(True)).all()
|
||||||
44
freqtrade/rpc/rpc_manager.py
Normal file
44
freqtrade/rpc/rpc_manager.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
"""
|
||||||
|
This module contains class to manage RPC communications (Telegram, Slack, ...)
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from freqtrade.rpc.rpc import RPC
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RPCManager(object):
|
||||||
|
"""
|
||||||
|
Class to manage RPC objects (Telegram, Slack, ...)
|
||||||
|
"""
|
||||||
|
def __init__(self, freqtrade) -> None:
|
||||||
|
""" Initializes all enabled rpc modules """
|
||||||
|
self.registered_modules: List[RPC] = []
|
||||||
|
|
||||||
|
# Enable telegram
|
||||||
|
if freqtrade.config['telegram'].get('enabled', False):
|
||||||
|
logger.info('Enabling rpc.telegram ...')
|
||||||
|
from freqtrade.rpc.telegram import Telegram
|
||||||
|
self.registered_modules.append(Telegram(freqtrade))
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
|
""" Stops all enabled rpc modules """
|
||||||
|
logger.info('Cleaning up rpc modules ...')
|
||||||
|
while self.registered_modules:
|
||||||
|
mod = self.registered_modules.pop()
|
||||||
|
logger.debug('Cleaning up rpc.%s ...', mod.name)
|
||||||
|
mod.cleanup()
|
||||||
|
del mod
|
||||||
|
|
||||||
|
def send_msg(self, msg: str) -> None:
|
||||||
|
"""
|
||||||
|
Send given markdown message to all registered rpc modules
|
||||||
|
:param msg: message
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
logger.info('Sending rpc message: %s', msg)
|
||||||
|
for mod in self.registered_modules:
|
||||||
|
logger.debug('Forwarding message to rpc.%s', mod.name)
|
||||||
|
mod.send_msg(msg)
|
||||||
@@ -1,56 +1,104 @@
|
|||||||
import logging
|
# pragma pylint: disable=unused-argument, unused-variable, protected-access, invalid-name
|
||||||
from datetime import timedelta
|
|
||||||
from typing import Callable, Any
|
|
||||||
|
|
||||||
import arrow
|
"""
|
||||||
from sqlalchemy import and_, func, text
|
This module manage Telegram communication
|
||||||
from telegram import ParseMode, Bot, Update
|
"""
|
||||||
from telegram.error import NetworkError
|
import logging
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from tabulate import tabulate
|
||||||
|
from telegram import Bot, ParseMode, ReplyKeyboardMarkup, Update
|
||||||
|
from telegram.error import NetworkError, TelegramError
|
||||||
from telegram.ext import CommandHandler, Updater
|
from telegram.ext import CommandHandler, Updater
|
||||||
|
|
||||||
from freqtrade import exchange
|
from freqtrade.__init__ import __version__
|
||||||
from freqtrade.misc import get_state, State, update_state
|
from freqtrade.rpc.rpc import RPC, RPCException
|
||||||
from freqtrade.persistence import Trade
|
|
||||||
|
|
||||||
# Remove noisy log messages
|
|
||||||
logging.getLogger('requests.packages.urllib3').setLevel(logging.INFO)
|
|
||||||
logging.getLogger('telegram').setLevel(logging.INFO)
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_updater = None
|
logger.debug('Included module rpc.telegram ...')
|
||||||
_CONF = {}
|
|
||||||
|
|
||||||
|
|
||||||
def init(config: dict) -> None:
|
def authorized_only(command_handler: Callable[[Any, Bot, Update], None]) -> Callable[..., Any]:
|
||||||
|
"""
|
||||||
|
Decorator to check if the message comes from the correct chat_id
|
||||||
|
:param command_handler: Telegram CommandHandler
|
||||||
|
:return: decorated function
|
||||||
|
"""
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
""" Decorator logic """
|
||||||
|
update = kwargs.get('update') or args[1]
|
||||||
|
|
||||||
|
# Reject unauthorized messages
|
||||||
|
chat_id = int(self._config['telegram']['chat_id'])
|
||||||
|
|
||||||
|
if int(update.message.chat_id) != chat_id:
|
||||||
|
logger.info(
|
||||||
|
'Rejected unauthorized message from: %s',
|
||||||
|
update.message.chat_id
|
||||||
|
)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Executing handler: %s for chat_id: %s',
|
||||||
|
command_handler.__name__,
|
||||||
|
chat_id
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return command_handler(self, *args, **kwargs)
|
||||||
|
except BaseException:
|
||||||
|
logger.exception('Exception occurred within Telegram module')
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
class Telegram(RPC):
|
||||||
|
""" This class handles all telegram communication """
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return "telegram"
|
||||||
|
|
||||||
|
def __init__(self, freqtrade) -> None:
|
||||||
|
"""
|
||||||
|
Init the Telegram call, and init the super class RPC
|
||||||
|
:param freqtrade: Instance of a freqtrade bot
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
super().__init__(freqtrade)
|
||||||
|
|
||||||
|
self._updater: Updater = None
|
||||||
|
self._config = freqtrade.config
|
||||||
|
self._init()
|
||||||
|
|
||||||
|
def _init(self) -> None:
|
||||||
"""
|
"""
|
||||||
Initializes this module with the given config,
|
Initializes this module with the given config,
|
||||||
registers all known command handlers
|
registers all known command handlers
|
||||||
and starts polling for message updates
|
and starts polling for message updates
|
||||||
:param config: config to use
|
|
||||||
:return: None
|
|
||||||
"""
|
"""
|
||||||
global _updater
|
self._updater = Updater(token=self._config['telegram']['token'], workers=0)
|
||||||
|
|
||||||
_CONF.update(config)
|
|
||||||
if not _CONF['telegram']['enabled']:
|
|
||||||
return
|
|
||||||
|
|
||||||
_updater = Updater(token=config['telegram']['token'], workers=0)
|
|
||||||
|
|
||||||
# Register command handler and start telegram message polling
|
# Register command handler and start telegram message polling
|
||||||
handles = [
|
handles = [
|
||||||
CommandHandler('status', _status),
|
CommandHandler('status', self._status),
|
||||||
CommandHandler('profit', _profit),
|
CommandHandler('profit', self._profit),
|
||||||
CommandHandler('start', _start),
|
CommandHandler('balance', self._balance),
|
||||||
CommandHandler('stop', _stop),
|
CommandHandler('start', self._start),
|
||||||
CommandHandler('forcesell', _forcesell),
|
CommandHandler('stop', self._stop),
|
||||||
CommandHandler('performance', _performance),
|
CommandHandler('forcesell', self._forcesell),
|
||||||
|
CommandHandler('performance', self._performance),
|
||||||
|
CommandHandler('daily', self._daily),
|
||||||
|
CommandHandler('count', self._count),
|
||||||
|
CommandHandler('reload_conf', self._reload_conf),
|
||||||
|
CommandHandler('help', self._help),
|
||||||
|
CommandHandler('version', self._version),
|
||||||
]
|
]
|
||||||
for handle in handles:
|
for handle in handles:
|
||||||
_updater.dispatcher.add_handler(handle)
|
self._updater.dispatcher.add_handler(handle)
|
||||||
_updater.start_polling(
|
self._updater.start_polling(
|
||||||
clean=True,
|
clean=True,
|
||||||
bootstrap_retries=3,
|
bootstrap_retries=-1,
|
||||||
timeout=30,
|
timeout=30,
|
||||||
read_latency=60,
|
read_latency=60,
|
||||||
)
|
)
|
||||||
@@ -59,30 +107,19 @@ def init(config: dict) -> None:
|
|||||||
[h.command for h in handles]
|
[h.command for h in handles]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def cleanup(self) -> None:
|
||||||
def authorized_only(command_handler: Callable[[Bot, Update], None]) -> Callable[..., Any]:
|
|
||||||
"""
|
"""
|
||||||
Decorator to check if the message comes from the correct chat_id
|
Stops all running telegram threads.
|
||||||
:param command_handler: Telegram CommandHandler
|
:return: None
|
||||||
:return: decorated function
|
|
||||||
"""
|
"""
|
||||||
def wrapper(*args, **kwargs):
|
self._updater.stop()
|
||||||
bot, update = kwargs.get('bot') or args[0], kwargs.get('update') or args[1]
|
|
||||||
|
|
||||||
if not isinstance(bot, Bot) or not isinstance(update, Update):
|
def send_msg(self, msg: str) -> None:
|
||||||
raise ValueError('Received invalid Arguments: {}'.format(*args))
|
""" Send a message to telegram channel """
|
||||||
|
self._send_msg(msg)
|
||||||
|
|
||||||
chat_id = int(_CONF['telegram']['chat_id'])
|
@authorized_only
|
||||||
if int(update.message.chat_id) == chat_id:
|
def _status(self, bot: Bot, update: Update) -> None:
|
||||||
logger.info('Executing handler: %s for chat_id: %s', command_handler.__name__, chat_id)
|
|
||||||
return command_handler(*args, **kwargs)
|
|
||||||
else:
|
|
||||||
logger.info('Rejected unauthorized message from: %s', update.message.chat_id)
|
|
||||||
return wrapper
|
|
||||||
|
|
||||||
|
|
||||||
@authorized_only
|
|
||||||
def _status(bot: Bot, update: Update) -> None:
|
|
||||||
"""
|
"""
|
||||||
Handler for /status.
|
Handler for /status.
|
||||||
Returns the current TradeThread status
|
Returns the current TradeThread status
|
||||||
@@ -90,53 +127,70 @@ def _status(bot: Bot, update: Update) -> None:
|
|||||||
:param update: message update
|
:param update: message update
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
# Fetch open trade
|
|
||||||
trades = Trade.query.filter(Trade.is_open.is_(True)).all()
|
|
||||||
if get_state() != State.RUNNING:
|
|
||||||
send_msg('*Status:* `trader is not running`', bot=bot)
|
|
||||||
elif not trades:
|
|
||||||
send_msg('*Status:* `no active order`', bot=bot)
|
|
||||||
else:
|
|
||||||
for trade in trades:
|
|
||||||
# calculate profit and send message to user
|
|
||||||
current_rate = exchange.get_ticker(trade.pair)['bid']
|
|
||||||
current_profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate)
|
|
||||||
orders = exchange.get_open_orders(trade.pair)
|
|
||||||
orders = [o for o in orders if o['id'] == trade.open_order_id]
|
|
||||||
order = orders[0] if orders else None
|
|
||||||
|
|
||||||
fmt_close_profit = '{:.2f}%'.format(
|
# Check if additional parameters are passed
|
||||||
round(trade.close_profit, 2)
|
params = update.message.text.replace('/status', '').split(' ') \
|
||||||
) if trade.close_profit else None
|
if update.message.text else []
|
||||||
message = """
|
if 'table' in params:
|
||||||
*Trade ID:* `{trade_id}`
|
self._status_table(bot, update)
|
||||||
*Current Pair:* [{pair}]({market_url})
|
return
|
||||||
*Open Since:* `{date}`
|
|
||||||
*Amount:* `{amount}`
|
try:
|
||||||
*Open Rate:* `{open_rate}`
|
for trade_msg in self._rpc_trade_status():
|
||||||
*Close Rate:* `{close_rate}`
|
self._send_msg(trade_msg, bot=bot)
|
||||||
*Current Rate:* `{current_rate}`
|
except RPCException as e:
|
||||||
*Close Profit:* `{close_profit}`
|
self._send_msg(str(e), bot=bot)
|
||||||
*Current Profit:* `{current_profit:.2f}%`
|
|
||||||
*Open Order:* `{open_order}`
|
@authorized_only
|
||||||
""".format(
|
def _status_table(self, bot: Bot, update: Update) -> None:
|
||||||
trade_id=trade.id,
|
"""
|
||||||
pair=trade.pair,
|
Handler for /status table.
|
||||||
market_url=exchange.get_pair_detail_url(trade.pair),
|
Returns the current TradeThread status in table format
|
||||||
date=arrow.get(trade.open_date).humanize(),
|
:param bot: telegram bot
|
||||||
open_rate=trade.open_rate,
|
:param update: message update
|
||||||
close_rate=trade.close_rate,
|
:return: None
|
||||||
current_rate=current_rate,
|
"""
|
||||||
amount=round(trade.amount, 8),
|
try:
|
||||||
close_profit=fmt_close_profit,
|
df_statuses = self._rpc_status_table()
|
||||||
current_profit=round(current_profit, 2),
|
message = tabulate(df_statuses, headers='keys', tablefmt='simple')
|
||||||
open_order='{} ({})'.format(order['remaining'], order['type']) if order else None,
|
self._send_msg("<pre>{}</pre>".format(message), parse_mode=ParseMode.HTML)
|
||||||
|
except RPCException as e:
|
||||||
|
self._send_msg(str(e), bot=bot)
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def _daily(self, bot: Bot, update: Update) -> None:
|
||||||
|
"""
|
||||||
|
Handler for /daily <n>
|
||||||
|
Returns a daily profit (in BTC) over the last n days.
|
||||||
|
:param bot: telegram bot
|
||||||
|
:param update: message update
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
timescale = int(update.message.text.replace('/daily', '').strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
timescale = 7
|
||||||
|
try:
|
||||||
|
stats = self._rpc_daily_profit(
|
||||||
|
timescale,
|
||||||
|
self._config['stake_currency'],
|
||||||
|
self._config['fiat_display_currency']
|
||||||
)
|
)
|
||||||
send_msg(message, bot=bot)
|
stats = tabulate(stats,
|
||||||
|
headers=[
|
||||||
|
'Day',
|
||||||
|
'Profit {}'.format(self._config['stake_currency']),
|
||||||
|
'Profit {}'.format(self._config['fiat_display_currency'])
|
||||||
|
],
|
||||||
|
tablefmt='simple')
|
||||||
|
message = '<b>Daily Profit over the last {} days</b>:\n<pre>{}</pre>'\
|
||||||
|
.format(timescale, stats)
|
||||||
|
self._send_msg(message, bot=bot, parse_mode=ParseMode.HTML)
|
||||||
|
except RPCException as e:
|
||||||
|
self._send_msg(str(e), bot=bot)
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
@authorized_only
|
def _profit(self, bot: Bot, update: Update) -> None:
|
||||||
def _profit(bot: Bot, update: Update) -> None:
|
|
||||||
"""
|
"""
|
||||||
Handler for /profit.
|
Handler for /profit.
|
||||||
Returns a cumulative profit statistics.
|
Returns a cumulative profit statistics.
|
||||||
@@ -144,57 +198,66 @@ def _profit(bot: Bot, update: Update) -> None:
|
|||||||
:param update: message update
|
:param update: message update
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
trades = Trade.query.order_by(Trade.id).all()
|
try:
|
||||||
|
stats = self._rpc_trade_statistics(
|
||||||
|
self._config['stake_currency'],
|
||||||
|
self._config['fiat_display_currency'])
|
||||||
|
|
||||||
profit_amounts = []
|
# Message to display
|
||||||
profits = []
|
markdown_msg = "*ROI:* Close trades\n" \
|
||||||
durations = []
|
"∙ `{profit_closed_coin:.8f} {coin} ({profit_closed_percent:.2f}%)`\n" \
|
||||||
for trade in trades:
|
"∙ `{profit_closed_fiat:.3f} {fiat}`\n" \
|
||||||
if trade.close_date:
|
"*ROI:* All trades\n" \
|
||||||
durations.append((trade.close_date - trade.open_date).total_seconds())
|
"∙ `{profit_all_coin:.8f} {coin} ({profit_all_percent:.2f}%)`\n" \
|
||||||
if trade.close_profit:
|
"∙ `{profit_all_fiat:.3f} {fiat}`\n" \
|
||||||
profit = trade.close_profit
|
"*Total Trade Count:* `{trade_count}`\n" \
|
||||||
else:
|
"*First Trade opened:* `{first_trade_date}`\n" \
|
||||||
# Get current rate
|
"*Latest Trade opened:* `{latest_trade_date}`\n" \
|
||||||
current_rate = exchange.get_ticker(trade.pair)['bid']
|
"*Avg. Duration:* `{avg_duration}`\n" \
|
||||||
profit = 100 * ((current_rate - trade.open_rate) / trade.open_rate)
|
"*Best Performing:* `{best_pair}: {best_rate:.2f}%`"\
|
||||||
|
.format(
|
||||||
profit_amounts.append((profit / 100) * trade.stake_amount)
|
coin=self._config['stake_currency'],
|
||||||
profits.append(profit)
|
fiat=self._config['fiat_display_currency'],
|
||||||
|
profit_closed_coin=stats['profit_closed_coin'],
|
||||||
best_pair = Trade.session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \
|
profit_closed_percent=stats['profit_closed_percent'],
|
||||||
.filter(Trade.is_open.is_(False)) \
|
profit_closed_fiat=stats['profit_closed_fiat'],
|
||||||
.group_by(Trade.pair) \
|
profit_all_coin=stats['profit_all_coin'],
|
||||||
.order_by(text('profit_sum DESC')) \
|
profit_all_percent=stats['profit_all_percent'],
|
||||||
.first()
|
profit_all_fiat=stats['profit_all_fiat'],
|
||||||
|
trade_count=stats['trade_count'],
|
||||||
if not best_pair:
|
first_trade_date=stats['first_trade_date'],
|
||||||
send_msg('*Status:* `no closed trade`', bot=bot)
|
latest_trade_date=stats['latest_trade_date'],
|
||||||
return
|
avg_duration=stats['avg_duration'],
|
||||||
|
best_pair=stats['best_pair'],
|
||||||
bp_pair, bp_rate = best_pair
|
best_rate=stats['best_rate']
|
||||||
markdown_msg = """
|
|
||||||
*ROI:* `{profit_btc:.2f} ({profit:.2f}%)`
|
|
||||||
*Trade Count:* `{trade_count}`
|
|
||||||
*First Trade opened:* `{first_trade_date}`
|
|
||||||
*Latest Trade opened:* `{latest_trade_date}`
|
|
||||||
*Avg. Duration:* `{avg_duration}`
|
|
||||||
*Best Performing:* `{best_pair}: {best_rate:.2f}%`
|
|
||||||
""".format(
|
|
||||||
profit_btc=round(sum(profit_amounts), 8),
|
|
||||||
profit=round(sum(profits), 2),
|
|
||||||
trade_count=len(trades),
|
|
||||||
first_trade_date=arrow.get(trades[0].open_date).humanize(),
|
|
||||||
latest_trade_date=arrow.get(trades[-1].open_date).humanize(),
|
|
||||||
avg_duration=str(timedelta(seconds=sum(durations) / float(len(durations)))).split('.')[0],
|
|
||||||
best_pair=bp_pair,
|
|
||||||
best_rate=round(bp_rate, 2),
|
|
||||||
)
|
)
|
||||||
send_msg(markdown_msg, bot=bot)
|
self._send_msg(markdown_msg, bot=bot)
|
||||||
|
except RPCException as e:
|
||||||
|
self._send_msg(str(e), bot=bot)
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def _balance(self, bot: Bot, update: Update) -> None:
|
||||||
|
""" Handler for /balance """
|
||||||
|
try:
|
||||||
|
currencys, total, symbol, value = \
|
||||||
|
self._rpc_balance(self._config['fiat_display_currency'])
|
||||||
|
output = ''
|
||||||
|
for currency in currencys:
|
||||||
|
output += "*{currency}:*\n" \
|
||||||
|
"\t`Available: {available: .8f}`\n" \
|
||||||
|
"\t`Balance: {balance: .8f}`\n" \
|
||||||
|
"\t`Pending: {pending: .8f}`\n" \
|
||||||
|
"\t`Est. BTC: {est_btc: .8f}`\n".format(**currency)
|
||||||
|
|
||||||
@authorized_only
|
output += "\n*Estimated Value*:\n" \
|
||||||
def _start(bot: Bot, update: Update) -> None:
|
"\t`BTC: {0: .8f}`\n" \
|
||||||
|
"\t`{1}: {2: .2f}`\n".format(total, symbol, value)
|
||||||
|
self._send_msg(output, bot=bot)
|
||||||
|
except RPCException as e:
|
||||||
|
self._send_msg(str(e), bot=bot)
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def _start(self, bot: Bot, update: Update) -> None:
|
||||||
"""
|
"""
|
||||||
Handler for /start.
|
Handler for /start.
|
||||||
Starts TradeThread
|
Starts TradeThread
|
||||||
@@ -202,14 +265,11 @@ def _start(bot: Bot, update: Update) -> None:
|
|||||||
:param update: message update
|
:param update: message update
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
if get_state() == State.RUNNING:
|
msg = self._rpc_start()
|
||||||
send_msg('*Status:* `already running`', bot=bot)
|
self._send_msg(msg, bot=bot)
|
||||||
else:
|
|
||||||
update_state(State.RUNNING)
|
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
@authorized_only
|
def _stop(self, bot: Bot, update: Update) -> None:
|
||||||
def _stop(bot: Bot, update: Update) -> None:
|
|
||||||
"""
|
"""
|
||||||
Handler for /stop.
|
Handler for /stop.
|
||||||
Stops TradeThread
|
Stops TradeThread
|
||||||
@@ -217,15 +277,23 @@ def _stop(bot: Bot, update: Update) -> None:
|
|||||||
:param update: message update
|
:param update: message update
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
if get_state() == State.RUNNING:
|
msg = self._rpc_stop()
|
||||||
send_msg('`Stopping trader ...`', bot=bot)
|
self._send_msg(msg, bot=bot)
|
||||||
update_state(State.STOPPED)
|
|
||||||
else:
|
|
||||||
send_msg('*Status:* `already stopped`', bot=bot)
|
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def _reload_conf(self, bot: Bot, update: Update) -> None:
|
||||||
|
"""
|
||||||
|
Handler for /reload_conf.
|
||||||
|
Triggers a config file reload
|
||||||
|
:param bot: telegram bot
|
||||||
|
:param update: message update
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
msg = self._rpc_reload_conf()
|
||||||
|
self._send_msg(msg, bot=bot)
|
||||||
|
|
||||||
@authorized_only
|
@authorized_only
|
||||||
def _forcesell(bot: Bot, update: Update) -> None:
|
def _forcesell(self, bot: Bot, update: Update) -> None:
|
||||||
"""
|
"""
|
||||||
Handler for /forcesell <id>.
|
Handler for /forcesell <id>.
|
||||||
Sells the given trade at current price
|
Sells the given trade at current price
|
||||||
@@ -233,46 +301,15 @@ def _forcesell(bot: Bot, update: Update) -> None:
|
|||||||
:param update: message update
|
:param update: message update
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
if get_state() != State.RUNNING:
|
|
||||||
send_msg('`trader is not running`', bot=bot)
|
|
||||||
return
|
|
||||||
|
|
||||||
|
trade_id = update.message.text.replace('/forcesell', '').strip()
|
||||||
try:
|
try:
|
||||||
trade_id = int(update.message.text
|
self._rpc_forcesell(trade_id)
|
||||||
.replace('/forcesell', '')
|
except RPCException as e:
|
||||||
.strip())
|
self._send_msg(str(e), bot=bot)
|
||||||
# Query for trade
|
|
||||||
trade = Trade.query.filter(and_(
|
|
||||||
Trade.id == trade_id,
|
|
||||||
Trade.is_open.is_(True)
|
|
||||||
)).first()
|
|
||||||
if not trade:
|
|
||||||
send_msg('There is no open trade with ID: `{}`'.format(trade_id))
|
|
||||||
return
|
|
||||||
# Get current rate
|
|
||||||
current_rate = exchange.get_ticker(trade.pair)['bid']
|
|
||||||
# Get available balance
|
|
||||||
currency = trade.pair.split('_')[1]
|
|
||||||
balance = exchange.get_balance(currency)
|
|
||||||
# Execute sell
|
|
||||||
profit = trade.exec_sell_order(current_rate, balance)
|
|
||||||
message = '*{}:* Selling [{}]({}) at rate `{:f} (profit: {}%)`'.format(
|
|
||||||
trade.exchange,
|
|
||||||
trade.pair.replace('_', '/'),
|
|
||||||
exchange.get_pair_detail_url(trade.pair),
|
|
||||||
trade.close_rate,
|
|
||||||
round(profit, 2)
|
|
||||||
)
|
|
||||||
logger.info(message)
|
|
||||||
send_msg(message)
|
|
||||||
|
|
||||||
except ValueError:
|
@authorized_only
|
||||||
send_msg('Invalid argument. Usage: `/forcesell <trade_id>`')
|
def _performance(self, bot: Bot, update: Update) -> None:
|
||||||
logger.warning('/forcesell: Invalid argument received')
|
|
||||||
|
|
||||||
|
|
||||||
@authorized_only
|
|
||||||
def _performance(bot: Bot, update: Update) -> None:
|
|
||||||
"""
|
"""
|
||||||
Handler for /performance.
|
Handler for /performance.
|
||||||
Shows a performance statistic from finished trades
|
Shows a performance statistic from finished trades
|
||||||
@@ -280,28 +317,80 @@ def _performance(bot: Bot, update: Update) -> None:
|
|||||||
:param update: message update
|
:param update: message update
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
if get_state() != State.RUNNING:
|
try:
|
||||||
send_msg('`trader is not running`', bot=bot)
|
trades = self._rpc_performance()
|
||||||
return
|
stats = '\n'.join('{index}.\t<code>{pair}\t{profit:.2f}% ({count})</code>'.format(
|
||||||
|
|
||||||
pair_rates = Trade.session.query(Trade.pair, func.sum(Trade.close_profit).label('profit_sum')) \
|
|
||||||
.filter(Trade.is_open.is_(False)) \
|
|
||||||
.group_by(Trade.pair) \
|
|
||||||
.order_by(text('profit_sum DESC')) \
|
|
||||||
.all()
|
|
||||||
|
|
||||||
stats = '\n'.join('{index}. <code>{pair}\t{profit:.2f}%</code>'.format(
|
|
||||||
index=i + 1,
|
index=i + 1,
|
||||||
pair=pair,
|
pair=trade['pair'],
|
||||||
profit=round(rate, 2)
|
profit=trade['profit'],
|
||||||
) for i, (pair, rate) in enumerate(pair_rates))
|
count=trade['count']
|
||||||
|
) for i, trade in enumerate(trades))
|
||||||
|
message = '<b>Performance:</b>\n{}'.format(stats)
|
||||||
|
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||||
|
except RPCException as e:
|
||||||
|
self._send_msg(str(e), bot=bot)
|
||||||
|
|
||||||
message = '<b>Performance:</b>\n{}\n'.format(stats)
|
@authorized_only
|
||||||
|
def _count(self, bot: Bot, update: Update) -> None:
|
||||||
|
"""
|
||||||
|
Handler for /count.
|
||||||
|
Returns the number of trades running
|
||||||
|
:param bot: telegram bot
|
||||||
|
:param update: message update
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
trades = self._rpc_count()
|
||||||
|
message = tabulate({
|
||||||
|
'current': [len(trades)],
|
||||||
|
'max': [self._config['max_open_trades']],
|
||||||
|
'total stake': [sum((trade.open_rate * trade.amount) for trade in trades)]
|
||||||
|
}, headers=['current', 'max', 'total stake'], tablefmt='simple')
|
||||||
|
message = "<pre>{}</pre>".format(message)
|
||||||
logger.debug(message)
|
logger.debug(message)
|
||||||
send_msg(message, parse_mode=ParseMode.HTML)
|
self._send_msg(message, parse_mode=ParseMode.HTML)
|
||||||
|
except RPCException as e:
|
||||||
|
self._send_msg(str(e), bot=bot)
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def _help(self, bot: Bot, update: Update) -> None:
|
||||||
|
"""
|
||||||
|
Handler for /help.
|
||||||
|
Show commands of the bot
|
||||||
|
:param bot: telegram bot
|
||||||
|
:param update: message update
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
message = "*/start:* `Starts the trader`\n" \
|
||||||
|
"*/stop:* `Stops the trader`\n" \
|
||||||
|
"*/status [table]:* `Lists all open trades`\n" \
|
||||||
|
" *table :* `will display trades in a table`\n" \
|
||||||
|
"*/profit:* `Lists cumulative profit from all finished trades`\n" \
|
||||||
|
"*/forcesell <trade_id>|all:* `Instantly sells the given trade or all trades, " \
|
||||||
|
"regardless of profit`\n" \
|
||||||
|
"*/performance:* `Show performance of each finished trade grouped by pair`\n" \
|
||||||
|
"*/daily <n>:* `Shows profit or loss per day, over the last n days`\n" \
|
||||||
|
"*/count:* `Show number of trades running compared to allowed number of trades`" \
|
||||||
|
"\n" \
|
||||||
|
"*/balance:* `Show account balance per currency`\n" \
|
||||||
|
"*/help:* `This help message`\n" \
|
||||||
|
"*/version:* `Show version`"
|
||||||
|
|
||||||
def send_msg(msg: str, bot: Bot = None, parse_mode: ParseMode = ParseMode.MARKDOWN) -> None:
|
self._send_msg(message, bot=bot)
|
||||||
|
|
||||||
|
@authorized_only
|
||||||
|
def _version(self, bot: Bot, update: Update) -> None:
|
||||||
|
"""
|
||||||
|
Handler for /version.
|
||||||
|
Show version information
|
||||||
|
:param bot: telegram bot
|
||||||
|
:param update: message update
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
self._send_msg('*Version:* `{}`'.format(__version__), bot=bot)
|
||||||
|
|
||||||
|
def _send_msg(self, msg: str, bot: Bot = None,
|
||||||
|
parse_mode: ParseMode = ParseMode.MARKDOWN) -> None:
|
||||||
"""
|
"""
|
||||||
Send given markdown message
|
Send given markdown message
|
||||||
:param msg: message
|
:param msg: message
|
||||||
@@ -309,18 +398,37 @@ def send_msg(msg: str, bot: Bot = None, parse_mode: ParseMode = ParseMode.MARKDO
|
|||||||
:param parse_mode: telegram parse mode
|
:param parse_mode: telegram parse mode
|
||||||
:return: None
|
:return: None
|
||||||
"""
|
"""
|
||||||
if _CONF['telegram'].get('enabled', False):
|
bot = bot or self._updater.bot
|
||||||
|
|
||||||
|
keyboard = [['/daily', '/profit', '/balance'],
|
||||||
|
['/status', '/status table', '/performance'],
|
||||||
|
['/count', '/start', '/stop', '/help']]
|
||||||
|
|
||||||
|
reply_markup = ReplyKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
bot = bot or _updater.bot
|
|
||||||
try:
|
try:
|
||||||
bot.send_message(_CONF['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
bot.send_message(
|
||||||
except NetworkError as error:
|
self._config['telegram']['chat_id'],
|
||||||
|
text=msg,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
reply_markup=reply_markup
|
||||||
|
)
|
||||||
|
except NetworkError as network_err:
|
||||||
# Sometimes the telegram server resets the current connection,
|
# Sometimes the telegram server resets the current connection,
|
||||||
# if this is the case we send the message again.
|
# if this is the case we send the message again.
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'Got Telegram NetworkError: %s! Trying one more time.',
|
'Telegram NetworkError: %s! Trying one more time.',
|
||||||
error.message
|
network_err.message
|
||||||
|
)
|
||||||
|
bot.send_message(
|
||||||
|
self._config['telegram']['chat_id'],
|
||||||
|
text=msg,
|
||||||
|
parse_mode=parse_mode,
|
||||||
|
reply_markup=reply_markup
|
||||||
|
)
|
||||||
|
except TelegramError as telegram_err:
|
||||||
|
logger.warning(
|
||||||
|
'TelegramError: %s! Giving up on that message.',
|
||||||
|
telegram_err.message
|
||||||
)
|
)
|
||||||
bot.send_message(_CONF['telegram']['chat_id'], msg, parse_mode=parse_mode)
|
|
||||||
except Exception:
|
|
||||||
logger.exception('Exception occurred within Telegram API')
|
|
||||||
|
|||||||
15
freqtrade/state.py
Normal file
15
freqtrade/state.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# pragma pylint: disable=too-few-public-methods
|
||||||
|
|
||||||
|
"""
|
||||||
|
Bot state constant
|
||||||
|
"""
|
||||||
|
import enum
|
||||||
|
|
||||||
|
|
||||||
|
class State(enum.Enum):
|
||||||
|
"""
|
||||||
|
Bot application states
|
||||||
|
"""
|
||||||
|
RUNNING = 0
|
||||||
|
STOPPED = 1
|
||||||
|
RELOAD_CONF = 2
|
||||||
32
freqtrade/strategy/__init__.py
Normal file
32
freqtrade/strategy/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
|
from freqtrade.strategy.interface import IStrategy
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def import_strategy(strategy: IStrategy) -> IStrategy:
|
||||||
|
"""
|
||||||
|
Imports given Strategy instance to global scope
|
||||||
|
of freqtrade.strategy and returns an instance of it
|
||||||
|
"""
|
||||||
|
# Copy all attributes from base class and class
|
||||||
|
attr = deepcopy({**strategy.__class__.__dict__, **strategy.__dict__})
|
||||||
|
# Adjust module name
|
||||||
|
attr['__module__'] = 'freqtrade.strategy'
|
||||||
|
|
||||||
|
name = strategy.__class__.__name__
|
||||||
|
clazz = type(name, (IStrategy,), attr)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'Imported strategy %s.%s as %s.%s',
|
||||||
|
strategy.__module__, strategy.__class__.__name__,
|
||||||
|
clazz.__module__, strategy.__class__.__name__,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Modify global scope to declare class
|
||||||
|
globals()[name] = clazz
|
||||||
|
|
||||||
|
return clazz()
|
||||||
240
freqtrade/strategy/default_strategy.py
Normal file
240
freqtrade/strategy/default_strategy.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement
|
||||||
|
|
||||||
|
import talib.abstract as ta
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
import freqtrade.vendor.qtpylib.indicators as qtpylib
|
||||||
|
from freqtrade.indicator_helpers import fishers_inverse
|
||||||
|
from freqtrade.strategy.interface import IStrategy
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultStrategy(IStrategy):
|
||||||
|
"""
|
||||||
|
Default Strategy provided by freqtrade bot.
|
||||||
|
You can override it with your own strategy
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Minimal ROI designed for the strategy
|
||||||
|
minimal_roi = {
|
||||||
|
"40": 0.0,
|
||||||
|
"30": 0.01,
|
||||||
|
"20": 0.02,
|
||||||
|
"0": 0.04
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optimal stoploss designed for the strategy
|
||||||
|
stoploss = -0.10
|
||||||
|
|
||||||
|
# Optimal ticker interval for the strategy
|
||||||
|
ticker_interval = '5m'
|
||||||
|
|
||||||
|
def populate_indicators(self, dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Adds several different TA indicators to the given DataFrame
|
||||||
|
|
||||||
|
Performance Note: For the best performance be frugal on the number of indicators
|
||||||
|
you are using. Let uncomment only the indicator you are using in your strategies
|
||||||
|
or your hyperopt configuration, otherwise you will waste your memory and CPU usage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Momentum Indicator
|
||||||
|
# ------------------------------------
|
||||||
|
|
||||||
|
# ADX
|
||||||
|
dataframe['adx'] = ta.ADX(dataframe)
|
||||||
|
|
||||||
|
# Awesome oscillator
|
||||||
|
dataframe['ao'] = qtpylib.awesome_oscillator(dataframe)
|
||||||
|
"""
|
||||||
|
# Commodity Channel Index: values Oversold:<-100, Overbought:>100
|
||||||
|
dataframe['cci'] = ta.CCI(dataframe)
|
||||||
|
"""
|
||||||
|
# MACD
|
||||||
|
macd = ta.MACD(dataframe)
|
||||||
|
dataframe['macd'] = macd['macd']
|
||||||
|
dataframe['macdsignal'] = macd['macdsignal']
|
||||||
|
dataframe['macdhist'] = macd['macdhist']
|
||||||
|
|
||||||
|
# MFI
|
||||||
|
dataframe['mfi'] = ta.MFI(dataframe)
|
||||||
|
|
||||||
|
# Minus Directional Indicator / Movement
|
||||||
|
dataframe['minus_dm'] = ta.MINUS_DM(dataframe)
|
||||||
|
dataframe['minus_di'] = ta.MINUS_DI(dataframe)
|
||||||
|
|
||||||
|
# Plus Directional Indicator / Movement
|
||||||
|
dataframe['plus_dm'] = ta.PLUS_DM(dataframe)
|
||||||
|
dataframe['plus_di'] = ta.PLUS_DI(dataframe)
|
||||||
|
dataframe['minus_di'] = ta.MINUS_DI(dataframe)
|
||||||
|
|
||||||
|
"""
|
||||||
|
# ROC
|
||||||
|
dataframe['roc'] = ta.ROC(dataframe)
|
||||||
|
"""
|
||||||
|
# RSI
|
||||||
|
dataframe['rsi'] = ta.RSI(dataframe)
|
||||||
|
|
||||||
|
# Inverse Fisher transform on RSI, values [-1.0, 1.0] (https://goo.gl/2JGGoy)
|
||||||
|
dataframe['fisher_rsi'] = fishers_inverse(dataframe['rsi'])
|
||||||
|
|
||||||
|
# Inverse Fisher transform on RSI normalized, value [0.0, 100.0] (https://goo.gl/2JGGoy)
|
||||||
|
dataframe['fisher_rsi_norma'] = 50 * (dataframe['fisher_rsi'] + 1)
|
||||||
|
|
||||||
|
# Stoch
|
||||||
|
stoch = ta.STOCH(dataframe)
|
||||||
|
dataframe['slowd'] = stoch['slowd']
|
||||||
|
dataframe['slowk'] = stoch['slowk']
|
||||||
|
|
||||||
|
# Stoch fast
|
||||||
|
stoch_fast = ta.STOCHF(dataframe)
|
||||||
|
dataframe['fastd'] = stoch_fast['fastd']
|
||||||
|
dataframe['fastk'] = stoch_fast['fastk']
|
||||||
|
"""
|
||||||
|
# Stoch RSI
|
||||||
|
stoch_rsi = ta.STOCHRSI(dataframe)
|
||||||
|
dataframe['fastd_rsi'] = stoch_rsi['fastd']
|
||||||
|
dataframe['fastk_rsi'] = stoch_rsi['fastk']
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Overlap Studies
|
||||||
|
# ------------------------------------
|
||||||
|
|
||||||
|
# Previous Bollinger bands
|
||||||
|
# Because ta.BBANDS implementation is broken with small numbers, it actually
|
||||||
|
# returns middle band for all the three bands. Switch to qtpylib.bollinger_bands
|
||||||
|
# and use middle band instead.
|
||||||
|
dataframe['blower'] = ta.BBANDS(dataframe, nbdevup=2, nbdevdn=2)['lowerband']
|
||||||
|
|
||||||
|
# Bollinger bands
|
||||||
|
bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=20, stds=2)
|
||||||
|
dataframe['bb_lowerband'] = bollinger['lower']
|
||||||
|
dataframe['bb_middleband'] = bollinger['mid']
|
||||||
|
dataframe['bb_upperband'] = bollinger['upper']
|
||||||
|
|
||||||
|
# EMA - Exponential Moving Average
|
||||||
|
dataframe['ema3'] = ta.EMA(dataframe, timeperiod=3)
|
||||||
|
dataframe['ema5'] = ta.EMA(dataframe, timeperiod=5)
|
||||||
|
dataframe['ema10'] = ta.EMA(dataframe, timeperiod=10)
|
||||||
|
dataframe['ema50'] = ta.EMA(dataframe, timeperiod=50)
|
||||||
|
dataframe['ema100'] = ta.EMA(dataframe, timeperiod=100)
|
||||||
|
|
||||||
|
# SAR Parabol
|
||||||
|
dataframe['sar'] = ta.SAR(dataframe)
|
||||||
|
|
||||||
|
# SMA - Simple Moving Average
|
||||||
|
dataframe['sma'] = ta.SMA(dataframe, timeperiod=40)
|
||||||
|
|
||||||
|
# TEMA - Triple Exponential Moving Average
|
||||||
|
dataframe['tema'] = ta.TEMA(dataframe, timeperiod=9)
|
||||||
|
|
||||||
|
# Cycle Indicator
|
||||||
|
# ------------------------------------
|
||||||
|
# Hilbert Transform Indicator - SineWave
|
||||||
|
hilbert = ta.HT_SINE(dataframe)
|
||||||
|
dataframe['htsine'] = hilbert['sine']
|
||||||
|
dataframe['htleadsine'] = hilbert['leadsine']
|
||||||
|
|
||||||
|
# Pattern Recognition - Bullish candlestick patterns
|
||||||
|
# ------------------------------------
|
||||||
|
"""
|
||||||
|
# Hammer: values [0, 100]
|
||||||
|
dataframe['CDLHAMMER'] = ta.CDLHAMMER(dataframe)
|
||||||
|
# Inverted Hammer: values [0, 100]
|
||||||
|
dataframe['CDLINVERTEDHAMMER'] = ta.CDLINVERTEDHAMMER(dataframe)
|
||||||
|
# Dragonfly Doji: values [0, 100]
|
||||||
|
dataframe['CDLDRAGONFLYDOJI'] = ta.CDLDRAGONFLYDOJI(dataframe)
|
||||||
|
# Piercing Line: values [0, 100]
|
||||||
|
dataframe['CDLPIERCING'] = ta.CDLPIERCING(dataframe) # values [0, 100]
|
||||||
|
# Morningstar: values [0, 100]
|
||||||
|
dataframe['CDLMORNINGSTAR'] = ta.CDLMORNINGSTAR(dataframe) # values [0, 100]
|
||||||
|
# Three White Soldiers: values [0, 100]
|
||||||
|
dataframe['CDL3WHITESOLDIERS'] = ta.CDL3WHITESOLDIERS(dataframe) # values [0, 100]
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Pattern Recognition - Bearish candlestick patterns
|
||||||
|
# ------------------------------------
|
||||||
|
"""
|
||||||
|
# Hanging Man: values [0, 100]
|
||||||
|
dataframe['CDLHANGINGMAN'] = ta.CDLHANGINGMAN(dataframe)
|
||||||
|
# Shooting Star: values [0, 100]
|
||||||
|
dataframe['CDLSHOOTINGSTAR'] = ta.CDLSHOOTINGSTAR(dataframe)
|
||||||
|
# Gravestone Doji: values [0, 100]
|
||||||
|
dataframe['CDLGRAVESTONEDOJI'] = ta.CDLGRAVESTONEDOJI(dataframe)
|
||||||
|
# Dark Cloud Cover: values [0, 100]
|
||||||
|
dataframe['CDLDARKCLOUDCOVER'] = ta.CDLDARKCLOUDCOVER(dataframe)
|
||||||
|
# Evening Doji Star: values [0, 100]
|
||||||
|
dataframe['CDLEVENINGDOJISTAR'] = ta.CDLEVENINGDOJISTAR(dataframe)
|
||||||
|
# Evening Star: values [0, 100]
|
||||||
|
dataframe['CDLEVENINGSTAR'] = ta.CDLEVENINGSTAR(dataframe)
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Pattern Recognition - Bullish/Bearish candlestick patterns
|
||||||
|
# ------------------------------------
|
||||||
|
"""
|
||||||
|
# Three Line Strike: values [0, -100, 100]
|
||||||
|
dataframe['CDL3LINESTRIKE'] = ta.CDL3LINESTRIKE(dataframe)
|
||||||
|
# Spinning Top: values [0, -100, 100]
|
||||||
|
dataframe['CDLSPINNINGTOP'] = ta.CDLSPINNINGTOP(dataframe) # values [0, -100, 100]
|
||||||
|
# Engulfing: values [0, -100, 100]
|
||||||
|
dataframe['CDLENGULFING'] = ta.CDLENGULFING(dataframe) # values [0, -100, 100]
|
||||||
|
# Harami: values [0, -100, 100]
|
||||||
|
dataframe['CDLHARAMI'] = ta.CDLHARAMI(dataframe) # values [0, -100, 100]
|
||||||
|
# Three Outside Up/Down: values [0, -100, 100]
|
||||||
|
dataframe['CDL3OUTSIDE'] = ta.CDL3OUTSIDE(dataframe) # values [0, -100, 100]
|
||||||
|
# Three Inside Up/Down: values [0, -100, 100]
|
||||||
|
dataframe['CDL3INSIDE'] = ta.CDL3INSIDE(dataframe) # values [0, -100, 100]
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Chart type
|
||||||
|
# ------------------------------------
|
||||||
|
# Heikinashi stategy
|
||||||
|
heikinashi = qtpylib.heikinashi(dataframe)
|
||||||
|
dataframe['ha_open'] = heikinashi['open']
|
||||||
|
dataframe['ha_close'] = heikinashi['close']
|
||||||
|
dataframe['ha_high'] = heikinashi['high']
|
||||||
|
dataframe['ha_low'] = heikinashi['low']
|
||||||
|
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
def populate_buy_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Based on TA indicators, populates the buy signal for the given dataframe
|
||||||
|
:param dataframe: DataFrame
|
||||||
|
:return: DataFrame with buy column
|
||||||
|
"""
|
||||||
|
dataframe.loc[
|
||||||
|
(
|
||||||
|
(dataframe['rsi'] < 35) &
|
||||||
|
(dataframe['fastd'] < 35) &
|
||||||
|
(dataframe['adx'] > 30) &
|
||||||
|
(dataframe['plus_di'] > 0.5)
|
||||||
|
) |
|
||||||
|
(
|
||||||
|
(dataframe['adx'] > 65) &
|
||||||
|
(dataframe['plus_di'] > 0.5)
|
||||||
|
),
|
||||||
|
'buy'] = 1
|
||||||
|
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
def populate_sell_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Based on TA indicators, populates the sell signal for the given dataframe
|
||||||
|
:param dataframe: DataFrame
|
||||||
|
:return: DataFrame with buy column
|
||||||
|
"""
|
||||||
|
dataframe.loc[
|
||||||
|
(
|
||||||
|
(
|
||||||
|
(qtpylib.crossed_above(dataframe['rsi'], 70)) |
|
||||||
|
(qtpylib.crossed_above(dataframe['fastd'], 70))
|
||||||
|
) &
|
||||||
|
(dataframe['adx'] > 10) &
|
||||||
|
(dataframe['minus_di'] > 0)
|
||||||
|
) |
|
||||||
|
(
|
||||||
|
(dataframe['adx'] > 70) &
|
||||||
|
(dataframe['minus_di'] > 0.5)
|
||||||
|
),
|
||||||
|
'sell'] = 1
|
||||||
|
return dataframe
|
||||||
48
freqtrade/strategy/interface.py
Normal file
48
freqtrade/strategy/interface.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
IStrategy interface
|
||||||
|
This module defines the interface to apply for strategies
|
||||||
|
"""
|
||||||
|
from typing import Dict
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
|
||||||
|
class IStrategy(ABC):
|
||||||
|
"""
|
||||||
|
Interface for freqtrade strategies
|
||||||
|
Defines the mandatory structure must follow any custom strategies
|
||||||
|
|
||||||
|
Attributes you can use:
|
||||||
|
minimal_roi -> Dict: Minimal ROI designed for the strategy
|
||||||
|
stoploss -> float: optimal stoploss designed for the strategy
|
||||||
|
ticker_interval -> str: value of the ticker interval to use for the strategy
|
||||||
|
"""
|
||||||
|
|
||||||
|
minimal_roi: Dict
|
||||||
|
stoploss: float
|
||||||
|
ticker_interval: str
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def populate_indicators(self, dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Populate indicators that will be used in the Buy and Sell strategy
|
||||||
|
:param dataframe: Raw data from the exchange and parsed by parse_ticker_dataframe()
|
||||||
|
:return: a Dataframe with all mandatory indicators for the strategies
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def populate_buy_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Based on TA indicators, populates the buy signal for the given dataframe
|
||||||
|
:param dataframe: DataFrame
|
||||||
|
:return: DataFrame with buy column
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def populate_sell_trend(self, dataframe: DataFrame) -> DataFrame:
|
||||||
|
"""
|
||||||
|
Based on TA indicators, populates the sell signal for the given dataframe
|
||||||
|
:param dataframe: DataFrame
|
||||||
|
:return: DataFrame with sell column
|
||||||
|
"""
|
||||||
135
freqtrade/strategy/resolver.py
Normal file
135
freqtrade/strategy/resolver.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
# pragma pylint: disable=attribute-defined-outside-init
|
||||||
|
|
||||||
|
"""
|
||||||
|
This module load custom strategies
|
||||||
|
"""
|
||||||
|
import importlib.util
|
||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections import OrderedDict
|
||||||
|
from typing import Optional, Dict, Type
|
||||||
|
|
||||||
|
from freqtrade import constants
|
||||||
|
from freqtrade.strategy import import_strategy
|
||||||
|
from freqtrade.strategy.interface import IStrategy
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyResolver(object):
|
||||||
|
"""
|
||||||
|
This class contains all the logic to load custom strategy class
|
||||||
|
"""
|
||||||
|
|
||||||
|
__slots__ = ['strategy']
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[Dict] = None) -> None:
|
||||||
|
"""
|
||||||
|
Load the custom class from config parameter
|
||||||
|
:param config: configuration dictionary or None
|
||||||
|
"""
|
||||||
|
config = config or {}
|
||||||
|
|
||||||
|
# Verify the strategy is in the configuration, otherwise fallback to the default strategy
|
||||||
|
strategy_name = config.get('strategy') or constants.DEFAULT_STRATEGY
|
||||||
|
self.strategy: IStrategy = self._load_strategy(strategy_name,
|
||||||
|
extra_dir=config.get('strategy_path'))
|
||||||
|
|
||||||
|
# Set attributes
|
||||||
|
# Check if we need to override configuration
|
||||||
|
if 'minimal_roi' in config:
|
||||||
|
self.strategy.minimal_roi = config['minimal_roi']
|
||||||
|
logger.info("Override strategy \'minimal_roi\' with value in config file.")
|
||||||
|
|
||||||
|
if 'stoploss' in config:
|
||||||
|
self.strategy.stoploss = config['stoploss']
|
||||||
|
logger.info(
|
||||||
|
"Override strategy \'stoploss\' with value in config file: %s.", config['stoploss']
|
||||||
|
)
|
||||||
|
|
||||||
|
if 'ticker_interval' in config:
|
||||||
|
self.strategy.ticker_interval = config['ticker_interval']
|
||||||
|
logger.info(
|
||||||
|
"Override strategy \'ticker_interval\' with value in config file: %s.",
|
||||||
|
config['ticker_interval']
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort and apply type conversions
|
||||||
|
self.strategy.minimal_roi = OrderedDict(sorted(
|
||||||
|
{int(key): value for (key, value) in self.strategy.minimal_roi.items()}.items(),
|
||||||
|
key=lambda t: t[0]))
|
||||||
|
self.strategy.stoploss = float(self.strategy.stoploss)
|
||||||
|
|
||||||
|
def _load_strategy(
|
||||||
|
self, strategy_name: str, extra_dir: Optional[str] = None) -> IStrategy:
|
||||||
|
"""
|
||||||
|
Search and loads the specified strategy.
|
||||||
|
:param strategy_name: name of the module to import
|
||||||
|
:param extra_dir: additional directory to search for the given strategy
|
||||||
|
:return: Strategy instance or None
|
||||||
|
"""
|
||||||
|
current_path = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
abs_paths = [
|
||||||
|
os.path.join(os.getcwd(), 'user_data', 'strategies'),
|
||||||
|
current_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
if extra_dir:
|
||||||
|
# Add extra strategy directory on top of search paths
|
||||||
|
abs_paths.insert(0, extra_dir)
|
||||||
|
|
||||||
|
for path in abs_paths:
|
||||||
|
try:
|
||||||
|
strategy = self._search_strategy(path, strategy_name)
|
||||||
|
if strategy:
|
||||||
|
logger.info('Using resolved strategy %s from \'%s\'', strategy_name, path)
|
||||||
|
return import_strategy(strategy)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning('Path "%s" does not exist', path)
|
||||||
|
|
||||||
|
raise ImportError(
|
||||||
|
"Impossible to load Strategy '{}'. This class does not exist"
|
||||||
|
" or contains Python code errors".format(strategy_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_valid_strategies(module_path: str, strategy_name: str) -> Optional[Type[IStrategy]]:
|
||||||
|
"""
|
||||||
|
Returns a list of all possible strategies for the given module_path
|
||||||
|
:param module_path: absolute path to the module
|
||||||
|
:param strategy_name: Class name of the strategy
|
||||||
|
:return: Tuple with (name, class) or None
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Generate spec based on absolute path
|
||||||
|
spec = importlib.util.spec_from_file_location('unknown', module_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module) # type: ignore # importlib does not use typehints
|
||||||
|
|
||||||
|
valid_strategies_gen = (
|
||||||
|
obj for name, obj in inspect.getmembers(module, inspect.isclass)
|
||||||
|
if strategy_name == name and IStrategy in obj.__bases__
|
||||||
|
)
|
||||||
|
return next(valid_strategies_gen, None)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _search_strategy(directory: str, strategy_name: str) -> Optional[IStrategy]:
|
||||||
|
"""
|
||||||
|
Search for the strategy_name in the given directory
|
||||||
|
:param directory: relative or absolute directory path
|
||||||
|
:return: name of the strategy class
|
||||||
|
"""
|
||||||
|
logger.debug('Searching for strategy %s in \'%s\'', strategy_name, directory)
|
||||||
|
for entry in os.listdir(directory):
|
||||||
|
# Only consider python files
|
||||||
|
if not entry.endswith('.py'):
|
||||||
|
logger.debug('Ignoring %s', entry)
|
||||||
|
continue
|
||||||
|
strategy = StrategyResolver._get_valid_strategies(
|
||||||
|
os.path.abspath(os.path.join(directory, entry)), strategy_name
|
||||||
|
)
|
||||||
|
if strategy:
|
||||||
|
return strategy()
|
||||||
|
return None
|
||||||
709
freqtrade/tests/conftest.py
Normal file
709
freqtrade/tests/conftest.py
Normal file
@@ -0,0 +1,709 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from functools import reduce
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import arrow
|
||||||
|
import pytest
|
||||||
|
from jsonschema import validate
|
||||||
|
from telegram import Chat, Message, Update
|
||||||
|
|
||||||
|
from freqtrade.analyze import Analyze
|
||||||
|
from freqtrade import constants
|
||||||
|
from freqtrade.exchange import Exchange
|
||||||
|
from freqtrade.freqtradebot import FreqtradeBot
|
||||||
|
|
||||||
|
logging.getLogger('').setLevel(logging.INFO)
|
||||||
|
|
||||||
|
|
||||||
|
def log_has(line, logs):
|
||||||
|
# caplog mocker returns log as a tuple: ('freqtrade.analyze', logging.WARNING, 'foobar')
|
||||||
|
# and we want to match line against foobar in the tuple
|
||||||
|
return reduce(lambda a, b: a or b,
|
||||||
|
filter(lambda x: x[2] == line, logs),
|
||||||
|
False)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_exchange(mocker, api_mock=None) -> None:
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.validate_pairs', MagicMock())
|
||||||
|
if api_mock:
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock))
|
||||||
|
else:
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock())
|
||||||
|
|
||||||
|
|
||||||
|
def get_patched_exchange(mocker, config, api_mock=None) -> Exchange:
|
||||||
|
patch_exchange(mocker, api_mock)
|
||||||
|
exchange = Exchange(config)
|
||||||
|
return exchange
|
||||||
|
|
||||||
|
|
||||||
|
# Functions for recurrent object patching
|
||||||
|
def get_patched_freqtradebot(mocker, config) -> FreqtradeBot:
|
||||||
|
"""
|
||||||
|
This function patch _init_modules() to not call dependencies
|
||||||
|
:param mocker: a Mocker object to apply patches
|
||||||
|
:param config: Config to pass to the bot
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
# mocker.patch('freqtrade.fiat_convert.Market', {'price_usd': 12345.0})
|
||||||
|
patch_coinmarketcap(mocker, {'price_usd': 12345.0})
|
||||||
|
mocker.patch('freqtrade.freqtradebot.Analyze', MagicMock())
|
||||||
|
mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock())
|
||||||
|
mocker.patch('freqtrade.freqtradebot.persistence.init', MagicMock())
|
||||||
|
patch_exchange(mocker, None)
|
||||||
|
mocker.patch('freqtrade.freqtradebot.RPCManager._init', MagicMock())
|
||||||
|
mocker.patch('freqtrade.freqtradebot.RPCManager.send_msg', MagicMock())
|
||||||
|
mocker.patch('freqtrade.freqtradebot.Analyze.get_signal', MagicMock())
|
||||||
|
|
||||||
|
return FreqtradeBot(config)
|
||||||
|
|
||||||
|
|
||||||
|
def patch_coinmarketcap(mocker, value: Optional[Dict[str, float]] = None) -> None:
|
||||||
|
"""
|
||||||
|
Mocker to coinmarketcap to speed up tests
|
||||||
|
:param mocker: mocker to patch coinmarketcap class
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
|
||||||
|
tickermock = MagicMock(return_value={'price_usd': 12345.0})
|
||||||
|
listmock = MagicMock(return_value={'data': [{'id': 1, 'name': 'Bitcoin', 'symbol': 'BTC',
|
||||||
|
'website_slug': 'bitcoin'},
|
||||||
|
{'id': 1027, 'name': 'Ethereum', 'symbol': 'ETH',
|
||||||
|
'website_slug': 'ethereum'}
|
||||||
|
]})
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.fiat_convert.Market',
|
||||||
|
ticker=tickermock,
|
||||||
|
listings=listmock,
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def default_conf():
|
||||||
|
""" Returns validated configuration suitable for most tests """
|
||||||
|
configuration = {
|
||||||
|
"max_open_trades": 1,
|
||||||
|
"stake_currency": "BTC",
|
||||||
|
"stake_amount": 0.001,
|
||||||
|
"fiat_display_currency": "USD",
|
||||||
|
"ticker_interval": '5m',
|
||||||
|
"dry_run": True,
|
||||||
|
"minimal_roi": {
|
||||||
|
"40": 0.0,
|
||||||
|
"30": 0.01,
|
||||||
|
"20": 0.02,
|
||||||
|
"0": 0.04
|
||||||
|
},
|
||||||
|
"stoploss": -0.10,
|
||||||
|
"unfilledtimeout": 600,
|
||||||
|
"bid_strategy": {
|
||||||
|
"ask_last_balance": 0.0
|
||||||
|
},
|
||||||
|
"exchange": {
|
||||||
|
"name": "bittrex",
|
||||||
|
"enabled": True,
|
||||||
|
"key": "key",
|
||||||
|
"secret": "secret",
|
||||||
|
"pair_whitelist": [
|
||||||
|
"ETH/BTC",
|
||||||
|
"LTC/BTC",
|
||||||
|
"XRP/BTC",
|
||||||
|
"NEO/BTC"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"telegram": {
|
||||||
|
"enabled": True,
|
||||||
|
"token": "token",
|
||||||
|
"chat_id": "0"
|
||||||
|
},
|
||||||
|
"initial_state": "running",
|
||||||
|
"db_url": "sqlite://",
|
||||||
|
"loglevel": logging.DEBUG,
|
||||||
|
}
|
||||||
|
validate(configuration, constants.CONF_SCHEMA)
|
||||||
|
return configuration
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def update():
|
||||||
|
_update = Update(0)
|
||||||
|
_update.message = Message(0, 0, datetime.utcnow(), Chat(0, 0))
|
||||||
|
return _update
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fee():
|
||||||
|
return MagicMock(return_value=0.0025)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ticker():
|
||||||
|
return MagicMock(return_value={
|
||||||
|
'bid': 0.00001098,
|
||||||
|
'ask': 0.00001099,
|
||||||
|
'last': 0.00001098,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ticker_sell_up():
|
||||||
|
return MagicMock(return_value={
|
||||||
|
'bid': 0.00001172,
|
||||||
|
'ask': 0.00001173,
|
||||||
|
'last': 0.00001172,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ticker_sell_down():
|
||||||
|
return MagicMock(return_value={
|
||||||
|
'bid': 0.00001044,
|
||||||
|
'ask': 0.00001043,
|
||||||
|
'last': 0.00001044,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def markets():
|
||||||
|
return MagicMock(return_value=[
|
||||||
|
{
|
||||||
|
'id': 'ethbtc',
|
||||||
|
'symbol': 'ETH/BTC',
|
||||||
|
'base': 'ETH',
|
||||||
|
'quote': 'BTC',
|
||||||
|
'active': True,
|
||||||
|
'precision': {
|
||||||
|
'price': 8,
|
||||||
|
'amount': 8,
|
||||||
|
'cost': 8,
|
||||||
|
},
|
||||||
|
'lot': 0.00000001,
|
||||||
|
'limits': {
|
||||||
|
'amount': {
|
||||||
|
'min': 0.01,
|
||||||
|
'max': 1000,
|
||||||
|
},
|
||||||
|
'price': 500000,
|
||||||
|
'cost': {
|
||||||
|
'min': 1,
|
||||||
|
'max': 500000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'info': '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'tknbtc',
|
||||||
|
'symbol': 'TKN/BTC',
|
||||||
|
'base': 'TKN',
|
||||||
|
'quote': 'BTC',
|
||||||
|
'active': True,
|
||||||
|
'precision': {
|
||||||
|
'price': 8,
|
||||||
|
'amount': 8,
|
||||||
|
'cost': 8,
|
||||||
|
},
|
||||||
|
'lot': 0.00000001,
|
||||||
|
'limits': {
|
||||||
|
'amount': {
|
||||||
|
'min': 0.01,
|
||||||
|
'max': 1000,
|
||||||
|
},
|
||||||
|
'price': 500000,
|
||||||
|
'cost': {
|
||||||
|
'min': 1,
|
||||||
|
'max': 500000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'info': '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'blkbtc',
|
||||||
|
'symbol': 'BLK/BTC',
|
||||||
|
'base': 'BLK',
|
||||||
|
'quote': 'BTC',
|
||||||
|
'active': True,
|
||||||
|
'precision': {
|
||||||
|
'price': 8,
|
||||||
|
'amount': 8,
|
||||||
|
'cost': 8,
|
||||||
|
},
|
||||||
|
'lot': 0.00000001,
|
||||||
|
'limits': {
|
||||||
|
'amount': {
|
||||||
|
'min': 0.01,
|
||||||
|
'max': 1000,
|
||||||
|
},
|
||||||
|
'price': 500000,
|
||||||
|
'cost': {
|
||||||
|
'min': 1,
|
||||||
|
'max': 500000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'info': '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'ltcbtc',
|
||||||
|
'symbol': 'LTC/BTC',
|
||||||
|
'base': 'LTC',
|
||||||
|
'quote': 'BTC',
|
||||||
|
'active': False,
|
||||||
|
'precision': {
|
||||||
|
'price': 8,
|
||||||
|
'amount': 8,
|
||||||
|
'cost': 8,
|
||||||
|
},
|
||||||
|
'lot': 0.00000001,
|
||||||
|
'limits': {
|
||||||
|
'amount': {
|
||||||
|
'min': 0.01,
|
||||||
|
'max': 1000,
|
||||||
|
},
|
||||||
|
'price': 500000,
|
||||||
|
'cost': {
|
||||||
|
'min': 1,
|
||||||
|
'max': 500000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'info': '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'xrpbtc',
|
||||||
|
'symbol': 'XRP/BTC',
|
||||||
|
'base': 'XRP',
|
||||||
|
'quote': 'BTC',
|
||||||
|
'active': False,
|
||||||
|
'precision': {
|
||||||
|
'price': 8,
|
||||||
|
'amount': 8,
|
||||||
|
'cost': 8,
|
||||||
|
},
|
||||||
|
'lot': 0.00000001,
|
||||||
|
'limits': {
|
||||||
|
'amount': {
|
||||||
|
'min': 0.01,
|
||||||
|
'max': 1000,
|
||||||
|
},
|
||||||
|
'price': 500000,
|
||||||
|
'cost': {
|
||||||
|
'min': 1,
|
||||||
|
'max': 500000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'info': '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'neobtc',
|
||||||
|
'symbol': 'NEO/BTC',
|
||||||
|
'base': 'NEO',
|
||||||
|
'quote': 'BTC',
|
||||||
|
'active': False,
|
||||||
|
'precision': {
|
||||||
|
'price': 8,
|
||||||
|
'amount': 8,
|
||||||
|
'cost': 8,
|
||||||
|
},
|
||||||
|
'lot': 0.00000001,
|
||||||
|
'limits': {
|
||||||
|
'amount': {
|
||||||
|
'min': 0.01,
|
||||||
|
'max': 1000,
|
||||||
|
},
|
||||||
|
'price': 500000,
|
||||||
|
'cost': {
|
||||||
|
'min': 1,
|
||||||
|
'max': 500000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'info': '',
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def markets_empty():
|
||||||
|
return MagicMock(return_value=[])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='function')
|
||||||
|
def limit_buy_order():
|
||||||
|
return {
|
||||||
|
'id': 'mocked_limit_buy',
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'buy',
|
||||||
|
'pair': 'mocked',
|
||||||
|
'datetime': arrow.utcnow().isoformat(),
|
||||||
|
'price': 0.00001099,
|
||||||
|
'amount': 90.99181073,
|
||||||
|
'remaining': 0.0,
|
||||||
|
'status': 'closed'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def limit_buy_order_old():
|
||||||
|
return {
|
||||||
|
'id': 'mocked_limit_buy_old',
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'buy',
|
||||||
|
'pair': 'mocked',
|
||||||
|
'datetime': str(arrow.utcnow().shift(minutes=-601).datetime),
|
||||||
|
'price': 0.00001099,
|
||||||
|
'amount': 90.99181073,
|
||||||
|
'remaining': 90.99181073,
|
||||||
|
'status': 'open'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def limit_sell_order_old():
|
||||||
|
return {
|
||||||
|
'id': 'mocked_limit_sell_old',
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'sell',
|
||||||
|
'pair': 'ETH/BTC',
|
||||||
|
'datetime': arrow.utcnow().shift(minutes=-601).isoformat(),
|
||||||
|
'price': 0.00001099,
|
||||||
|
'amount': 90.99181073,
|
||||||
|
'remaining': 90.99181073,
|
||||||
|
'status': 'open'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def limit_buy_order_old_partial():
|
||||||
|
return {
|
||||||
|
'id': 'mocked_limit_buy_old_partial',
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'buy',
|
||||||
|
'pair': 'ETH/BTC',
|
||||||
|
'datetime': arrow.utcnow().shift(minutes=-601).isoformat(),
|
||||||
|
'price': 0.00001099,
|
||||||
|
'amount': 90.99181073,
|
||||||
|
'remaining': 67.99181073,
|
||||||
|
'status': 'open'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def limit_sell_order():
|
||||||
|
return {
|
||||||
|
'id': 'mocked_limit_sell',
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'sell',
|
||||||
|
'pair': 'mocked',
|
||||||
|
'datetime': arrow.utcnow().isoformat(),
|
||||||
|
'price': 0.00001173,
|
||||||
|
'amount': 90.99181073,
|
||||||
|
'remaining': 0.0,
|
||||||
|
'status': 'closed'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ticker_history():
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
1511686200000, # unix timestamp ms
|
||||||
|
8.794e-05, # open
|
||||||
|
8.948e-05, # high
|
||||||
|
8.794e-05, # low
|
||||||
|
8.88e-05, # close
|
||||||
|
0.0877869, # volume (in quote currency)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1511686500000,
|
||||||
|
8.88e-05,
|
||||||
|
8.942e-05,
|
||||||
|
8.88e-05,
|
||||||
|
8.893e-05,
|
||||||
|
0.05874751,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1511686800000,
|
||||||
|
8.891e-05,
|
||||||
|
8.893e-05,
|
||||||
|
8.875e-05,
|
||||||
|
8.877e-05,
|
||||||
|
0.7039405
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def tickers():
|
||||||
|
return MagicMock(return_value={
|
||||||
|
'ETH/BTC': {
|
||||||
|
'symbol': 'ETH/BTC',
|
||||||
|
'timestamp': 1522014806207,
|
||||||
|
'datetime': '2018-03-25T21:53:26.207Z',
|
||||||
|
'high': 0.061697,
|
||||||
|
'low': 0.060531,
|
||||||
|
'bid': 0.061588,
|
||||||
|
'bidVolume': 3.321,
|
||||||
|
'ask': 0.061655,
|
||||||
|
'askVolume': 0.212,
|
||||||
|
'vwap': 0.06105296,
|
||||||
|
'open': 0.060809,
|
||||||
|
'close': 0.060761,
|
||||||
|
'first': None,
|
||||||
|
'last': 0.061588,
|
||||||
|
'change': 1.281,
|
||||||
|
'percentage': None,
|
||||||
|
'average': None,
|
||||||
|
'baseVolume': 111649.001,
|
||||||
|
'quoteVolume': 6816.50176926,
|
||||||
|
'info': {}
|
||||||
|
},
|
||||||
|
'TKN/BTC': {
|
||||||
|
'symbol': 'TKN/BTC',
|
||||||
|
'timestamp': 1522014806169,
|
||||||
|
'datetime': '2018-03-25T21:53:26.169Z',
|
||||||
|
'high': 0.01885,
|
||||||
|
'low': 0.018497,
|
||||||
|
'bid': 0.018799,
|
||||||
|
'bidVolume': 8.38,
|
||||||
|
'ask': 0.018802,
|
||||||
|
'askVolume': 15.0,
|
||||||
|
'vwap': 0.01869197,
|
||||||
|
'open': 0.018585,
|
||||||
|
'close': 0.018573,
|
||||||
|
'baseVolume': 81058.66,
|
||||||
|
'quoteVolume': 2247.48374509,
|
||||||
|
},
|
||||||
|
'BLK/BTC': {
|
||||||
|
'symbol': 'BLK/BTC',
|
||||||
|
'timestamp': 1522014806072,
|
||||||
|
'datetime': '2018-03-25T21:53:26.720Z',
|
||||||
|
'high': 0.007745,
|
||||||
|
'low': 0.007512,
|
||||||
|
'bid': 0.007729,
|
||||||
|
'bidVolume': 0.01,
|
||||||
|
'ask': 0.007743,
|
||||||
|
'askVolume': 21.37,
|
||||||
|
'vwap': 0.00761466,
|
||||||
|
'open': 0.007653,
|
||||||
|
'close': 0.007652,
|
||||||
|
'first': None,
|
||||||
|
'last': 0.007743,
|
||||||
|
'change': 1.176,
|
||||||
|
'percentage': None,
|
||||||
|
'average': None,
|
||||||
|
'baseVolume': 295152.26,
|
||||||
|
'quoteVolume': 1515.14631229,
|
||||||
|
'info': {}
|
||||||
|
},
|
||||||
|
'LTC/BTC': {
|
||||||
|
'symbol': 'LTC/BTC',
|
||||||
|
'timestamp': 1523787258992,
|
||||||
|
'datetime': '2018-04-15T10:14:19.992Z',
|
||||||
|
'high': 0.015978,
|
||||||
|
'low': 0.0157,
|
||||||
|
'bid': 0.015954,
|
||||||
|
'bidVolume': 12.83,
|
||||||
|
'ask': 0.015957,
|
||||||
|
'askVolume': 0.49,
|
||||||
|
'vwap': 0.01581636,
|
||||||
|
'open': 0.015823,
|
||||||
|
'close': 0.01582,
|
||||||
|
'first': None,
|
||||||
|
'last': 0.015951,
|
||||||
|
'change': 0.809,
|
||||||
|
'percentage': None,
|
||||||
|
'average': None,
|
||||||
|
'baseVolume': 88620.68,
|
||||||
|
'quoteVolume': 1401.65697943,
|
||||||
|
'info': {}
|
||||||
|
},
|
||||||
|
'ETH/USDT': {
|
||||||
|
'symbol': 'ETH/USDT',
|
||||||
|
'timestamp': 1522014804118,
|
||||||
|
'datetime': '2018-03-25T21:53:24.118Z',
|
||||||
|
'high': 530.88,
|
||||||
|
'low': 512.0,
|
||||||
|
'bid': 529.73,
|
||||||
|
'bidVolume': 0.2,
|
||||||
|
'ask': 530.21,
|
||||||
|
'askVolume': 0.2464,
|
||||||
|
'vwap': 521.02438405,
|
||||||
|
'open': 527.27,
|
||||||
|
'close': 528.42,
|
||||||
|
'first': None,
|
||||||
|
'last': 530.21,
|
||||||
|
'change': 0.558,
|
||||||
|
'percentage': None,
|
||||||
|
'average': None,
|
||||||
|
'baseVolume': 72300.0659,
|
||||||
|
'quoteVolume': 37670097.3022171,
|
||||||
|
'info': {}
|
||||||
|
},
|
||||||
|
'TKN/USDT': {
|
||||||
|
'symbol': 'TKN/USDT',
|
||||||
|
'timestamp': 1522014806198,
|
||||||
|
'datetime': '2018-03-25T21:53:26.198Z',
|
||||||
|
'high': 8718.0,
|
||||||
|
'low': 8365.77,
|
||||||
|
'bid': 8603.64,
|
||||||
|
'bidVolume': 0.15846,
|
||||||
|
'ask': 8603.67,
|
||||||
|
'askVolume': 0.069147,
|
||||||
|
'vwap': 8536.35621697,
|
||||||
|
'open': 8680.0,
|
||||||
|
'close': 8680.0,
|
||||||
|
'first': None,
|
||||||
|
'last': 8603.67,
|
||||||
|
'change': -0.879,
|
||||||
|
'percentage': None,
|
||||||
|
'average': None,
|
||||||
|
'baseVolume': 30414.604298,
|
||||||
|
'quoteVolume': 259629896.48584127,
|
||||||
|
'info': {}
|
||||||
|
},
|
||||||
|
'BLK/USDT': {
|
||||||
|
'symbol': 'BLK/USDT',
|
||||||
|
'timestamp': 1522014806145,
|
||||||
|
'datetime': '2018-03-25T21:53:26.145Z',
|
||||||
|
'high': 66.95,
|
||||||
|
'low': 63.38,
|
||||||
|
'bid': 66.473,
|
||||||
|
'bidVolume': 4.968,
|
||||||
|
'ask': 66.54,
|
||||||
|
'askVolume': 2.704,
|
||||||
|
'vwap': 65.0526901,
|
||||||
|
'open': 66.43,
|
||||||
|
'close': 66.383,
|
||||||
|
'first': None,
|
||||||
|
'last': 66.5,
|
||||||
|
'change': 0.105,
|
||||||
|
'percentage': None,
|
||||||
|
'average': None,
|
||||||
|
'baseVolume': 294106.204,
|
||||||
|
'quoteVolume': 19132399.743954,
|
||||||
|
'info': {}
|
||||||
|
},
|
||||||
|
'LTC/USDT': {
|
||||||
|
'symbol': 'LTC/USDT',
|
||||||
|
'timestamp': 1523787257812,
|
||||||
|
'datetime': '2018-04-15T10:14:18.812Z',
|
||||||
|
'high': 129.94,
|
||||||
|
'low': 124.0,
|
||||||
|
'bid': 129.28,
|
||||||
|
'bidVolume': 0.03201,
|
||||||
|
'ask': 129.52,
|
||||||
|
'askVolume': 0.14529,
|
||||||
|
'vwap': 126.92838682,
|
||||||
|
'open': 127.0,
|
||||||
|
'close': 127.1,
|
||||||
|
'first': None,
|
||||||
|
'last': 129.28,
|
||||||
|
'change': 1.795,
|
||||||
|
'percentage': None,
|
||||||
|
'average': None,
|
||||||
|
'baseVolume': 59698.79897,
|
||||||
|
'quoteVolume': 29132399.743954,
|
||||||
|
'info': {}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def result():
|
||||||
|
with open('freqtrade/tests/testdata/UNITTEST_BTC-1m.json') as data_file:
|
||||||
|
return Analyze.parse_ticker_dataframe(json.load(data_file))
|
||||||
|
|
||||||
|
# FIX:
|
||||||
|
# Create an fixture/function
|
||||||
|
# that inserts a trade of some type and open-status
|
||||||
|
# return the open-order-id
|
||||||
|
# See tests in rpc/main that could use this
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def trades_for_order():
|
||||||
|
return [{'info': {'id': 34567,
|
||||||
|
'orderId': 123456,
|
||||||
|
'price': '0.24544100',
|
||||||
|
'qty': '8.00000000',
|
||||||
|
'commission': '0.00800000',
|
||||||
|
'commissionAsset': 'LTC',
|
||||||
|
'time': 1521663363189,
|
||||||
|
'isBuyer': True,
|
||||||
|
'isMaker': False,
|
||||||
|
'isBestMatch': True},
|
||||||
|
'timestamp': 1521663363189,
|
||||||
|
'datetime': '2018-03-21T20:16:03.189Z',
|
||||||
|
'symbol': 'LTC/ETH',
|
||||||
|
'id': '34567',
|
||||||
|
'order': '123456',
|
||||||
|
'type': None,
|
||||||
|
'side': 'buy',
|
||||||
|
'price': 0.245441,
|
||||||
|
'cost': 1.963528,
|
||||||
|
'amount': 8.0,
|
||||||
|
'fee': {'cost': 0.008, 'currency': 'LTC'}}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def trades_for_order2():
|
||||||
|
return [{'info': {'id': 34567,
|
||||||
|
'orderId': 123456,
|
||||||
|
'price': '0.24544100',
|
||||||
|
'qty': '8.00000000',
|
||||||
|
'commission': '0.00800000',
|
||||||
|
'commissionAsset': 'LTC',
|
||||||
|
'time': 1521663363189,
|
||||||
|
'isBuyer': True,
|
||||||
|
'isMaker': False,
|
||||||
|
'isBestMatch': True},
|
||||||
|
'timestamp': 1521663363189,
|
||||||
|
'datetime': '2018-03-21T20:16:03.189Z',
|
||||||
|
'symbol': 'LTC/ETH',
|
||||||
|
'id': '34567',
|
||||||
|
'order': '123456',
|
||||||
|
'type': None,
|
||||||
|
'side': 'buy',
|
||||||
|
'price': 0.245441,
|
||||||
|
'cost': 1.963528,
|
||||||
|
'amount': 4.0,
|
||||||
|
'fee': {'cost': 0.004, 'currency': 'LTC'}},
|
||||||
|
{'info': {'id': 34567,
|
||||||
|
'orderId': 123456,
|
||||||
|
'price': '0.24544100',
|
||||||
|
'qty': '8.00000000',
|
||||||
|
'commission': '0.00800000',
|
||||||
|
'commissionAsset': 'LTC',
|
||||||
|
'time': 1521663363189,
|
||||||
|
'isBuyer': True,
|
||||||
|
'isMaker': False,
|
||||||
|
'isBestMatch': True},
|
||||||
|
'timestamp': 1521663363189,
|
||||||
|
'datetime': '2018-03-21T20:16:03.189Z',
|
||||||
|
'symbol': 'LTC/ETH',
|
||||||
|
'id': '34567',
|
||||||
|
'order': '123456',
|
||||||
|
'type': None,
|
||||||
|
'side': 'buy',
|
||||||
|
'price': 0.245441,
|
||||||
|
'cost': 1.963528,
|
||||||
|
'amount': 4.0,
|
||||||
|
'fee': {'cost': 0.004, 'currency': 'LTC'}}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def buy_order_fee():
|
||||||
|
return {
|
||||||
|
'id': 'mocked_limit_buy_old',
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'buy',
|
||||||
|
'pair': 'mocked',
|
||||||
|
'datetime': str(arrow.utcnow().shift(minutes=-601).datetime),
|
||||||
|
'price': 0.245441,
|
||||||
|
'amount': 8.0,
|
||||||
|
'remaining': 90.99181073,
|
||||||
|
'status': 'closed',
|
||||||
|
'fee': None
|
||||||
|
}
|
||||||
731
freqtrade/tests/exchange/test_exchange.py
Normal file
731
freqtrade/tests/exchange/test_exchange.py
Normal file
@@ -0,0 +1,731 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring, C0103, bad-continuation, global-statement
|
||||||
|
# pragma pylint: disable=protected-access
|
||||||
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
|
from random import randint
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock, PropertyMock
|
||||||
|
|
||||||
|
import ccxt
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from freqtrade import OperationalException, DependencyException, TemporaryError
|
||||||
|
from freqtrade.exchange import Exchange, API_RETRY_COUNT
|
||||||
|
from freqtrade.tests.conftest import log_has, get_patched_exchange
|
||||||
|
|
||||||
|
|
||||||
|
def test_init(default_conf, mocker, caplog):
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
get_patched_exchange(mocker, default_conf)
|
||||||
|
assert log_has('Instance is running with dry_run enabled', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_exception(default_conf):
|
||||||
|
default_conf['exchange']['name'] = 'wrong_exchange_name'
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
OperationalException,
|
||||||
|
match='Exchange {} is not supported'.format(default_conf['exchange']['name'])):
|
||||||
|
Exchange(default_conf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_pairs(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.load_markets = MagicMock(return_value={
|
||||||
|
'ETH/BTC': '', 'LTC/BTC': '', 'XRP/BTC': '', 'NEO/BTC': ''
|
||||||
|
})
|
||||||
|
id_mock = PropertyMock(return_value='test_exchange')
|
||||||
|
type(api_mock).id = id_mock
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock))
|
||||||
|
Exchange(default_conf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_pairs_not_available(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.load_markets = MagicMock(return_value={})
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock))
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException, match=r'not available'):
|
||||||
|
Exchange(default_conf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_pairs_not_compatible(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.load_markets = MagicMock(return_value={
|
||||||
|
'ETH/BTC': '', 'TKN/BTC': '', 'TRST/BTC': '', 'SWT/BTC': '', 'BCC/BTC': ''
|
||||||
|
})
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['stake_currency'] = 'ETH'
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock))
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException, match=r'not compatible'):
|
||||||
|
Exchange(conf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_pairs_exception(default_conf, mocker, caplog):
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
api_mock = MagicMock()
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.name', PropertyMock(return_value='Binance'))
|
||||||
|
|
||||||
|
api_mock.load_markets = MagicMock(return_value={})
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange._init_ccxt', api_mock)
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException, match=r'Pair ETH/BTC is not available at Binance'):
|
||||||
|
Exchange(default_conf)
|
||||||
|
|
||||||
|
api_mock.load_markets = MagicMock(side_effect=ccxt.BaseError())
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock))
|
||||||
|
Exchange(default_conf)
|
||||||
|
assert log_has('Unable to validate pairs (assuming they are correct). Reason: ',
|
||||||
|
caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_pairs_stake_exception(default_conf, mocker, caplog):
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['stake_currency'] = 'ETH'
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.name = MagicMock(return_value='binance')
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange._init_ccxt', api_mock)
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
OperationalException,
|
||||||
|
match=r'Pair ETH/BTC not compatible with stake_currency: ETH'
|
||||||
|
):
|
||||||
|
Exchange(conf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_dry_run(default_conf, mocker):
|
||||||
|
default_conf['dry_run'] = True
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
|
||||||
|
order = exchange.buy(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
assert 'id' in order
|
||||||
|
assert 'dry_run_buy_' in order['id']
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_prod(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
order_id = 'test_prod_buy_{}'.format(randint(0, 10 ** 6))
|
||||||
|
api_mock.create_limit_buy_order = MagicMock(return_value={
|
||||||
|
'id': order_id,
|
||||||
|
'info': {
|
||||||
|
'foo': 'bar'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
default_conf['dry_run'] = False
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
|
||||||
|
order = exchange.buy(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
assert 'id' in order
|
||||||
|
assert 'info' in order
|
||||||
|
assert order['id'] == order_id
|
||||||
|
|
||||||
|
# test exception handling
|
||||||
|
with pytest.raises(DependencyException):
|
||||||
|
api_mock.create_limit_buy_order = MagicMock(side_effect=ccxt.InsufficientFunds)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.buy(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
|
||||||
|
with pytest.raises(DependencyException):
|
||||||
|
api_mock.create_limit_buy_order = MagicMock(side_effect=ccxt.InvalidOrder)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.buy(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
|
||||||
|
with pytest.raises(TemporaryError):
|
||||||
|
api_mock.create_limit_buy_order = MagicMock(side_effect=ccxt.NetworkError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.buy(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock.create_limit_buy_order = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.buy(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sell_dry_run(default_conf, mocker):
|
||||||
|
default_conf['dry_run'] = True
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
|
||||||
|
order = exchange.sell(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
assert 'id' in order
|
||||||
|
assert 'dry_run_sell_' in order['id']
|
||||||
|
|
||||||
|
|
||||||
|
def test_sell_prod(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
order_id = 'test_prod_sell_{}'.format(randint(0, 10 ** 6))
|
||||||
|
api_mock.create_limit_sell_order = MagicMock(return_value={
|
||||||
|
'id': order_id,
|
||||||
|
'info': {
|
||||||
|
'foo': 'bar'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
default_conf['dry_run'] = False
|
||||||
|
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
|
||||||
|
order = exchange.sell(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
assert 'id' in order
|
||||||
|
assert 'info' in order
|
||||||
|
assert order['id'] == order_id
|
||||||
|
|
||||||
|
# test exception handling
|
||||||
|
with pytest.raises(DependencyException):
|
||||||
|
api_mock.create_limit_sell_order = MagicMock(side_effect=ccxt.InsufficientFunds)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.sell(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
|
||||||
|
with pytest.raises(DependencyException):
|
||||||
|
api_mock.create_limit_sell_order = MagicMock(side_effect=ccxt.InvalidOrder)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.sell(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
|
||||||
|
with pytest.raises(TemporaryError):
|
||||||
|
api_mock.create_limit_sell_order = MagicMock(side_effect=ccxt.NetworkError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.sell(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock.create_limit_sell_order = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.sell(pair='ETH/BTC', rate=200, amount=1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_balance_dry_run(default_conf, mocker):
|
||||||
|
default_conf['dry_run'] = True
|
||||||
|
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
assert exchange.get_balance(currency='BTC') == 999.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_balance_prod(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.fetch_balance = MagicMock(return_value={'BTC': {'free': 123.4}})
|
||||||
|
default_conf['dry_run'] = False
|
||||||
|
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
|
||||||
|
assert exchange.get_balance(currency='BTC') == 123.4
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock.fetch_balance = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
|
||||||
|
exchange.get_balance(currency='BTC')
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_balances_dry_run(default_conf, mocker):
|
||||||
|
default_conf['dry_run'] = True
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
assert exchange.get_balances() == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_balances_prod(default_conf, mocker):
|
||||||
|
balance_item = {
|
||||||
|
'free': 10.0,
|
||||||
|
'total': 10.0,
|
||||||
|
'used': 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.fetch_balance = MagicMock(return_value={
|
||||||
|
'1ST': balance_item,
|
||||||
|
'2ST': balance_item,
|
||||||
|
'3ST': balance_item
|
||||||
|
})
|
||||||
|
default_conf['dry_run'] = False
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
assert len(exchange.get_balances()) == 3
|
||||||
|
assert exchange.get_balances()['1ST']['free'] == 10.0
|
||||||
|
assert exchange.get_balances()['1ST']['total'] == 10.0
|
||||||
|
assert exchange.get_balances()['1ST']['used'] == 0.0
|
||||||
|
|
||||||
|
with pytest.raises(TemporaryError):
|
||||||
|
api_mock.fetch_balance = MagicMock(side_effect=ccxt.NetworkError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_balances()
|
||||||
|
assert api_mock.fetch_balance.call_count == API_RETRY_COUNT + 1
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock.fetch_balance = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_balances()
|
||||||
|
assert api_mock.fetch_balance.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_tickers(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
tick = {'ETH/BTC': {
|
||||||
|
'symbol': 'ETH/BTC',
|
||||||
|
'bid': 0.5,
|
||||||
|
'ask': 1,
|
||||||
|
'last': 42,
|
||||||
|
}, 'BCH/BTC': {
|
||||||
|
'symbol': 'BCH/BTC',
|
||||||
|
'bid': 0.6,
|
||||||
|
'ask': 0.5,
|
||||||
|
'last': 41,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api_mock.fetch_tickers = MagicMock(return_value=tick)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
# retrieve original ticker
|
||||||
|
tickers = exchange.get_tickers()
|
||||||
|
|
||||||
|
assert 'ETH/BTC' in tickers
|
||||||
|
assert 'BCH/BTC' in tickers
|
||||||
|
assert tickers['ETH/BTC']['bid'] == 0.5
|
||||||
|
assert tickers['ETH/BTC']['ask'] == 1
|
||||||
|
assert tickers['BCH/BTC']['bid'] == 0.6
|
||||||
|
assert tickers['BCH/BTC']['ask'] == 0.5
|
||||||
|
|
||||||
|
with pytest.raises(TemporaryError): # test retrier
|
||||||
|
api_mock.fetch_tickers = MagicMock(side_effect=ccxt.NetworkError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_tickers()
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock.fetch_tickers = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_tickers()
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock.fetch_tickers = MagicMock(side_effect=ccxt.NotSupported)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_tickers()
|
||||||
|
|
||||||
|
api_mock.fetch_tickers = MagicMock(return_value={})
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_tickers()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ticker(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
tick = {
|
||||||
|
'symbol': 'ETH/BTC',
|
||||||
|
'bid': 0.00001098,
|
||||||
|
'ask': 0.00001099,
|
||||||
|
'last': 0.0001,
|
||||||
|
}
|
||||||
|
api_mock.fetch_ticker = MagicMock(return_value=tick)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
# retrieve original ticker
|
||||||
|
ticker = exchange.get_ticker(pair='ETH/BTC')
|
||||||
|
|
||||||
|
assert ticker['bid'] == 0.00001098
|
||||||
|
assert ticker['ask'] == 0.00001099
|
||||||
|
|
||||||
|
# change the ticker
|
||||||
|
tick = {
|
||||||
|
'symbol': 'ETH/BTC',
|
||||||
|
'bid': 0.5,
|
||||||
|
'ask': 1,
|
||||||
|
'last': 42,
|
||||||
|
}
|
||||||
|
api_mock.fetch_ticker = MagicMock(return_value=tick)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
|
||||||
|
# if not caching the result we should get the same ticker
|
||||||
|
# if not fetching a new result we should get the cached ticker
|
||||||
|
ticker = exchange.get_ticker(pair='ETH/BTC')
|
||||||
|
|
||||||
|
assert api_mock.fetch_ticker.call_count == 1
|
||||||
|
assert ticker['bid'] == 0.5
|
||||||
|
assert ticker['ask'] == 1
|
||||||
|
|
||||||
|
assert 'ETH/BTC' in exchange._cached_ticker
|
||||||
|
assert exchange._cached_ticker['ETH/BTC']['bid'] == 0.5
|
||||||
|
assert exchange._cached_ticker['ETH/BTC']['ask'] == 1
|
||||||
|
|
||||||
|
# Test caching
|
||||||
|
api_mock.fetch_ticker = MagicMock()
|
||||||
|
exchange.get_ticker(pair='ETH/BTC', refresh=False)
|
||||||
|
assert api_mock.fetch_ticker.call_count == 0
|
||||||
|
|
||||||
|
with pytest.raises(TemporaryError): # test retrier
|
||||||
|
api_mock.fetch_ticker = MagicMock(side_effect=ccxt.NetworkError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_ticker(pair='ETH/BTC', refresh=True)
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock.fetch_ticker = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_ticker(pair='ETH/BTC', refresh=True)
|
||||||
|
|
||||||
|
api_mock.fetch_ticker = MagicMock(return_value={})
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_ticker(pair='ETH/BTC', refresh=True)
|
||||||
|
|
||||||
|
|
||||||
|
def make_fetch_ohlcv_mock(data):
|
||||||
|
def fetch_ohlcv_mock(pair, timeframe, since):
|
||||||
|
if since:
|
||||||
|
assert since > data[-1][0]
|
||||||
|
return []
|
||||||
|
return data
|
||||||
|
return fetch_ohlcv_mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ticker_history(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
tick = [
|
||||||
|
[
|
||||||
|
1511686200000, # unix timestamp ms
|
||||||
|
1, # open
|
||||||
|
2, # high
|
||||||
|
3, # low
|
||||||
|
4, # close
|
||||||
|
5, # volume (in quote currency)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
type(api_mock).has = PropertyMock(return_value={'fetchOHLCV': True})
|
||||||
|
api_mock.fetch_ohlcv = MagicMock(side_effect=make_fetch_ohlcv_mock(tick))
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
|
||||||
|
# retrieve original ticker
|
||||||
|
ticks = exchange.get_ticker_history('ETH/BTC', default_conf['ticker_interval'])
|
||||||
|
assert ticks[0][0] == 1511686200000
|
||||||
|
assert ticks[0][1] == 1
|
||||||
|
assert ticks[0][2] == 2
|
||||||
|
assert ticks[0][3] == 3
|
||||||
|
assert ticks[0][4] == 4
|
||||||
|
assert ticks[0][5] == 5
|
||||||
|
|
||||||
|
# change ticker and ensure tick changes
|
||||||
|
new_tick = [
|
||||||
|
[
|
||||||
|
1511686210000, # unix timestamp ms
|
||||||
|
6, # open
|
||||||
|
7, # high
|
||||||
|
8, # low
|
||||||
|
9, # close
|
||||||
|
10, # volume (in quote currency)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
api_mock.fetch_ohlcv = MagicMock(side_effect=make_fetch_ohlcv_mock(new_tick))
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
|
||||||
|
ticks = exchange.get_ticker_history('ETH/BTC', default_conf['ticker_interval'])
|
||||||
|
assert ticks[0][0] == 1511686210000
|
||||||
|
assert ticks[0][1] == 6
|
||||||
|
assert ticks[0][2] == 7
|
||||||
|
assert ticks[0][3] == 8
|
||||||
|
assert ticks[0][4] == 9
|
||||||
|
assert ticks[0][5] == 10
|
||||||
|
|
||||||
|
with pytest.raises(TemporaryError): # test retrier
|
||||||
|
api_mock.fetch_ohlcv = MagicMock(side_effect=ccxt.NetworkError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
# new symbol to get around cache
|
||||||
|
exchange.get_ticker_history('ABCD/BTC', default_conf['ticker_interval'])
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock.fetch_ohlcv = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
# new symbol to get around cache
|
||||||
|
exchange.get_ticker_history('EFGH/BTC', default_conf['ticker_interval'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_ticker_history_sort(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
|
||||||
|
# GDAX use-case (real data from GDAX)
|
||||||
|
# This ticker history is ordered DESC (newest first, oldest last)
|
||||||
|
tick = [
|
||||||
|
[1527833100000, 0.07666, 0.07671, 0.07666, 0.07668, 16.65244264],
|
||||||
|
[1527832800000, 0.07662, 0.07666, 0.07662, 0.07666, 1.30051526],
|
||||||
|
[1527832500000, 0.07656, 0.07661, 0.07656, 0.07661, 12.034778840000001],
|
||||||
|
[1527832200000, 0.07658, 0.07658, 0.07655, 0.07656, 0.59780186],
|
||||||
|
[1527831900000, 0.07658, 0.07658, 0.07658, 0.07658, 1.76278136],
|
||||||
|
[1527831600000, 0.07658, 0.07658, 0.07658, 0.07658, 2.22646521],
|
||||||
|
[1527831300000, 0.07655, 0.07657, 0.07655, 0.07657, 1.1753],
|
||||||
|
[1527831000000, 0.07654, 0.07654, 0.07651, 0.07651, 0.8073060299999999],
|
||||||
|
[1527830700000, 0.07652, 0.07652, 0.07651, 0.07652, 10.04822687],
|
||||||
|
[1527830400000, 0.07649, 0.07651, 0.07649, 0.07651, 2.5734867]
|
||||||
|
]
|
||||||
|
type(api_mock).has = PropertyMock(return_value={'fetchOHLCV': True})
|
||||||
|
api_mock.fetch_ohlcv = MagicMock(side_effect=make_fetch_ohlcv_mock(tick))
|
||||||
|
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
|
||||||
|
# Test the ticker history sort
|
||||||
|
ticks = exchange.get_ticker_history('ETH/BTC', default_conf['ticker_interval'])
|
||||||
|
assert ticks[0][0] == 1527830400000
|
||||||
|
assert ticks[0][1] == 0.07649
|
||||||
|
assert ticks[0][2] == 0.07651
|
||||||
|
assert ticks[0][3] == 0.07649
|
||||||
|
assert ticks[0][4] == 0.07651
|
||||||
|
assert ticks[0][5] == 2.5734867
|
||||||
|
|
||||||
|
assert ticks[9][0] == 1527833100000
|
||||||
|
assert ticks[9][1] == 0.07666
|
||||||
|
assert ticks[9][2] == 0.07671
|
||||||
|
assert ticks[9][3] == 0.07666
|
||||||
|
assert ticks[9][4] == 0.07668
|
||||||
|
assert ticks[9][5] == 16.65244264
|
||||||
|
|
||||||
|
# Bittrex use-case (real data from Bittrex)
|
||||||
|
# This ticker history is ordered ASC (oldest first, newest last)
|
||||||
|
tick = [
|
||||||
|
[1527827700000, 0.07659999, 0.0766, 0.07627, 0.07657998, 1.85216924],
|
||||||
|
[1527828000000, 0.07657995, 0.07657995, 0.0763, 0.0763, 26.04051037],
|
||||||
|
[1527828300000, 0.0763, 0.07659998, 0.0763, 0.0764, 10.36434124],
|
||||||
|
[1527828600000, 0.0764, 0.0766, 0.0764, 0.0766, 5.71044773],
|
||||||
|
[1527828900000, 0.0764, 0.07666998, 0.0764, 0.07666998, 47.48888565],
|
||||||
|
[1527829200000, 0.0765, 0.07672999, 0.0765, 0.07672999, 3.37640326],
|
||||||
|
[1527829500000, 0.0766, 0.07675, 0.0765, 0.07675, 8.36203831],
|
||||||
|
[1527829800000, 0.07675, 0.07677999, 0.07620002, 0.076695, 119.22963884],
|
||||||
|
[1527830100000, 0.076695, 0.07671, 0.07624171, 0.07671, 1.80689244],
|
||||||
|
[1527830400000, 0.07671, 0.07674399, 0.07629216, 0.07655213, 2.31452783]
|
||||||
|
]
|
||||||
|
type(api_mock).has = PropertyMock(return_value={'fetchOHLCV': True})
|
||||||
|
api_mock.fetch_ohlcv = MagicMock(side_effect=make_fetch_ohlcv_mock(tick))
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
# Test the ticker history sort
|
||||||
|
ticks = exchange.get_ticker_history('ETH/BTC', default_conf['ticker_interval'])
|
||||||
|
assert ticks[0][0] == 1527827700000
|
||||||
|
assert ticks[0][1] == 0.07659999
|
||||||
|
assert ticks[0][2] == 0.0766
|
||||||
|
assert ticks[0][3] == 0.07627
|
||||||
|
assert ticks[0][4] == 0.07657998
|
||||||
|
assert ticks[0][5] == 1.85216924
|
||||||
|
|
||||||
|
assert ticks[9][0] == 1527830400000
|
||||||
|
assert ticks[9][1] == 0.07671
|
||||||
|
assert ticks[9][2] == 0.07674399
|
||||||
|
assert ticks[9][3] == 0.07629216
|
||||||
|
assert ticks[9][4] == 0.07655213
|
||||||
|
assert ticks[9][5] == 2.31452783
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel_order_dry_run(default_conf, mocker):
|
||||||
|
default_conf['dry_run'] = True
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
assert exchange.cancel_order(order_id='123', pair='TKN/BTC') is None
|
||||||
|
|
||||||
|
|
||||||
|
# Ensure that if not dry_run, we should call API
|
||||||
|
def test_cancel_order(default_conf, mocker):
|
||||||
|
default_conf['dry_run'] = False
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.cancel_order = MagicMock(return_value=123)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
assert exchange.cancel_order(order_id='_', pair='TKN/BTC') == 123
|
||||||
|
|
||||||
|
with pytest.raises(TemporaryError):
|
||||||
|
api_mock.cancel_order = MagicMock(side_effect=ccxt.NetworkError)
|
||||||
|
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.cancel_order(order_id='_', pair='TKN/BTC')
|
||||||
|
assert api_mock.cancel_order.call_count == API_RETRY_COUNT + 1
|
||||||
|
|
||||||
|
with pytest.raises(DependencyException):
|
||||||
|
api_mock.cancel_order = MagicMock(side_effect=ccxt.InvalidOrder)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.cancel_order(order_id='_', pair='TKN/BTC')
|
||||||
|
assert api_mock.cancel_order.call_count == API_RETRY_COUNT + 1
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock.cancel_order = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.cancel_order(order_id='_', pair='TKN/BTC')
|
||||||
|
assert api_mock.cancel_order.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_order(default_conf, mocker):
|
||||||
|
default_conf['dry_run'] = True
|
||||||
|
order = MagicMock()
|
||||||
|
order.myid = 123
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
exchange._dry_run_open_orders['X'] = order
|
||||||
|
print(exchange.get_order('X', 'TKN/BTC'))
|
||||||
|
assert exchange.get_order('X', 'TKN/BTC').myid == 123
|
||||||
|
|
||||||
|
default_conf['dry_run'] = False
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.fetch_order = MagicMock(return_value=456)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
assert exchange.get_order('X', 'TKN/BTC') == 456
|
||||||
|
|
||||||
|
with pytest.raises(TemporaryError):
|
||||||
|
api_mock.fetch_order = MagicMock(side_effect=ccxt.NetworkError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_order(order_id='_', pair='TKN/BTC')
|
||||||
|
assert api_mock.fetch_order.call_count == API_RETRY_COUNT + 1
|
||||||
|
|
||||||
|
with pytest.raises(DependencyException):
|
||||||
|
api_mock.fetch_order = MagicMock(side_effect=ccxt.InvalidOrder)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_order(order_id='_', pair='TKN/BTC')
|
||||||
|
assert api_mock.fetch_order.call_count == API_RETRY_COUNT + 1
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock.fetch_order = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_order(order_id='_', pair='TKN/BTC')
|
||||||
|
assert api_mock.fetch_order.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_name(default_conf, mocker):
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.validate_pairs',
|
||||||
|
side_effect=lambda s: True)
|
||||||
|
default_conf['exchange']['name'] = 'binance'
|
||||||
|
exchange = Exchange(default_conf)
|
||||||
|
|
||||||
|
assert exchange.name == 'Binance'
|
||||||
|
|
||||||
|
|
||||||
|
def test_id(default_conf, mocker):
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.validate_pairs',
|
||||||
|
side_effect=lambda s: True)
|
||||||
|
default_conf['exchange']['name'] = 'binance'
|
||||||
|
exchange = Exchange(default_conf)
|
||||||
|
assert exchange.id == 'binance'
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_pair_detail_url(default_conf, mocker, caplog):
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.validate_pairs',
|
||||||
|
side_effect=lambda s: True)
|
||||||
|
default_conf['exchange']['name'] = 'binance'
|
||||||
|
exchange = Exchange(default_conf)
|
||||||
|
|
||||||
|
url = exchange.get_pair_detail_url('TKN/ETH')
|
||||||
|
assert 'TKN' in url
|
||||||
|
assert 'ETH' in url
|
||||||
|
|
||||||
|
url = exchange.get_pair_detail_url('LOOONG/BTC')
|
||||||
|
assert 'LOOONG' in url
|
||||||
|
assert 'BTC' in url
|
||||||
|
|
||||||
|
default_conf['exchange']['name'] = 'bittrex'
|
||||||
|
exchange = Exchange(default_conf)
|
||||||
|
|
||||||
|
url = exchange.get_pair_detail_url('TKN/ETH')
|
||||||
|
assert 'TKN' in url
|
||||||
|
assert 'ETH' in url
|
||||||
|
|
||||||
|
url = exchange.get_pair_detail_url('LOOONG/BTC')
|
||||||
|
assert 'LOOONG' in url
|
||||||
|
assert 'BTC' in url
|
||||||
|
|
||||||
|
default_conf['exchange']['name'] = 'poloniex'
|
||||||
|
exchange = Exchange(default_conf)
|
||||||
|
url = exchange.get_pair_detail_url('LOOONG/BTC')
|
||||||
|
assert '' == url
|
||||||
|
assert log_has('Could not get exchange url for Poloniex', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_trades_for_order(default_conf, mocker):
|
||||||
|
order_id = 'ABCD-ABCD'
|
||||||
|
since = datetime(2018, 5, 5)
|
||||||
|
default_conf["dry_run"] = False
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.exchange_has', return_value=True)
|
||||||
|
api_mock = MagicMock()
|
||||||
|
|
||||||
|
api_mock.fetch_my_trades = MagicMock(return_value=[{'id': 'TTR67E-3PFBD-76IISV',
|
||||||
|
'order': 'ABCD-ABCD',
|
||||||
|
'info': {'pair': 'XLTCZBTC',
|
||||||
|
'time': 1519860024.4388,
|
||||||
|
'type': 'buy',
|
||||||
|
'ordertype': 'limit',
|
||||||
|
'price': '20.00000',
|
||||||
|
'cost': '38.62000',
|
||||||
|
'fee': '0.06179',
|
||||||
|
'vol': '5',
|
||||||
|
'id': 'ABCD-ABCD'},
|
||||||
|
'timestamp': 1519860024438,
|
||||||
|
'datetime': '2018-02-28T23:20:24.438Z',
|
||||||
|
'symbol': 'LTC/BTC',
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'buy',
|
||||||
|
'price': 165.0,
|
||||||
|
'amount': 0.2340606,
|
||||||
|
'fee': {'cost': 0.06179, 'currency': 'BTC'}
|
||||||
|
}])
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
|
||||||
|
orders = exchange.get_trades_for_order(order_id, 'LTC/BTC', since)
|
||||||
|
assert len(orders) == 1
|
||||||
|
assert orders[0]['price'] == 165
|
||||||
|
|
||||||
|
# test Exceptions
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.fetch_my_trades = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_trades_for_order(order_id, 'LTC/BTC', since)
|
||||||
|
|
||||||
|
with pytest.raises(TemporaryError):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.fetch_my_trades = MagicMock(side_effect=ccxt.NetworkError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_trades_for_order(order_id, 'LTC/BTC', since)
|
||||||
|
assert api_mock.fetch_my_trades.call_count == API_RETRY_COUNT + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_markets(default_conf, mocker, markets):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.fetch_markets = markets
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
ret = exchange.get_markets()
|
||||||
|
assert isinstance(ret, list)
|
||||||
|
assert len(ret) == 6
|
||||||
|
|
||||||
|
assert ret[0]["id"] == "ethbtc"
|
||||||
|
assert ret[0]["symbol"] == "ETH/BTC"
|
||||||
|
|
||||||
|
# test Exceptions
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.fetch_markets = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_markets()
|
||||||
|
|
||||||
|
with pytest.raises(TemporaryError):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.fetch_markets = MagicMock(side_effect=ccxt.NetworkError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_markets()
|
||||||
|
assert api_mock.fetch_markets.call_count == API_RETRY_COUNT + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_fee(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.calculate_fee = MagicMock(return_value={
|
||||||
|
'type': 'taker',
|
||||||
|
'currency': 'BTC',
|
||||||
|
'rate': 0.025,
|
||||||
|
'cost': 0.05
|
||||||
|
})
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
|
||||||
|
assert exchange.get_fee() == 0.025
|
||||||
|
|
||||||
|
# test Exceptions
|
||||||
|
with pytest.raises(OperationalException):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.calculate_fee = MagicMock(side_effect=ccxt.BaseError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_fee()
|
||||||
|
|
||||||
|
with pytest.raises(TemporaryError):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.calculate_fee = MagicMock(side_effect=ccxt.NetworkError)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
exchange.get_fee()
|
||||||
|
assert api_mock.calculate_fee.call_count == API_RETRY_COUNT + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_amount_lots(default_conf, mocker):
|
||||||
|
api_mock = MagicMock()
|
||||||
|
api_mock.amount_to_lots = MagicMock(return_value=1.0)
|
||||||
|
api_mock.markets = None
|
||||||
|
marketmock = MagicMock()
|
||||||
|
api_mock.load_markets = marketmock
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf, api_mock)
|
||||||
|
|
||||||
|
assert exchange.get_amount_lots('LTC/BTC', 1.54) == 1
|
||||||
|
assert marketmock.call_count == 1
|
||||||
705
freqtrade/tests/optimize/test_backtesting.py
Normal file
705
freqtrade/tests/optimize/test_backtesting.py
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring, W0212, line-too-long, C0103, unused-argument
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import pytest
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import List
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from arrow import Arrow
|
||||||
|
|
||||||
|
from freqtrade import optimize, constants, DependencyException
|
||||||
|
from freqtrade.analyze import Analyze
|
||||||
|
from freqtrade.arguments import Arguments, TimeRange
|
||||||
|
from freqtrade.optimize.backtesting import Backtesting, start, setup_configuration
|
||||||
|
from freqtrade.tests.conftest import log_has, patch_exchange
|
||||||
|
|
||||||
|
|
||||||
|
def get_args(args) -> List[str]:
|
||||||
|
return Arguments(args, '').get_parsed_arg()
|
||||||
|
|
||||||
|
|
||||||
|
def trim_dictlist(dict_list, num):
|
||||||
|
new = {}
|
||||||
|
for pair, pair_data in dict_list.items():
|
||||||
|
new[pair] = pair_data[num:]
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
def load_data_test(what):
|
||||||
|
timerange = TimeRange(None, 'line', 0, -101)
|
||||||
|
data = optimize.load_data(None, ticker_interval='1m',
|
||||||
|
pairs=['UNITTEST/BTC'], timerange=timerange)
|
||||||
|
pair = data['UNITTEST/BTC']
|
||||||
|
datalen = len(pair)
|
||||||
|
# Depending on the what parameter we now adjust the
|
||||||
|
# loaded data looks:
|
||||||
|
# pair :: [[ 1509836520000, unix timestamp in ms
|
||||||
|
# 0.00162008, open
|
||||||
|
# 0.00162008, high
|
||||||
|
# 0.00162008, low
|
||||||
|
# 0.00162008, close
|
||||||
|
# 108.14853839 base volume
|
||||||
|
# ]]
|
||||||
|
base = 0.001
|
||||||
|
if what == 'raise':
|
||||||
|
return {'UNITTEST/BTC': [
|
||||||
|
[
|
||||||
|
pair[x][0], # Keep old dates
|
||||||
|
x * base, # But replace O,H,L,C
|
||||||
|
x * base + 0.0001,
|
||||||
|
x * base - 0.0001,
|
||||||
|
x * base,
|
||||||
|
pair[x][5], # Keep old volume
|
||||||
|
] for x in range(0, datalen)
|
||||||
|
]}
|
||||||
|
if what == 'lower':
|
||||||
|
return {'UNITTEST/BTC': [
|
||||||
|
[
|
||||||
|
pair[x][0], # Keep old dates
|
||||||
|
1 - x * base, # But replace O,H,L,C
|
||||||
|
1 - x * base + 0.0001,
|
||||||
|
1 - x * base - 0.0001,
|
||||||
|
1 - x * base,
|
||||||
|
pair[x][5] # Keep old volume
|
||||||
|
] for x in range(0, datalen)
|
||||||
|
]}
|
||||||
|
if what == 'sine':
|
||||||
|
hz = 0.1 # frequency
|
||||||
|
return {'UNITTEST/BTC': [
|
||||||
|
[
|
||||||
|
pair[x][0], # Keep old dates
|
||||||
|
math.sin(x * hz) / 1000 + base, # But replace O,H,L,C
|
||||||
|
math.sin(x * hz) / 1000 + base + 0.0001,
|
||||||
|
math.sin(x * hz) / 1000 + base - 0.0001,
|
||||||
|
math.sin(x * hz) / 1000 + base,
|
||||||
|
pair[x][5] # Keep old volume
|
||||||
|
] for x in range(0, datalen)
|
||||||
|
]}
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def simple_backtest(config, contour, num_results, mocker) -> None:
|
||||||
|
patch_exchange(mocker)
|
||||||
|
backtesting = Backtesting(config)
|
||||||
|
|
||||||
|
data = load_data_test(contour)
|
||||||
|
processed = backtesting.tickerdata_to_dataframe(data)
|
||||||
|
assert isinstance(processed, dict)
|
||||||
|
results = backtesting.backtest(
|
||||||
|
{
|
||||||
|
'stake_amount': config['stake_amount'],
|
||||||
|
'processed': processed,
|
||||||
|
'max_open_trades': 1,
|
||||||
|
'realistic': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# results :: <class 'pandas.core.frame.DataFrame'>
|
||||||
|
assert len(results) == num_results
|
||||||
|
|
||||||
|
|
||||||
|
def mocked_load_data(datadir, pairs=[], ticker_interval='0m', refresh_pairs=False,
|
||||||
|
timerange=None, exchange=None):
|
||||||
|
tickerdata = optimize.load_tickerdata_file(datadir, 'UNITTEST/BTC', '1m', timerange=timerange)
|
||||||
|
pairdata = {'UNITTEST/BTC': tickerdata}
|
||||||
|
return pairdata
|
||||||
|
|
||||||
|
|
||||||
|
# use for mock freqtrade.exchange.get_ticker_history'
|
||||||
|
def _load_pair_as_ticks(pair, tickfreq):
|
||||||
|
ticks = optimize.load_data(None, ticker_interval=tickfreq, pairs=[pair])
|
||||||
|
ticks = trim_dictlist(ticks, -201)
|
||||||
|
return ticks[pair]
|
||||||
|
|
||||||
|
|
||||||
|
# FIX: fixturize this?
|
||||||
|
def _make_backtest_conf(mocker, conf=None, pair='UNITTEST/BTC', record=None):
|
||||||
|
data = optimize.load_data(None, ticker_interval='8m', pairs=[pair])
|
||||||
|
data = trim_dictlist(data, -201)
|
||||||
|
patch_exchange(mocker)
|
||||||
|
backtesting = Backtesting(conf)
|
||||||
|
return {
|
||||||
|
'stake_amount': conf['stake_amount'],
|
||||||
|
'processed': backtesting.tickerdata_to_dataframe(data),
|
||||||
|
'max_open_trades': 10,
|
||||||
|
'realistic': True,
|
||||||
|
'record': record
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _trend(signals, buy_value, sell_value):
|
||||||
|
n = len(signals['low'])
|
||||||
|
buy = np.zeros(n)
|
||||||
|
sell = np.zeros(n)
|
||||||
|
for i in range(0, len(signals['buy'])):
|
||||||
|
if random.random() > 0.5: # Both buy and sell signals at same timeframe
|
||||||
|
buy[i] = buy_value
|
||||||
|
sell[i] = sell_value
|
||||||
|
signals['buy'] = buy
|
||||||
|
signals['sell'] = sell
|
||||||
|
return signals
|
||||||
|
|
||||||
|
|
||||||
|
def _trend_alternate(dataframe=None):
|
||||||
|
signals = dataframe
|
||||||
|
low = signals['low']
|
||||||
|
n = len(low)
|
||||||
|
buy = np.zeros(n)
|
||||||
|
sell = np.zeros(n)
|
||||||
|
for i in range(0, len(buy)):
|
||||||
|
if i % 2 == 0:
|
||||||
|
buy[i] = 1
|
||||||
|
else:
|
||||||
|
sell[i] = 1
|
||||||
|
signals['buy'] = buy
|
||||||
|
signals['sell'] = sell
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
|
||||||
|
# Unit tests
|
||||||
|
def test_setup_configuration_without_arguments(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test setup_configuration() function
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(default_conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
args = [
|
||||||
|
'--config', 'config.json',
|
||||||
|
'--strategy', 'DefaultStrategy',
|
||||||
|
'backtesting'
|
||||||
|
]
|
||||||
|
|
||||||
|
config = setup_configuration(get_args(args))
|
||||||
|
assert 'max_open_trades' in config
|
||||||
|
assert 'stake_currency' in config
|
||||||
|
assert 'stake_amount' in config
|
||||||
|
assert 'exchange' in config
|
||||||
|
assert 'pair_whitelist' in config['exchange']
|
||||||
|
assert 'datadir' in config
|
||||||
|
assert log_has(
|
||||||
|
'Using data folder: {} ...'.format(config['datadir']),
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
assert 'ticker_interval' in config
|
||||||
|
assert not log_has('Parameter -i/--ticker-interval detected ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'live' not in config
|
||||||
|
assert not log_has('Parameter -l/--live detected ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'realistic_simulation' not in config
|
||||||
|
assert not log_has('Parameter --realistic-simulation detected ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'refresh_pairs' not in config
|
||||||
|
assert not log_has('Parameter -r/--refresh-pairs-cached detected ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'timerange' not in config
|
||||||
|
assert 'export' not in config
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_configuration_with_arguments(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test setup_configuration() function
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(default_conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
args = [
|
||||||
|
'--config', 'config.json',
|
||||||
|
'--strategy', 'DefaultStrategy',
|
||||||
|
'--datadir', '/foo/bar',
|
||||||
|
'backtesting',
|
||||||
|
'--ticker-interval', '1m',
|
||||||
|
'--live',
|
||||||
|
'--realistic-simulation',
|
||||||
|
'--refresh-pairs-cached',
|
||||||
|
'--timerange', ':100',
|
||||||
|
'--export', '/bar/foo',
|
||||||
|
'--export-filename', 'foo_bar.json'
|
||||||
|
]
|
||||||
|
|
||||||
|
config = setup_configuration(get_args(args))
|
||||||
|
assert 'max_open_trades' in config
|
||||||
|
assert 'stake_currency' in config
|
||||||
|
assert 'stake_amount' in config
|
||||||
|
assert 'exchange' in config
|
||||||
|
assert 'pair_whitelist' in config['exchange']
|
||||||
|
assert 'datadir' in config
|
||||||
|
assert log_has(
|
||||||
|
'Using data folder: {} ...'.format(config['datadir']),
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
assert 'ticker_interval' in config
|
||||||
|
assert log_has('Parameter -i/--ticker-interval detected ...', caplog.record_tuples)
|
||||||
|
assert log_has(
|
||||||
|
'Using ticker_interval: 1m ...',
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'live' in config
|
||||||
|
assert log_has('Parameter -l/--live detected ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'realistic_simulation' in config
|
||||||
|
assert log_has('Parameter --realistic-simulation detected ...', caplog.record_tuples)
|
||||||
|
assert log_has('Using max_open_trades: 1 ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'refresh_pairs' in config
|
||||||
|
assert log_has('Parameter -r/--refresh-pairs-cached detected ...', caplog.record_tuples)
|
||||||
|
assert 'timerange' in config
|
||||||
|
assert log_has(
|
||||||
|
'Parameter --timerange detected: {} ...'.format(config['timerange']),
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'export' in config
|
||||||
|
assert log_has(
|
||||||
|
'Parameter --export detected: {} ...'.format(config['export']),
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
assert 'exportfilename' in config
|
||||||
|
assert log_has(
|
||||||
|
'Storing backtest results to {} ...'.format(config['exportfilename']),
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_configuration_unlimited_stake_amount(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test setup_configuration() function
|
||||||
|
"""
|
||||||
|
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['stake_amount'] = constants.UNLIMITED_STAKE_AMOUNT
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
args = [
|
||||||
|
'--config', 'config.json',
|
||||||
|
'--strategy', 'DefaultStrategy',
|
||||||
|
'backtesting'
|
||||||
|
]
|
||||||
|
|
||||||
|
with pytest.raises(DependencyException, match=r'.*stake amount.*'):
|
||||||
|
setup_configuration(get_args(args))
|
||||||
|
|
||||||
|
|
||||||
|
def test_start(mocker, fee, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test start() function
|
||||||
|
"""
|
||||||
|
start_mock = MagicMock()
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mocker.patch('freqtrade.optimize.backtesting.Backtesting.start', start_mock)
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(default_conf)
|
||||||
|
))
|
||||||
|
args = [
|
||||||
|
'--config', 'config.json',
|
||||||
|
'--strategy', 'DefaultStrategy',
|
||||||
|
'backtesting'
|
||||||
|
]
|
||||||
|
args = get_args(args)
|
||||||
|
start(args)
|
||||||
|
assert log_has(
|
||||||
|
'Starting freqtrade in Backtesting mode',
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
assert start_mock.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtesting_init(mocker, default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test Backtesting._init() method
|
||||||
|
"""
|
||||||
|
patch_exchange(mocker)
|
||||||
|
get_fee = mocker.patch('freqtrade.exchange.Exchange.get_fee', MagicMock(return_value=0.5))
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
assert backtesting.config == default_conf
|
||||||
|
assert isinstance(backtesting.analyze, Analyze)
|
||||||
|
assert backtesting.ticker_interval == '5m'
|
||||||
|
assert callable(backtesting.tickerdata_to_dataframe)
|
||||||
|
assert callable(backtesting.populate_buy_trend)
|
||||||
|
assert callable(backtesting.populate_sell_trend)
|
||||||
|
get_fee.assert_called()
|
||||||
|
assert backtesting.fee == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_tickerdata_to_dataframe(default_conf, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test Backtesting.tickerdata_to_dataframe() method
|
||||||
|
"""
|
||||||
|
patch_exchange(mocker)
|
||||||
|
timerange = TimeRange(None, 'line', 0, -100)
|
||||||
|
tick = optimize.load_tickerdata_file(None, 'UNITTEST/BTC', '1m', timerange=timerange)
|
||||||
|
tickerlist = {'UNITTEST/BTC': tick}
|
||||||
|
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
data = backtesting.tickerdata_to_dataframe(tickerlist)
|
||||||
|
assert len(data['UNITTEST/BTC']) == 99
|
||||||
|
|
||||||
|
# Load Analyze to compare the result between Backtesting function and Analyze are the same
|
||||||
|
analyze = Analyze(default_conf)
|
||||||
|
data2 = analyze.tickerdata_to_dataframe(tickerlist)
|
||||||
|
assert data['UNITTEST/BTC'].equals(data2['UNITTEST/BTC'])
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_timeframe(default_conf, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test Backtesting.get_timeframe() method
|
||||||
|
"""
|
||||||
|
patch_exchange(mocker)
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
|
||||||
|
data = backtesting.tickerdata_to_dataframe(
|
||||||
|
optimize.load_data(
|
||||||
|
None,
|
||||||
|
ticker_interval='1m',
|
||||||
|
pairs=['UNITTEST/BTC']
|
||||||
|
)
|
||||||
|
)
|
||||||
|
min_date, max_date = backtesting.get_timeframe(data)
|
||||||
|
assert min_date.isoformat() == '2017-11-04T23:02:00+00:00'
|
||||||
|
assert max_date.isoformat() == '2017-11-14T22:58:00+00:00'
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_text_table(default_conf, mocker):
|
||||||
|
"""
|
||||||
|
Test Backtesting.generate_text_table() method
|
||||||
|
"""
|
||||||
|
patch_exchange(mocker)
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
|
||||||
|
results = pd.DataFrame(
|
||||||
|
{
|
||||||
|
'pair': ['ETH/BTC', 'ETH/BTC'],
|
||||||
|
'profit_percent': [0.1, 0.2],
|
||||||
|
'profit_abs': [0.2, 0.4],
|
||||||
|
'trade_duration': [10, 30],
|
||||||
|
'profit': [2, 0],
|
||||||
|
'loss': [0, 0]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result_str = (
|
||||||
|
'| pair | buy count | avg profit % | '
|
||||||
|
'total profit BTC | avg duration | profit | loss |\n'
|
||||||
|
'|:--------|------------:|---------------:|'
|
||||||
|
'-------------------:|---------------:|---------:|-------:|\n'
|
||||||
|
'| ETH/BTC | 2 | 15.00 | '
|
||||||
|
'0.60000000 | 20.0 | 2 | 0 |\n'
|
||||||
|
'| TOTAL | 2 | 15.00 | '
|
||||||
|
'0.60000000 | 20.0 | 2 | 0 |'
|
||||||
|
)
|
||||||
|
assert backtesting._generate_text_table(data={'ETH/BTC': {}}, results=results) == result_str
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtesting_start(default_conf, mocker, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test Backtesting.start() method
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_timeframe(input1, input2):
|
||||||
|
return Arrow(2017, 11, 14, 21, 17), Arrow(2017, 11, 14, 22, 59)
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.freqtradebot.Analyze', MagicMock())
|
||||||
|
mocker.patch('freqtrade.optimize.load_data', mocked_load_data)
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history')
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.optimize.backtesting.Backtesting',
|
||||||
|
backtest=MagicMock(),
|
||||||
|
_generate_text_table=MagicMock(return_value='1'),
|
||||||
|
get_timeframe=get_timeframe,
|
||||||
|
)
|
||||||
|
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['exchange']['pair_whitelist'] = ['UNITTEST/BTC']
|
||||||
|
conf['ticker_interval'] = 1
|
||||||
|
conf['live'] = False
|
||||||
|
conf['datadir'] = None
|
||||||
|
conf['export'] = None
|
||||||
|
conf['timerange'] = '-100'
|
||||||
|
|
||||||
|
backtesting = Backtesting(conf)
|
||||||
|
backtesting.start()
|
||||||
|
# check the logs, that will contain the backtest result
|
||||||
|
exists = [
|
||||||
|
'Using local backtesting data (using whitelist in given config) ...',
|
||||||
|
'Using stake_currency: BTC ...',
|
||||||
|
'Using stake_amount: 0.001 ...',
|
||||||
|
'Measuring data from 2017-11-14T21:17:00+00:00 '
|
||||||
|
'up to 2017-11-14T22:59:00+00:00 (0 days)..'
|
||||||
|
]
|
||||||
|
for line in exists:
|
||||||
|
assert log_has(line, caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtesting_start_no_data(default_conf, mocker, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test Backtesting.start() method if no data is found
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_timeframe(input1, input2):
|
||||||
|
return Arrow(2017, 11, 14, 21, 17), Arrow(2017, 11, 14, 22, 59)
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.freqtradebot.Analyze', MagicMock())
|
||||||
|
mocker.patch('freqtrade.optimize.load_data', MagicMock(return_value={}))
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history')
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.optimize.backtesting.Backtesting',
|
||||||
|
backtest=MagicMock(),
|
||||||
|
_generate_text_table=MagicMock(return_value='1'),
|
||||||
|
get_timeframe=get_timeframe,
|
||||||
|
)
|
||||||
|
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['exchange']['pair_whitelist'] = ['UNITTEST/BTC']
|
||||||
|
conf['ticker_interval'] = "1m"
|
||||||
|
conf['live'] = False
|
||||||
|
conf['datadir'] = None
|
||||||
|
conf['export'] = None
|
||||||
|
conf['timerange'] = '20180101-20180102'
|
||||||
|
|
||||||
|
backtesting = Backtesting(conf)
|
||||||
|
backtesting.start()
|
||||||
|
# check the logs, that will contain the backtest result
|
||||||
|
|
||||||
|
assert log_has('No data found. Terminating.', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest(default_conf, fee, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test Backtesting.backtest() method
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
|
||||||
|
patch_exchange(mocker)
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
|
||||||
|
data = optimize.load_data(None, ticker_interval='5m', pairs=['UNITTEST/BTC'])
|
||||||
|
data = trim_dictlist(data, -200)
|
||||||
|
results = backtesting.backtest(
|
||||||
|
{
|
||||||
|
'stake_amount': default_conf['stake_amount'],
|
||||||
|
'processed': backtesting.tickerdata_to_dataframe(data),
|
||||||
|
'max_open_trades': 10,
|
||||||
|
'realistic': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert not results.empty
|
||||||
|
assert len(results) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_1min_ticker_interval(default_conf, fee, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test Backtesting.backtest() method with 1 min ticker
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
|
||||||
|
patch_exchange(mocker)
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
|
||||||
|
# Run a backtesting for an exiting 5min ticker_interval
|
||||||
|
data = optimize.load_data(None, ticker_interval='1m', pairs=['UNITTEST/BTC'])
|
||||||
|
data = trim_dictlist(data, -200)
|
||||||
|
results = backtesting.backtest(
|
||||||
|
{
|
||||||
|
'stake_amount': default_conf['stake_amount'],
|
||||||
|
'processed': backtesting.tickerdata_to_dataframe(data),
|
||||||
|
'max_open_trades': 1,
|
||||||
|
'realistic': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert not results.empty
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_processed(default_conf, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test Backtesting.backtest() method with offline data
|
||||||
|
"""
|
||||||
|
patch_exchange(mocker)
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
|
||||||
|
dict_of_tickerrows = load_data_test('raise')
|
||||||
|
dataframes = backtesting.tickerdata_to_dataframe(dict_of_tickerrows)
|
||||||
|
dataframe = dataframes['UNITTEST/BTC']
|
||||||
|
cols = dataframe.columns
|
||||||
|
# assert the dataframe got some of the indicator columns
|
||||||
|
for col in ['close', 'high', 'low', 'open', 'date',
|
||||||
|
'ema50', 'ao', 'macd', 'plus_dm']:
|
||||||
|
assert col in cols
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_pricecontours(default_conf, fee, mocker) -> None:
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
|
||||||
|
tests = [['raise', 18], ['lower', 0], ['sine', 16]]
|
||||||
|
for [contour, numres] in tests:
|
||||||
|
simple_backtest(default_conf, contour, numres, mocker)
|
||||||
|
|
||||||
|
|
||||||
|
# Test backtest using offline data (testdata directory)
|
||||||
|
def test_backtest_ticks(default_conf, fee, mocker):
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
|
||||||
|
patch_exchange(mocker)
|
||||||
|
ticks = [1, 5]
|
||||||
|
fun = Backtesting(default_conf).populate_buy_trend
|
||||||
|
for _ in ticks:
|
||||||
|
backtest_conf = _make_backtest_conf(mocker, conf=default_conf)
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
backtesting.populate_buy_trend = fun # Override
|
||||||
|
backtesting.populate_sell_trend = fun # Override
|
||||||
|
results = backtesting.backtest(backtest_conf)
|
||||||
|
assert not results.empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_clash_buy_sell(mocker, default_conf):
|
||||||
|
# Override the default buy trend function in our default_strategy
|
||||||
|
def fun(dataframe=None):
|
||||||
|
buy_value = 1
|
||||||
|
sell_value = 1
|
||||||
|
return _trend(dataframe, buy_value, sell_value)
|
||||||
|
|
||||||
|
backtest_conf = _make_backtest_conf(mocker, conf=default_conf)
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
backtesting.populate_buy_trend = fun # Override
|
||||||
|
backtesting.populate_sell_trend = fun # Override
|
||||||
|
results = backtesting.backtest(backtest_conf)
|
||||||
|
assert results.empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_only_sell(mocker, default_conf):
|
||||||
|
# Override the default buy trend function in our default_strategy
|
||||||
|
def fun(dataframe=None):
|
||||||
|
buy_value = 0
|
||||||
|
sell_value = 1
|
||||||
|
return _trend(dataframe, buy_value, sell_value)
|
||||||
|
|
||||||
|
backtest_conf = _make_backtest_conf(mocker, conf=default_conf)
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
backtesting.populate_buy_trend = fun # Override
|
||||||
|
backtesting.populate_sell_trend = fun # Override
|
||||||
|
results = backtesting.backtest(backtest_conf)
|
||||||
|
assert results.empty
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_alternate_buy_sell(default_conf, fee, mocker):
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
|
||||||
|
backtest_conf = _make_backtest_conf(mocker, conf=default_conf, pair='UNITTEST/BTC')
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
backtesting.populate_buy_trend = _trend_alternate # Override
|
||||||
|
backtesting.populate_sell_trend = _trend_alternate # Override
|
||||||
|
results = backtesting.backtest(backtest_conf)
|
||||||
|
backtesting._store_backtest_result("test_.json", results)
|
||||||
|
assert len(results) == 4
|
||||||
|
# One trade was force-closed at the end
|
||||||
|
assert len(results.loc[results.open_at_end]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_record(default_conf, fee, mocker):
|
||||||
|
names = []
|
||||||
|
records = []
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.optimize.backtesting.file_dump_json',
|
||||||
|
new=lambda n, r: (names.append(n), records.append(r))
|
||||||
|
)
|
||||||
|
|
||||||
|
backtesting = Backtesting(default_conf)
|
||||||
|
results = pd.DataFrame({"pair": ["UNITTEST/BTC", "UNITTEST/BTC",
|
||||||
|
"UNITTEST/BTC", "UNITTEST/BTC"],
|
||||||
|
"profit_percent": [0.003312, 0.010801, 0.013803, 0.002780],
|
||||||
|
"profit_abs": [0.000003, 0.000011, 0.000014, 0.000003],
|
||||||
|
"open_time": [Arrow(2017, 11, 14, 19, 32, 00).datetime,
|
||||||
|
Arrow(2017, 11, 14, 21, 36, 00).datetime,
|
||||||
|
Arrow(2017, 11, 14, 22, 12, 00).datetime,
|
||||||
|
Arrow(2017, 11, 14, 22, 44, 00).datetime],
|
||||||
|
"close_time": [Arrow(2017, 11, 14, 21, 35, 00).datetime,
|
||||||
|
Arrow(2017, 11, 14, 22, 10, 00).datetime,
|
||||||
|
Arrow(2017, 11, 14, 22, 43, 00).datetime,
|
||||||
|
Arrow(2017, 11, 14, 22, 58, 00).datetime],
|
||||||
|
"open_index": [1, 119, 153, 185],
|
||||||
|
"close_index": [118, 151, 184, 199],
|
||||||
|
"trade_duration": [123, 34, 31, 14]})
|
||||||
|
backtesting._store_backtest_result("backtest-result.json", results)
|
||||||
|
assert len(results) == 4
|
||||||
|
# Assert file_dump_json was only called once
|
||||||
|
assert names == ['backtest-result.json']
|
||||||
|
records = records[0]
|
||||||
|
# Ensure records are of correct type
|
||||||
|
assert len(records) == 4
|
||||||
|
# ('UNITTEST/BTC', 0.00331158, '1510684320', '1510691700', 0, 117)
|
||||||
|
# Below follows just a typecheck of the schema/type of trade-records
|
||||||
|
oix = None
|
||||||
|
for (pair, profit, date_buy, date_sell, buy_index, dur) in records:
|
||||||
|
assert pair == 'UNITTEST/BTC'
|
||||||
|
isinstance(profit, float)
|
||||||
|
# FIX: buy/sell should be converted to ints
|
||||||
|
isinstance(date_buy, str)
|
||||||
|
isinstance(date_sell, str)
|
||||||
|
isinstance(buy_index, pd._libs.tslib.Timestamp)
|
||||||
|
if oix:
|
||||||
|
assert buy_index > oix
|
||||||
|
oix = buy_index
|
||||||
|
assert dur > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_backtest_start_live(default_conf, mocker, caplog):
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['exchange']['pair_whitelist'] = ['UNITTEST/BTC']
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history',
|
||||||
|
new=lambda s, n, i: _load_pair_as_ticks(n, i))
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', MagicMock())
|
||||||
|
mocker.patch('freqtrade.optimize.backtesting.Backtesting._generate_text_table', MagicMock())
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
args = MagicMock()
|
||||||
|
args.ticker_interval = 1
|
||||||
|
args.level = 10
|
||||||
|
args.live = True
|
||||||
|
args.datadir = None
|
||||||
|
args.export = None
|
||||||
|
args.strategy = 'DefaultStrategy'
|
||||||
|
args.timerange = '-100' # needed due to MagicMock malleability
|
||||||
|
|
||||||
|
args = [
|
||||||
|
'--config', 'config.json',
|
||||||
|
'--strategy', 'DefaultStrategy',
|
||||||
|
'--datadir', 'freqtrade/tests/testdata',
|
||||||
|
'backtesting',
|
||||||
|
'--ticker-interval', '1m',
|
||||||
|
'--live',
|
||||||
|
'--timerange', '-100',
|
||||||
|
'--realistic-simulation'
|
||||||
|
]
|
||||||
|
args = get_args(args)
|
||||||
|
start(args)
|
||||||
|
# check the logs, that will contain the backtest result
|
||||||
|
exists = [
|
||||||
|
'Parameter -i/--ticker-interval detected ...',
|
||||||
|
'Using ticker_interval: 1m ...',
|
||||||
|
'Parameter -l/--live detected ...',
|
||||||
|
'Using max_open_trades: 1 ...',
|
||||||
|
'Parameter --timerange detected: -100 ...',
|
||||||
|
'Using data folder: freqtrade/tests/testdata ...',
|
||||||
|
'Using stake_currency: BTC ...',
|
||||||
|
'Using stake_amount: 0.001 ...',
|
||||||
|
'Downloading data for all pairs in whitelist ...',
|
||||||
|
'Measuring data from 2017-11-14T19:31:00+00:00 up to 2017-11-14T22:58:00+00:00 (0 days)..',
|
||||||
|
'Parameter --realistic-simulation detected ...'
|
||||||
|
]
|
||||||
|
|
||||||
|
for line in exists:
|
||||||
|
assert log_has(line, caplog.record_tuples)
|
||||||
537
freqtrade/tests/optimize/test_hyperopt.py
Normal file
537
freqtrade/tests/optimize/test_hyperopt.py
Normal file
@@ -0,0 +1,537 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring,W0212,C0103
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
from copy import deepcopy
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from freqtrade.optimize.__init__ import load_tickerdata_file
|
||||||
|
from freqtrade.optimize.hyperopt import Hyperopt, start
|
||||||
|
from freqtrade.strategy.resolver import StrategyResolver
|
||||||
|
from freqtrade.tests.conftest import log_has, patch_exchange
|
||||||
|
from freqtrade.tests.optimize.test_backtesting import get_args
|
||||||
|
|
||||||
|
# Avoid to reinit the same object again and again
|
||||||
|
_HYPEROPT_INITIALIZED = False
|
||||||
|
_HYPEROPT = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='function')
|
||||||
|
def init_hyperopt(default_conf, mocker):
|
||||||
|
global _HYPEROPT_INITIALIZED, _HYPEROPT
|
||||||
|
if not _HYPEROPT_INITIALIZED:
|
||||||
|
patch_exchange(mocker)
|
||||||
|
_HYPEROPT = Hyperopt(default_conf)
|
||||||
|
_HYPEROPT_INITIALIZED = True
|
||||||
|
|
||||||
|
|
||||||
|
# Functions for recurrent object patching
|
||||||
|
def create_trials(mocker) -> None:
|
||||||
|
"""
|
||||||
|
When creating trials, mock the hyperopt Trials so that *by default*
|
||||||
|
- we don't create any pickle'd files in the filesystem
|
||||||
|
- we might have a pickle'd file so make sure that we return
|
||||||
|
false when looking for it
|
||||||
|
"""
|
||||||
|
_HYPEROPT.trials_file = os.path.join('freqtrade', 'tests', 'optimize', 'ut_trials.pickle')
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.os.path.exists', return_value=False)
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.os.path.getsize', return_value=1)
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.os.remove', return_value=True)
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.pickle.dump', return_value=None)
|
||||||
|
|
||||||
|
return mocker.Mock(
|
||||||
|
results=[
|
||||||
|
{
|
||||||
|
'loss': 1,
|
||||||
|
'result': 'foo',
|
||||||
|
'status': 'ok'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
best_trial={'misc': {'vals': {'adx': 999}}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Unit tests
|
||||||
|
def test_start(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test start() function
|
||||||
|
"""
|
||||||
|
start_mock = MagicMock()
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.configuration.Configuration._load_config_file',
|
||||||
|
lambda *args, **kwargs: default_conf
|
||||||
|
)
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.Hyperopt.start', start_mock)
|
||||||
|
patch_exchange(mocker)
|
||||||
|
|
||||||
|
args = [
|
||||||
|
'--config', 'config.json',
|
||||||
|
'--strategy', 'DefaultStrategy',
|
||||||
|
'hyperopt',
|
||||||
|
'--epochs', '5'
|
||||||
|
]
|
||||||
|
args = get_args(args)
|
||||||
|
StrategyResolver({'strategy': 'DefaultStrategy'})
|
||||||
|
start(args)
|
||||||
|
|
||||||
|
import pprint
|
||||||
|
pprint.pprint(caplog.record_tuples)
|
||||||
|
|
||||||
|
assert log_has(
|
||||||
|
'Starting freqtrade in Hyperopt mode',
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
assert start_mock.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_loss_calculation_prefer_correct_trade_count(init_hyperopt) -> None:
|
||||||
|
"""
|
||||||
|
Test Hyperopt.calculate_loss()
|
||||||
|
"""
|
||||||
|
hyperopt = _HYPEROPT
|
||||||
|
StrategyResolver({'strategy': 'DefaultStrategy'})
|
||||||
|
|
||||||
|
correct = hyperopt.calculate_loss(1, hyperopt.target_trades, 20)
|
||||||
|
over = hyperopt.calculate_loss(1, hyperopt.target_trades + 100, 20)
|
||||||
|
under = hyperopt.calculate_loss(1, hyperopt.target_trades - 100, 20)
|
||||||
|
assert over > correct
|
||||||
|
assert under > correct
|
||||||
|
|
||||||
|
|
||||||
|
def test_loss_calculation_prefer_shorter_trades(init_hyperopt) -> None:
|
||||||
|
"""
|
||||||
|
Test Hyperopt.calculate_loss()
|
||||||
|
"""
|
||||||
|
hyperopt = _HYPEROPT
|
||||||
|
|
||||||
|
shorter = hyperopt.calculate_loss(1, 100, 20)
|
||||||
|
longer = hyperopt.calculate_loss(1, 100, 30)
|
||||||
|
assert shorter < longer
|
||||||
|
|
||||||
|
|
||||||
|
def test_loss_calculation_has_limited_profit(init_hyperopt) -> None:
|
||||||
|
hyperopt = _HYPEROPT
|
||||||
|
|
||||||
|
correct = hyperopt.calculate_loss(hyperopt.expected_max_profit, hyperopt.target_trades, 20)
|
||||||
|
over = hyperopt.calculate_loss(hyperopt.expected_max_profit * 2, hyperopt.target_trades, 20)
|
||||||
|
under = hyperopt.calculate_loss(hyperopt.expected_max_profit / 2, hyperopt.target_trades, 20)
|
||||||
|
assert over == correct
|
||||||
|
assert under > correct
|
||||||
|
|
||||||
|
|
||||||
|
def test_log_results_if_loss_improves(init_hyperopt, capsys) -> None:
|
||||||
|
hyperopt = _HYPEROPT
|
||||||
|
hyperopt.current_best_loss = 2
|
||||||
|
hyperopt.log_results(
|
||||||
|
{
|
||||||
|
'loss': 1,
|
||||||
|
'current_tries': 1,
|
||||||
|
'total_tries': 2,
|
||||||
|
'result': 'foo'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
out, err = capsys.readouterr()
|
||||||
|
assert ' 1/2: foo. Loss 1.00000'in out
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_log_if_loss_does_not_improve(init_hyperopt, caplog) -> None:
|
||||||
|
hyperopt = _HYPEROPT
|
||||||
|
hyperopt.current_best_loss = 2
|
||||||
|
hyperopt.log_results(
|
||||||
|
{
|
||||||
|
'loss': 3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert caplog.record_tuples == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_fmin_best_results(mocker, init_hyperopt, default_conf, caplog) -> None:
|
||||||
|
fmin_result = {
|
||||||
|
"macd_below_zero": 0,
|
||||||
|
"adx": 1,
|
||||||
|
"adx-value": 15.0,
|
||||||
|
"fastd": 1,
|
||||||
|
"fastd-value": 40.0,
|
||||||
|
"green_candle": 1,
|
||||||
|
"mfi": 0,
|
||||||
|
"over_sar": 0,
|
||||||
|
"rsi": 1,
|
||||||
|
"rsi-value": 37.0,
|
||||||
|
"trigger": 2,
|
||||||
|
"uptrend_long_ema": 1,
|
||||||
|
"uptrend_short_ema": 0,
|
||||||
|
"uptrend_sma": 0,
|
||||||
|
"stoploss": -0.1,
|
||||||
|
"roi_t1": 1,
|
||||||
|
"roi_t2": 2,
|
||||||
|
"roi_t3": 3,
|
||||||
|
"roi_p1": 1,
|
||||||
|
"roi_p2": 2,
|
||||||
|
"roi_p3": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf.update({'config': 'config.json.example'})
|
||||||
|
conf.update({'epochs': 1})
|
||||||
|
conf.update({'timerange': None})
|
||||||
|
conf.update({'spaces': 'all'})
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.load_data', MagicMock())
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.fmin', return_value=fmin_result)
|
||||||
|
patch_exchange(mocker)
|
||||||
|
|
||||||
|
StrategyResolver({'strategy': 'DefaultStrategy'})
|
||||||
|
hyperopt = Hyperopt(conf)
|
||||||
|
hyperopt.trials = create_trials(mocker)
|
||||||
|
hyperopt.tickerdata_to_dataframe = MagicMock()
|
||||||
|
hyperopt.start()
|
||||||
|
|
||||||
|
exists = [
|
||||||
|
'Best parameters:',
|
||||||
|
'"adx": {\n "enabled": true,\n "value": 15.0\n },',
|
||||||
|
'"fastd": {\n "enabled": true,\n "value": 40.0\n },',
|
||||||
|
'"green_candle": {\n "enabled": true\n },',
|
||||||
|
'"macd_below_zero": {\n "enabled": false\n },',
|
||||||
|
'"mfi": {\n "enabled": false\n },',
|
||||||
|
'"over_sar": {\n "enabled": false\n },',
|
||||||
|
'"roi_p1": 1.0,',
|
||||||
|
'"roi_p2": 2.0,',
|
||||||
|
'"roi_p3": 3.0,',
|
||||||
|
'"roi_t1": 1.0,',
|
||||||
|
'"roi_t2": 2.0,',
|
||||||
|
'"roi_t3": 3.0,',
|
||||||
|
'"rsi": {\n "enabled": true,\n "value": 37.0\n },',
|
||||||
|
'"stoploss": -0.1,',
|
||||||
|
'"trigger": {\n "type": "faststoch10"\n },',
|
||||||
|
'"uptrend_long_ema": {\n "enabled": true\n },',
|
||||||
|
'"uptrend_short_ema": {\n "enabled": false\n },',
|
||||||
|
'"uptrend_sma": {\n "enabled": false\n }',
|
||||||
|
'ROI table:\n{0: 6.0, 3.0: 3.0, 5.0: 1.0, 6.0: 0}',
|
||||||
|
'Best Result:\nfoo'
|
||||||
|
]
|
||||||
|
for line in exists:
|
||||||
|
assert line in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_fmin_throw_value_error(mocker, init_hyperopt, default_conf, caplog) -> None:
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.load_data', MagicMock())
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.fmin', side_effect=ValueError())
|
||||||
|
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf.update({'config': 'config.json.example'})
|
||||||
|
conf.update({'epochs': 1})
|
||||||
|
conf.update({'timerange': None})
|
||||||
|
conf.update({'spaces': 'all'})
|
||||||
|
patch_exchange(mocker)
|
||||||
|
|
||||||
|
StrategyResolver({'strategy': 'DefaultStrategy'})
|
||||||
|
hyperopt = Hyperopt(conf)
|
||||||
|
hyperopt.trials = create_trials(mocker)
|
||||||
|
hyperopt.tickerdata_to_dataframe = MagicMock()
|
||||||
|
|
||||||
|
hyperopt.start()
|
||||||
|
|
||||||
|
exists = [
|
||||||
|
'Best Result:',
|
||||||
|
'Sorry, Hyperopt was not able to find good parameters. Please try with more epochs '
|
||||||
|
'(param: -e).',
|
||||||
|
]
|
||||||
|
|
||||||
|
for line in exists:
|
||||||
|
assert line in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_resuming_previous_hyperopt_results_succeeds(mocker, init_hyperopt, default_conf) -> None:
|
||||||
|
trials = create_trials(mocker)
|
||||||
|
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf.update({'config': 'config.json.example'})
|
||||||
|
conf.update({'epochs': 1})
|
||||||
|
conf.update({'timerange': None})
|
||||||
|
conf.update({'spaces': 'all'})
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.os.path.exists', return_value=True)
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.len', return_value=len(trials.results))
|
||||||
|
mock_read = mocker.patch(
|
||||||
|
'freqtrade.optimize.hyperopt.Hyperopt.read_trials',
|
||||||
|
return_value=trials
|
||||||
|
)
|
||||||
|
mock_save = mocker.patch(
|
||||||
|
'freqtrade.optimize.hyperopt.Hyperopt.save_trials',
|
||||||
|
return_value=None
|
||||||
|
)
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.sorted', return_value=trials.results)
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.load_data', MagicMock())
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.fmin', return_value={})
|
||||||
|
patch_exchange(mocker)
|
||||||
|
|
||||||
|
StrategyResolver({'strategy': 'DefaultStrategy'})
|
||||||
|
hyperopt = Hyperopt(conf)
|
||||||
|
hyperopt.trials = trials
|
||||||
|
hyperopt.tickerdata_to_dataframe = MagicMock()
|
||||||
|
|
||||||
|
hyperopt.start()
|
||||||
|
|
||||||
|
mock_read.assert_called_once()
|
||||||
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
|
current_tries = hyperopt.current_tries
|
||||||
|
total_tries = hyperopt.total_tries
|
||||||
|
|
||||||
|
assert current_tries == len(trials.results)
|
||||||
|
assert total_tries == (current_tries + len(trials.results))
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_trials_saves_trials(mocker, init_hyperopt, caplog) -> None:
|
||||||
|
create_trials(mocker)
|
||||||
|
mock_dump = mocker.patch('freqtrade.optimize.hyperopt.pickle.dump', return_value=None)
|
||||||
|
|
||||||
|
hyperopt = _HYPEROPT
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.open', return_value=hyperopt.trials_file)
|
||||||
|
|
||||||
|
hyperopt.save_trials()
|
||||||
|
|
||||||
|
trials_file = os.path.join('freqtrade', 'tests', 'optimize', 'ut_trials.pickle')
|
||||||
|
assert log_has(
|
||||||
|
'Saving Trials to \'{}\''.format(trials_file),
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
mock_dump.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_trials_returns_trials_file(mocker, init_hyperopt, caplog) -> None:
|
||||||
|
trials = create_trials(mocker)
|
||||||
|
mock_load = mocker.patch('freqtrade.optimize.hyperopt.pickle.load', return_value=trials)
|
||||||
|
mock_open = mocker.patch('freqtrade.optimize.hyperopt.open', return_value=mock_load)
|
||||||
|
|
||||||
|
hyperopt = _HYPEROPT
|
||||||
|
hyperopt_trial = hyperopt.read_trials()
|
||||||
|
trials_file = os.path.join('freqtrade', 'tests', 'optimize', 'ut_trials.pickle')
|
||||||
|
assert log_has(
|
||||||
|
'Reading Trials from \'{}\''.format(trials_file),
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
assert hyperopt_trial == trials
|
||||||
|
mock_open.assert_called_once()
|
||||||
|
mock_load.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_roi_table_generation(init_hyperopt) -> None:
|
||||||
|
params = {
|
||||||
|
'roi_t1': 5,
|
||||||
|
'roi_t2': 10,
|
||||||
|
'roi_t3': 15,
|
||||||
|
'roi_p1': 1,
|
||||||
|
'roi_p2': 2,
|
||||||
|
'roi_p3': 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
hyperopt = _HYPEROPT
|
||||||
|
assert hyperopt.generate_roi_table(params) == {0: 6, 15: 3, 25: 1, 30: 0}
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_calls_fmin(mocker, init_hyperopt, default_conf) -> None:
|
||||||
|
trials = create_trials(mocker)
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.sorted', return_value=trials.results)
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.load_data', MagicMock())
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mock_fmin = mocker.patch('freqtrade.optimize.hyperopt.fmin', return_value={})
|
||||||
|
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf.update({'config': 'config.json.example'})
|
||||||
|
conf.update({'epochs': 1})
|
||||||
|
conf.update({'timerange': None})
|
||||||
|
conf.update({'spaces': 'all'})
|
||||||
|
|
||||||
|
hyperopt = Hyperopt(conf)
|
||||||
|
hyperopt.trials = trials
|
||||||
|
hyperopt.tickerdata_to_dataframe = MagicMock()
|
||||||
|
|
||||||
|
hyperopt.start()
|
||||||
|
mock_fmin.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_results(init_hyperopt):
|
||||||
|
"""
|
||||||
|
Test Hyperopt.format_results()
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Test with BTC as stake_currency
|
||||||
|
trades = [
|
||||||
|
('ETH/BTC', 2, 2, 123),
|
||||||
|
('LTC/BTC', 1, 1, 123),
|
||||||
|
('XPR/BTC', -1, -2, -246)
|
||||||
|
]
|
||||||
|
labels = ['currency', 'profit_percent', 'profit_abs', 'trade_duration']
|
||||||
|
df = pd.DataFrame.from_records(trades, columns=labels)
|
||||||
|
|
||||||
|
result = _HYPEROPT.format_results(df)
|
||||||
|
assert result.find(' 66.67%')
|
||||||
|
assert result.find('Total profit 1.00000000 BTC')
|
||||||
|
assert result.find('2.0000Σ %')
|
||||||
|
|
||||||
|
# Test with EUR as stake_currency
|
||||||
|
trades = [
|
||||||
|
('ETH/EUR', 2, 2, 123),
|
||||||
|
('LTC/EUR', 1, 1, 123),
|
||||||
|
('XPR/EUR', -1, -2, -246)
|
||||||
|
]
|
||||||
|
df = pd.DataFrame.from_records(trades, columns=labels)
|
||||||
|
result = _HYPEROPT.format_results(df)
|
||||||
|
assert result.find('Total profit 1.00000000 EUR')
|
||||||
|
|
||||||
|
|
||||||
|
def test_signal_handler(mocker, init_hyperopt):
|
||||||
|
"""
|
||||||
|
Test Hyperopt.signal_handler()
|
||||||
|
"""
|
||||||
|
m = MagicMock()
|
||||||
|
mocker.patch('sys.exit', m)
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.Hyperopt.save_trials', m)
|
||||||
|
mocker.patch('freqtrade.optimize.hyperopt.Hyperopt.log_trials_result', m)
|
||||||
|
|
||||||
|
hyperopt = _HYPEROPT
|
||||||
|
hyperopt.signal_handler(signal.SIGTERM, None)
|
||||||
|
assert m.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_has_space(init_hyperopt):
|
||||||
|
"""
|
||||||
|
Test Hyperopt.has_space() method
|
||||||
|
"""
|
||||||
|
_HYPEROPT.config.update({'spaces': ['buy', 'roi']})
|
||||||
|
assert _HYPEROPT.has_space('roi')
|
||||||
|
assert _HYPEROPT.has_space('buy')
|
||||||
|
assert not _HYPEROPT.has_space('stoploss')
|
||||||
|
|
||||||
|
_HYPEROPT.config.update({'spaces': ['all']})
|
||||||
|
assert _HYPEROPT.has_space('buy')
|
||||||
|
|
||||||
|
|
||||||
|
def test_populate_indicators(init_hyperopt) -> None:
|
||||||
|
"""
|
||||||
|
Test Hyperopt.populate_indicators()
|
||||||
|
"""
|
||||||
|
tick = load_tickerdata_file(None, 'UNITTEST/BTC', '1m')
|
||||||
|
tickerlist = {'UNITTEST/BTC': tick}
|
||||||
|
dataframes = _HYPEROPT.tickerdata_to_dataframe(tickerlist)
|
||||||
|
dataframe = _HYPEROPT.populate_indicators(dataframes['UNITTEST/BTC'])
|
||||||
|
|
||||||
|
# Check if some indicators are generated. We will not test all of them
|
||||||
|
assert 'adx' in dataframe
|
||||||
|
assert 'ao' in dataframe
|
||||||
|
assert 'cci' in dataframe
|
||||||
|
|
||||||
|
|
||||||
|
def test_buy_strategy_generator(init_hyperopt) -> None:
|
||||||
|
"""
|
||||||
|
Test Hyperopt.buy_strategy_generator()
|
||||||
|
"""
|
||||||
|
tick = load_tickerdata_file(None, 'UNITTEST/BTC', '1m')
|
||||||
|
tickerlist = {'UNITTEST/BTC': tick}
|
||||||
|
dataframes = _HYPEROPT.tickerdata_to_dataframe(tickerlist)
|
||||||
|
dataframe = _HYPEROPT.populate_indicators(dataframes['UNITTEST/BTC'])
|
||||||
|
|
||||||
|
populate_buy_trend = _HYPEROPT.buy_strategy_generator(
|
||||||
|
{
|
||||||
|
'uptrend_long_ema': {
|
||||||
|
'enabled': True
|
||||||
|
},
|
||||||
|
'macd_below_zero': {
|
||||||
|
'enabled': True
|
||||||
|
},
|
||||||
|
'uptrend_short_ema': {
|
||||||
|
'enabled': True
|
||||||
|
},
|
||||||
|
'mfi': {
|
||||||
|
'enabled': True,
|
||||||
|
'value': 20
|
||||||
|
},
|
||||||
|
'fastd': {
|
||||||
|
'enabled': True,
|
||||||
|
'value': 20
|
||||||
|
},
|
||||||
|
'adx': {
|
||||||
|
'enabled': True,
|
||||||
|
'value': 20
|
||||||
|
},
|
||||||
|
'rsi': {
|
||||||
|
'enabled': True,
|
||||||
|
'value': 20
|
||||||
|
},
|
||||||
|
'over_sar': {
|
||||||
|
'enabled': True,
|
||||||
|
},
|
||||||
|
'green_candle': {
|
||||||
|
'enabled': True,
|
||||||
|
},
|
||||||
|
'uptrend_sma': {
|
||||||
|
'enabled': True,
|
||||||
|
},
|
||||||
|
|
||||||
|
'trigger': {
|
||||||
|
'type': 'lower_bb'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = populate_buy_trend(dataframe)
|
||||||
|
# Check if some indicators are generated. We will not test all of them
|
||||||
|
assert 'buy' in result
|
||||||
|
assert 1 in result['buy']
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_optimizer(mocker, init_hyperopt, default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test Hyperopt.generate_optimizer() function
|
||||||
|
"""
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf.update({'config': 'config.json.example'})
|
||||||
|
conf.update({'timerange': None})
|
||||||
|
conf.update({'spaces': 'all'})
|
||||||
|
|
||||||
|
trades = [
|
||||||
|
('POWR/BTC', 0.023117, 0.000233, 100)
|
||||||
|
]
|
||||||
|
labels = ['currency', 'profit_percent', 'profit_abs', 'trade_duration']
|
||||||
|
backtest_result = pd.DataFrame.from_records(trades, columns=labels)
|
||||||
|
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.optimize.hyperopt.Hyperopt.backtest',
|
||||||
|
MagicMock(return_value=backtest_result)
|
||||||
|
)
|
||||||
|
patch_exchange(mocker)
|
||||||
|
|
||||||
|
optimizer_param = {
|
||||||
|
'adx': {'enabled': False},
|
||||||
|
'fastd': {'enabled': True, 'value': 35.0},
|
||||||
|
'green_candle': {'enabled': True},
|
||||||
|
'macd_below_zero': {'enabled': True},
|
||||||
|
'mfi': {'enabled': False},
|
||||||
|
'over_sar': {'enabled': False},
|
||||||
|
'roi_p1': 0.01,
|
||||||
|
'roi_p2': 0.01,
|
||||||
|
'roi_p3': 0.1,
|
||||||
|
'roi_t1': 60.0,
|
||||||
|
'roi_t2': 30.0,
|
||||||
|
'roi_t3': 20.0,
|
||||||
|
'rsi': {'enabled': False},
|
||||||
|
'stoploss': -0.4,
|
||||||
|
'trigger': {'type': 'macd_cross_signal'},
|
||||||
|
'uptrend_long_ema': {'enabled': False},
|
||||||
|
'uptrend_short_ema': {'enabled': True},
|
||||||
|
'uptrend_sma': {'enabled': True}
|
||||||
|
}
|
||||||
|
|
||||||
|
response_expected = {
|
||||||
|
'loss': 1.9840569076926293,
|
||||||
|
'result': ' 1 trades. Avg profit 2.31%. Total profit 0.00023300 BTC '
|
||||||
|
'(0.0231Σ%). Avg duration 100.0 mins.',
|
||||||
|
'status': 'ok'
|
||||||
|
}
|
||||||
|
|
||||||
|
hyperopt = Hyperopt(conf)
|
||||||
|
generate_optimizer_value = hyperopt.generate_optimizer(optimizer_param)
|
||||||
|
assert generate_optimizer_value == response_expected
|
||||||
446
freqtrade/tests/optimize/test_optimize.py
Normal file
446
freqtrade/tests/optimize/test_optimize.py
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring, protected-access, C0103
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import arrow
|
||||||
|
from shutil import copyfile
|
||||||
|
|
||||||
|
from freqtrade import optimize
|
||||||
|
from freqtrade.misc import file_dump_json
|
||||||
|
from freqtrade.optimize.__init__ import make_testdata_path, download_pairs, \
|
||||||
|
download_backtesting_testdata, load_tickerdata_file, trim_tickerlist, \
|
||||||
|
load_cached_data_for_updating
|
||||||
|
from freqtrade.arguments import TimeRange
|
||||||
|
from freqtrade.tests.conftest import log_has, get_patched_exchange
|
||||||
|
|
||||||
|
# Change this if modifying UNITTEST/BTC testdatafile
|
||||||
|
_BTC_UNITTEST_LENGTH = 13681
|
||||||
|
|
||||||
|
|
||||||
|
def _backup_file(file: str, copy_file: bool = False) -> None:
|
||||||
|
"""
|
||||||
|
Backup existing file to avoid deleting the user file
|
||||||
|
:param file: complete path to the file
|
||||||
|
:param touch_file: create an empty file in replacement
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
file_swp = file + '.swp'
|
||||||
|
if os.path.isfile(file):
|
||||||
|
os.rename(file, file_swp)
|
||||||
|
|
||||||
|
if copy_file:
|
||||||
|
copyfile(file_swp, file)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_test_file(file: str) -> None:
|
||||||
|
"""
|
||||||
|
Backup existing file to avoid deleting the user file
|
||||||
|
:param file: complete path to the file
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
file_swp = file + '.swp'
|
||||||
|
# 1. Delete file from the test
|
||||||
|
if os.path.isfile(file):
|
||||||
|
os.remove(file)
|
||||||
|
|
||||||
|
# 2. Rollback to the initial file
|
||||||
|
if os.path.isfile(file_swp):
|
||||||
|
os.rename(file_swp, file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_data_30min_ticker(ticker_history, mocker, caplog, default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test load_data() with 30 min ticker
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=ticker_history)
|
||||||
|
file = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'UNITTEST_BTC-30m.json')
|
||||||
|
_backup_file(file, copy_file=True)
|
||||||
|
optimize.load_data(None, pairs=['UNITTEST/BTC'], ticker_interval='30m')
|
||||||
|
assert os.path.isfile(file) is True
|
||||||
|
assert not log_has('Download the pair: "UNITTEST/BTC", Interval: 30m', caplog.record_tuples)
|
||||||
|
_clean_test_file(file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_data_5min_ticker(ticker_history, mocker, caplog, default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test load_data() with 5 min ticker
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=ticker_history)
|
||||||
|
|
||||||
|
file = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'UNITTEST_BTC-5m.json')
|
||||||
|
_backup_file(file, copy_file=True)
|
||||||
|
optimize.load_data(None, pairs=['UNITTEST/BTC'], ticker_interval='5m')
|
||||||
|
assert os.path.isfile(file) is True
|
||||||
|
assert not log_has('Download the pair: "UNITTEST/BTC", Interval: 5m', caplog.record_tuples)
|
||||||
|
_clean_test_file(file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_data_1min_ticker(ticker_history, mocker, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test load_data() with 1 min ticker
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=ticker_history)
|
||||||
|
|
||||||
|
file = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'UNITTEST_BTC-1m.json')
|
||||||
|
_backup_file(file, copy_file=True)
|
||||||
|
optimize.load_data(None, ticker_interval='1m', pairs=['UNITTEST/BTC'])
|
||||||
|
assert os.path.isfile(file) is True
|
||||||
|
assert not log_has('Download the pair: "UNITTEST/BTC", Interval: 1m', caplog.record_tuples)
|
||||||
|
_clean_test_file(file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_data_with_new_pair_1min(ticker_history, mocker, caplog, default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test load_data() with 1 min ticker
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=ticker_history)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
file = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'MEME_BTC-1m.json')
|
||||||
|
|
||||||
|
_backup_file(file)
|
||||||
|
# do not download a new pair if refresh_pairs isn't set
|
||||||
|
optimize.load_data(None,
|
||||||
|
ticker_interval='1m',
|
||||||
|
refresh_pairs=False,
|
||||||
|
pairs=['MEME/BTC'])
|
||||||
|
assert os.path.isfile(file) is False
|
||||||
|
assert log_has('No data for pair: "MEME/BTC", Interval: 1m. '
|
||||||
|
'Use --refresh-pairs-cached to download the data',
|
||||||
|
caplog.record_tuples)
|
||||||
|
|
||||||
|
# download a new pair if refresh_pairs is set
|
||||||
|
optimize.load_data(None,
|
||||||
|
ticker_interval='1m',
|
||||||
|
refresh_pairs=True,
|
||||||
|
exchange=exchange,
|
||||||
|
pairs=['MEME/BTC'])
|
||||||
|
assert os.path.isfile(file) is True
|
||||||
|
assert log_has('Download the pair: "MEME/BTC", Interval: 1m', caplog.record_tuples)
|
||||||
|
_clean_test_file(file)
|
||||||
|
|
||||||
|
|
||||||
|
def test_testdata_path() -> None:
|
||||||
|
assert os.path.join('freqtrade', 'tests', 'testdata') in make_testdata_path(None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_pairs(ticker_history, mocker, default_conf) -> None:
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=ticker_history)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
file1_1 = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'MEME_BTC-1m.json')
|
||||||
|
file1_5 = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'MEME_BTC-5m.json')
|
||||||
|
file2_1 = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'CFI_BTC-1m.json')
|
||||||
|
file2_5 = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'CFI_BTC-5m.json')
|
||||||
|
|
||||||
|
_backup_file(file1_1)
|
||||||
|
_backup_file(file1_5)
|
||||||
|
_backup_file(file2_1)
|
||||||
|
_backup_file(file2_5)
|
||||||
|
|
||||||
|
assert os.path.isfile(file1_1) is False
|
||||||
|
assert os.path.isfile(file2_1) is False
|
||||||
|
|
||||||
|
assert download_pairs(None, exchange,
|
||||||
|
pairs=['MEME/BTC', 'CFI/BTC'], ticker_interval='1m') is True
|
||||||
|
|
||||||
|
assert os.path.isfile(file1_1) is True
|
||||||
|
assert os.path.isfile(file2_1) is True
|
||||||
|
|
||||||
|
# clean files freshly downloaded
|
||||||
|
_clean_test_file(file1_1)
|
||||||
|
_clean_test_file(file2_1)
|
||||||
|
|
||||||
|
assert os.path.isfile(file1_5) is False
|
||||||
|
assert os.path.isfile(file2_5) is False
|
||||||
|
|
||||||
|
assert download_pairs(None, exchange,
|
||||||
|
pairs=['MEME/BTC', 'CFI/BTC'], ticker_interval='5m') is True
|
||||||
|
|
||||||
|
assert os.path.isfile(file1_5) is True
|
||||||
|
assert os.path.isfile(file2_5) is True
|
||||||
|
|
||||||
|
# clean files freshly downloaded
|
||||||
|
_clean_test_file(file1_5)
|
||||||
|
_clean_test_file(file2_5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_cached_data_for_updating(mocker) -> None:
|
||||||
|
datadir = os.path.join(os.path.dirname(__file__), '..', 'testdata')
|
||||||
|
|
||||||
|
test_data = None
|
||||||
|
test_filename = os.path.join(datadir, 'UNITTEST_BTC-1m.json')
|
||||||
|
with open(test_filename, "rt") as file:
|
||||||
|
test_data = json.load(file)
|
||||||
|
|
||||||
|
# change now time to test 'line' cases
|
||||||
|
# now = last cached item + 1 hour
|
||||||
|
now_ts = test_data[-1][0] / 1000 + 60 * 60
|
||||||
|
mocker.patch('arrow.utcnow', return_value=arrow.get(now_ts))
|
||||||
|
|
||||||
|
# timeframe starts earlier than the cached data
|
||||||
|
# should fully update data
|
||||||
|
timerange = TimeRange('date', None, test_data[0][0] / 1000 - 1, 0)
|
||||||
|
data, start_ts = load_cached_data_for_updating(test_filename,
|
||||||
|
'1m',
|
||||||
|
timerange)
|
||||||
|
assert data == []
|
||||||
|
assert start_ts == test_data[0][0] - 1000
|
||||||
|
|
||||||
|
# same with 'line' timeframe
|
||||||
|
num_lines = (test_data[-1][0] - test_data[1][0]) / 1000 / 60 + 120
|
||||||
|
data, start_ts = load_cached_data_for_updating(test_filename,
|
||||||
|
'1m',
|
||||||
|
TimeRange(None, 'line', 0, -num_lines))
|
||||||
|
assert data == []
|
||||||
|
assert start_ts < test_data[0][0] - 1
|
||||||
|
|
||||||
|
# timeframe starts in the center of the cached data
|
||||||
|
# should return the chached data w/o the last item
|
||||||
|
timerange = TimeRange('date', None, test_data[0][0] / 1000 + 1, 0)
|
||||||
|
data, start_ts = load_cached_data_for_updating(test_filename,
|
||||||
|
'1m',
|
||||||
|
timerange)
|
||||||
|
assert data == test_data[:-1]
|
||||||
|
assert test_data[-2][0] < start_ts < test_data[-1][0]
|
||||||
|
|
||||||
|
# same with 'line' timeframe
|
||||||
|
num_lines = (test_data[-1][0] - test_data[1][0]) / 1000 / 60 + 30
|
||||||
|
timerange = TimeRange(None, 'line', 0, -num_lines)
|
||||||
|
data, start_ts = load_cached_data_for_updating(test_filename,
|
||||||
|
'1m',
|
||||||
|
timerange)
|
||||||
|
assert data == test_data[:-1]
|
||||||
|
assert test_data[-2][0] < start_ts < test_data[-1][0]
|
||||||
|
|
||||||
|
# timeframe starts after the chached data
|
||||||
|
# should return the chached data w/o the last item
|
||||||
|
timerange = TimeRange('date', None, test_data[-1][0] / 1000 + 1, 0)
|
||||||
|
data, start_ts = load_cached_data_for_updating(test_filename,
|
||||||
|
'1m',
|
||||||
|
timerange)
|
||||||
|
assert data == test_data[:-1]
|
||||||
|
assert test_data[-2][0] < start_ts < test_data[-1][0]
|
||||||
|
|
||||||
|
# same with 'line' timeframe
|
||||||
|
num_lines = 30
|
||||||
|
timerange = TimeRange(None, 'line', 0, -num_lines)
|
||||||
|
data, start_ts = load_cached_data_for_updating(test_filename,
|
||||||
|
'1m',
|
||||||
|
timerange)
|
||||||
|
assert data == test_data[:-1]
|
||||||
|
assert test_data[-2][0] < start_ts < test_data[-1][0]
|
||||||
|
|
||||||
|
# no timeframe is set
|
||||||
|
# should return the chached data w/o the last item
|
||||||
|
num_lines = 30
|
||||||
|
timerange = TimeRange(None, 'line', 0, -num_lines)
|
||||||
|
data, start_ts = load_cached_data_for_updating(test_filename,
|
||||||
|
'1m',
|
||||||
|
timerange)
|
||||||
|
assert data == test_data[:-1]
|
||||||
|
assert test_data[-2][0] < start_ts < test_data[-1][0]
|
||||||
|
|
||||||
|
# no datafile exist
|
||||||
|
# should return timestamp start time
|
||||||
|
timerange = TimeRange('date', None, now_ts - 10000, 0)
|
||||||
|
data, start_ts = load_cached_data_for_updating(test_filename + 'unexist',
|
||||||
|
'1m',
|
||||||
|
timerange)
|
||||||
|
assert data == []
|
||||||
|
assert start_ts == (now_ts - 10000) * 1000
|
||||||
|
|
||||||
|
# same with 'line' timeframe
|
||||||
|
num_lines = 30
|
||||||
|
timerange = TimeRange(None, 'line', 0, -num_lines)
|
||||||
|
data, start_ts = load_cached_data_for_updating(test_filename + 'unexist',
|
||||||
|
'1m',
|
||||||
|
timerange)
|
||||||
|
assert data == []
|
||||||
|
assert start_ts == (now_ts - num_lines * 60) * 1000
|
||||||
|
|
||||||
|
# no datafile exist, no timeframe is set
|
||||||
|
# should return an empty array and None
|
||||||
|
data, start_ts = load_cached_data_for_updating(test_filename + 'unexist',
|
||||||
|
'1m',
|
||||||
|
None)
|
||||||
|
assert data == []
|
||||||
|
assert start_ts is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_pairs_exception(ticker_history, mocker, caplog, default_conf) -> None:
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=ticker_history)
|
||||||
|
mocker.patch('freqtrade.optimize.__init__.download_backtesting_testdata',
|
||||||
|
side_effect=BaseException('File Error'))
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
|
||||||
|
file1_1 = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'MEME_BTC-1m.json')
|
||||||
|
file1_5 = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'MEME_BTC-5m.json')
|
||||||
|
_backup_file(file1_1)
|
||||||
|
_backup_file(file1_5)
|
||||||
|
|
||||||
|
download_pairs(None, exchange, pairs=['MEME/BTC'], ticker_interval='1m')
|
||||||
|
# clean files freshly downloaded
|
||||||
|
_clean_test_file(file1_1)
|
||||||
|
_clean_test_file(file1_5)
|
||||||
|
assert log_has('Failed to download the pair: "MEME/BTC", Interval: 1m', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_backtesting_testdata(ticker_history, mocker, default_conf) -> None:
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=ticker_history)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
|
||||||
|
# Download a 1 min ticker file
|
||||||
|
file1 = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'XEL_BTC-1m.json')
|
||||||
|
_backup_file(file1)
|
||||||
|
download_backtesting_testdata(None, exchange, pair="XEL/BTC", tick_interval='1m')
|
||||||
|
assert os.path.isfile(file1) is True
|
||||||
|
_clean_test_file(file1)
|
||||||
|
|
||||||
|
# Download a 5 min ticker file
|
||||||
|
file2 = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'STORJ_BTC-5m.json')
|
||||||
|
_backup_file(file2)
|
||||||
|
|
||||||
|
download_backtesting_testdata(None, exchange, pair="STORJ/BTC", tick_interval='5m')
|
||||||
|
assert os.path.isfile(file2) is True
|
||||||
|
_clean_test_file(file2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_backtesting_testdata2(mocker, default_conf) -> None:
|
||||||
|
tick = [
|
||||||
|
[1509836520000, 0.00162008, 0.00162008, 0.00162008, 0.00162008, 108.14853839],
|
||||||
|
[1509836580000, 0.00161, 0.00161, 0.00161, 0.00161, 82.390199]
|
||||||
|
]
|
||||||
|
json_dump_mock = mocker.patch('freqtrade.misc.file_dump_json', return_value=None)
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=tick)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
download_backtesting_testdata(None, exchange, pair="UNITTEST/BTC", tick_interval='1m')
|
||||||
|
download_backtesting_testdata(None, exchange, pair="UNITTEST/BTC", tick_interval='3m')
|
||||||
|
assert json_dump_mock.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_tickerdata_file() -> None:
|
||||||
|
# 7 does not exist in either format.
|
||||||
|
assert not load_tickerdata_file(None, 'UNITTEST/BTC', '7m')
|
||||||
|
# 1 exists only as a .json
|
||||||
|
tickerdata = load_tickerdata_file(None, 'UNITTEST/BTC', '1m')
|
||||||
|
assert _BTC_UNITTEST_LENGTH == len(tickerdata)
|
||||||
|
# 8 .json is empty and will fail if it's loaded. .json.gz is a copy of 1.json
|
||||||
|
tickerdata = load_tickerdata_file(None, 'UNITTEST/BTC', '8m')
|
||||||
|
assert _BTC_UNITTEST_LENGTH == len(tickerdata)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init(default_conf, mocker) -> None:
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
assert {} == optimize.load_data(
|
||||||
|
'',
|
||||||
|
exchange=exchange,
|
||||||
|
pairs=[],
|
||||||
|
refresh_pairs=True,
|
||||||
|
ticker_interval=default_conf['ticker_interval']
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trim_tickerlist() -> None:
|
||||||
|
file = os.path.join(os.path.dirname(__file__), '..', 'testdata', 'UNITTEST_BTC-1m.json')
|
||||||
|
with open(file) as data_file:
|
||||||
|
ticker_list = json.load(data_file)
|
||||||
|
ticker_list_len = len(ticker_list)
|
||||||
|
|
||||||
|
# Test the pattern ^(-\d+)$
|
||||||
|
# This pattern uses the latest N elements
|
||||||
|
timerange = TimeRange(None, 'line', 0, -5)
|
||||||
|
ticker = trim_tickerlist(ticker_list, timerange)
|
||||||
|
ticker_len = len(ticker)
|
||||||
|
|
||||||
|
assert ticker_len == 5
|
||||||
|
assert ticker_list[0] is not ticker[0] # The first element should be different
|
||||||
|
assert ticker_list[-1] is ticker[-1] # The last element must be the same
|
||||||
|
|
||||||
|
# Test the pattern ^(\d+)-$
|
||||||
|
# This pattern keep X element from the end
|
||||||
|
timerange = TimeRange('line', None, 5, 0)
|
||||||
|
ticker = trim_tickerlist(ticker_list, timerange)
|
||||||
|
ticker_len = len(ticker)
|
||||||
|
|
||||||
|
assert ticker_len == 5
|
||||||
|
assert ticker_list[0] is ticker[0] # The first element must be the same
|
||||||
|
assert ticker_list[-1] is not ticker[-1] # The last element should be different
|
||||||
|
|
||||||
|
# Test the pattern ^(\d+)-(\d+)$
|
||||||
|
# This pattern extract a window
|
||||||
|
timerange = TimeRange('index', 'index', 5, 10)
|
||||||
|
ticker = trim_tickerlist(ticker_list, timerange)
|
||||||
|
ticker_len = len(ticker)
|
||||||
|
|
||||||
|
assert ticker_len == 5
|
||||||
|
assert ticker_list[0] is not ticker[0] # The first element should be different
|
||||||
|
assert ticker_list[5] is ticker[0] # The list starts at the index 5
|
||||||
|
assert ticker_list[9] is ticker[-1] # The list ends at the index 9 (5 elements)
|
||||||
|
|
||||||
|
# Test the pattern ^(\d{8})-(\d{8})$
|
||||||
|
# This pattern extract a window between the dates
|
||||||
|
timerange = TimeRange('date', 'date', ticker_list[5][0] / 1000, ticker_list[10][0] / 1000 - 1)
|
||||||
|
ticker = trim_tickerlist(ticker_list, timerange)
|
||||||
|
ticker_len = len(ticker)
|
||||||
|
|
||||||
|
assert ticker_len == 5
|
||||||
|
assert ticker_list[0] is not ticker[0] # The first element should be different
|
||||||
|
assert ticker_list[5] is ticker[0] # The list starts at the index 5
|
||||||
|
assert ticker_list[9] is ticker[-1] # The list ends at the index 9 (5 elements)
|
||||||
|
|
||||||
|
# Test the pattern ^-(\d{8})$
|
||||||
|
# This pattern extracts elements from the start to the date
|
||||||
|
timerange = TimeRange(None, 'date', 0, ticker_list[10][0] / 1000 - 1)
|
||||||
|
ticker = trim_tickerlist(ticker_list, timerange)
|
||||||
|
ticker_len = len(ticker)
|
||||||
|
|
||||||
|
assert ticker_len == 10
|
||||||
|
assert ticker_list[0] is ticker[0] # The start of the list is included
|
||||||
|
assert ticker_list[9] is ticker[-1] # The element 10 is not included
|
||||||
|
|
||||||
|
# Test the pattern ^(\d{8})-$
|
||||||
|
# This pattern extracts elements from the date to now
|
||||||
|
timerange = TimeRange('date', None, ticker_list[10][0] / 1000 - 1, None)
|
||||||
|
ticker = trim_tickerlist(ticker_list, timerange)
|
||||||
|
ticker_len = len(ticker)
|
||||||
|
|
||||||
|
assert ticker_len == ticker_list_len - 10
|
||||||
|
assert ticker_list[10] is ticker[0] # The first element is element #10
|
||||||
|
assert ticker_list[-1] is ticker[-1] # The last element is the same
|
||||||
|
|
||||||
|
# Test a wrong pattern
|
||||||
|
# This pattern must return the list unchanged
|
||||||
|
timerange = TimeRange(None, None, None, 5)
|
||||||
|
ticker = trim_tickerlist(ticker_list, timerange)
|
||||||
|
ticker_len = len(ticker)
|
||||||
|
|
||||||
|
assert ticker_list_len == ticker_len
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_dump_json() -> None:
|
||||||
|
"""
|
||||||
|
Test file_dump_json()
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
file = os.path.join(os.path.dirname(__file__), '..', 'testdata',
|
||||||
|
'test_{id}.json'.format(id=str(uuid.uuid4())))
|
||||||
|
data = {'bar': 'foo'}
|
||||||
|
|
||||||
|
# check the file we will create does not exist
|
||||||
|
assert os.path.isfile(file) is False
|
||||||
|
|
||||||
|
# Create the Json file
|
||||||
|
file_dump_json(file, data)
|
||||||
|
|
||||||
|
# Check the file was create
|
||||||
|
assert os.path.isfile(file) is True
|
||||||
|
|
||||||
|
# Open the Json file created and test the data is in it
|
||||||
|
with open(file) as data_file:
|
||||||
|
json_from_file = json.load(data_file)
|
||||||
|
|
||||||
|
assert 'bar' in json_from_file
|
||||||
|
assert json_from_file['bar'] == 'foo'
|
||||||
|
|
||||||
|
# Remove the file
|
||||||
|
_clean_test_file(file)
|
||||||
564
freqtrade/tests/rpc/test_rpc.py
Normal file
564
freqtrade/tests/rpc/test_rpc.py
Normal file
@@ -0,0 +1,564 @@
|
|||||||
|
# pragma pylint: disable=invalid-sequence-index, invalid-name, too-many-arguments
|
||||||
|
|
||||||
|
"""
|
||||||
|
Unit test file for rpc/rpc.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from freqtrade.freqtradebot import FreqtradeBot
|
||||||
|
from freqtrade.persistence import Trade
|
||||||
|
from freqtrade.rpc.rpc import RPC, RPCException
|
||||||
|
from freqtrade.state import State
|
||||||
|
from freqtrade.tests.test_freqtradebot import patch_get_signal, patch_coinmarketcap
|
||||||
|
|
||||||
|
|
||||||
|
# Functions for recurrent object patching
|
||||||
|
def prec_satoshi(a, b) -> float:
|
||||||
|
"""
|
||||||
|
:return: True if A and B differs less than one satoshi.
|
||||||
|
"""
|
||||||
|
return abs(a - b) < 0.00000001
|
||||||
|
|
||||||
|
|
||||||
|
# Unit tests
|
||||||
|
def test_rpc_trade_status(default_conf, ticker, fee, markets, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test rpc_trade_status() method
|
||||||
|
"""
|
||||||
|
patch_get_signal(mocker, (True, False))
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
get_fee=fee,
|
||||||
|
get_markets=markets
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtradebot = FreqtradeBot(default_conf)
|
||||||
|
rpc = RPC(freqtradebot)
|
||||||
|
|
||||||
|
freqtradebot.state = State.STOPPED
|
||||||
|
with pytest.raises(RPCException, match=r'.*trader is not running*'):
|
||||||
|
rpc._rpc_trade_status()
|
||||||
|
|
||||||
|
freqtradebot.state = State.RUNNING
|
||||||
|
with pytest.raises(RPCException, match=r'.*no active trade*'):
|
||||||
|
rpc._rpc_trade_status()
|
||||||
|
|
||||||
|
freqtradebot.create_trade()
|
||||||
|
trades = rpc._rpc_trade_status()
|
||||||
|
trade = trades[0]
|
||||||
|
|
||||||
|
result_message = [
|
||||||
|
'*Trade ID:* `1`\n'
|
||||||
|
'*Current Pair:* '
|
||||||
|
'[ETH/BTC](https://bittrex.com/Market/Index?MarketName=BTC-ETH)\n'
|
||||||
|
'*Open Since:* `just now`\n'
|
||||||
|
'*Amount:* `90.99181074`\n'
|
||||||
|
'*Open Rate:* `0.00001099`\n'
|
||||||
|
'*Close Rate:* `None`\n'
|
||||||
|
'*Current Rate:* `0.00001098`\n'
|
||||||
|
'*Close Profit:* `None`\n'
|
||||||
|
'*Current Profit:* `-0.59%`\n'
|
||||||
|
'*Open Order:* `(limit buy rem=0.00000000)`'
|
||||||
|
]
|
||||||
|
assert trades == result_message
|
||||||
|
assert trade.find('[ETH/BTC]') >= 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_status_table(default_conf, ticker, fee, markets, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test rpc_status_table() method
|
||||||
|
"""
|
||||||
|
patch_get_signal(mocker, (True, False))
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
get_fee=fee,
|
||||||
|
get_markets=markets
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtradebot = FreqtradeBot(default_conf)
|
||||||
|
rpc = RPC(freqtradebot)
|
||||||
|
|
||||||
|
freqtradebot.state = State.STOPPED
|
||||||
|
with pytest.raises(RPCException, match=r'.*\*Status:\* `trader is not running``*'):
|
||||||
|
rpc._rpc_status_table()
|
||||||
|
|
||||||
|
freqtradebot.state = State.RUNNING
|
||||||
|
with pytest.raises(RPCException, match=r'.*\*Status:\* `no active order`*'):
|
||||||
|
rpc._rpc_status_table()
|
||||||
|
|
||||||
|
freqtradebot.create_trade()
|
||||||
|
result = rpc._rpc_status_table()
|
||||||
|
assert 'just now' in result['Since'].all()
|
||||||
|
assert 'ETH/BTC' in result['Pair'].all()
|
||||||
|
assert '-0.59%' in result['Profit'].all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_daily_profit(default_conf, update, ticker, fee,
|
||||||
|
limit_buy_order, limit_sell_order, markets, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test rpc_daily_profit() method
|
||||||
|
"""
|
||||||
|
patch_get_signal(mocker, (True, False))
|
||||||
|
patch_coinmarketcap(mocker, value={'price_usd': 15000.0})
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
get_fee=fee,
|
||||||
|
get_markets=markets
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtradebot = FreqtradeBot(default_conf)
|
||||||
|
stake_currency = default_conf['stake_currency']
|
||||||
|
fiat_display_currency = default_conf['fiat_display_currency']
|
||||||
|
|
||||||
|
rpc = RPC(freqtradebot)
|
||||||
|
|
||||||
|
# Create some test data
|
||||||
|
freqtradebot.create_trade()
|
||||||
|
trade = Trade.query.first()
|
||||||
|
assert trade
|
||||||
|
|
||||||
|
# Simulate buy & sell
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
trade.close_date = datetime.utcnow()
|
||||||
|
trade.is_open = False
|
||||||
|
|
||||||
|
# Try valid data
|
||||||
|
update.message.text = '/daily 2'
|
||||||
|
days = rpc._rpc_daily_profit(7, stake_currency, fiat_display_currency)
|
||||||
|
assert len(days) == 7
|
||||||
|
for day in days:
|
||||||
|
# [datetime.date(2018, 1, 11), '0.00000000 BTC', '0.000 USD']
|
||||||
|
assert (day[1] == '0.00000000 BTC' or
|
||||||
|
day[1] == '0.00006217 BTC')
|
||||||
|
|
||||||
|
assert (day[2] == '0.000 USD' or
|
||||||
|
day[2] == '0.933 USD')
|
||||||
|
# ensure first day is current date
|
||||||
|
assert str(days[0][0]) == str(datetime.utcnow().date())
|
||||||
|
|
||||||
|
# Try invalid data
|
||||||
|
with pytest.raises(RPCException, match=r'.*must be an integer greater than 0*'):
|
||||||
|
rpc._rpc_daily_profit(0, stake_currency, fiat_display_currency)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_trade_statistics(default_conf, ticker, ticker_sell_up, fee,
|
||||||
|
limit_buy_order, limit_sell_order, markets, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test rpc_trade_statistics() method
|
||||||
|
"""
|
||||||
|
patch_get_signal(mocker, (True, False))
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.fiat_convert.Market',
|
||||||
|
ticker=MagicMock(return_value={'price_usd': 15000.0}),
|
||||||
|
)
|
||||||
|
mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
get_fee=fee,
|
||||||
|
get_markets=markets
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtradebot = FreqtradeBot(default_conf)
|
||||||
|
stake_currency = default_conf['stake_currency']
|
||||||
|
fiat_display_currency = default_conf['fiat_display_currency']
|
||||||
|
|
||||||
|
rpc = RPC(freqtradebot)
|
||||||
|
|
||||||
|
with pytest.raises(RPCException, match=r'.*no closed trade*'):
|
||||||
|
rpc._rpc_trade_statistics(stake_currency, fiat_display_currency)
|
||||||
|
|
||||||
|
# Create some test data
|
||||||
|
freqtradebot.create_trade()
|
||||||
|
trade = Trade.query.first()
|
||||||
|
# Simulate fulfilled LIMIT_BUY order for trade
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
|
||||||
|
# Update the ticker with a market going up
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker_sell_up
|
||||||
|
)
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
trade.close_date = datetime.utcnow()
|
||||||
|
trade.is_open = False
|
||||||
|
|
||||||
|
freqtradebot.create_trade()
|
||||||
|
trade = Trade.query.first()
|
||||||
|
# Simulate fulfilled LIMIT_BUY order for trade
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
|
||||||
|
# Update the ticker with a market going up
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker_sell_up
|
||||||
|
)
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
trade.close_date = datetime.utcnow()
|
||||||
|
trade.is_open = False
|
||||||
|
|
||||||
|
stats = rpc._rpc_trade_statistics(stake_currency, fiat_display_currency)
|
||||||
|
assert prec_satoshi(stats['profit_closed_coin'], 6.217e-05)
|
||||||
|
assert prec_satoshi(stats['profit_closed_percent'], 6.2)
|
||||||
|
assert prec_satoshi(stats['profit_closed_fiat'], 0.93255)
|
||||||
|
assert prec_satoshi(stats['profit_all_coin'], 5.632e-05)
|
||||||
|
assert prec_satoshi(stats['profit_all_percent'], 2.81)
|
||||||
|
assert prec_satoshi(stats['profit_all_fiat'], 0.8448)
|
||||||
|
assert stats['trade_count'] == 2
|
||||||
|
assert stats['first_trade_date'] == 'just now'
|
||||||
|
assert stats['latest_trade_date'] == 'just now'
|
||||||
|
assert stats['avg_duration'] == '0:00:00'
|
||||||
|
assert stats['best_pair'] == 'ETH/BTC'
|
||||||
|
assert prec_satoshi(stats['best_rate'], 6.2)
|
||||||
|
|
||||||
|
|
||||||
|
# Test that rpc_trade_statistics can handle trades that lacks
|
||||||
|
# trade.open_rate (it is set to None)
|
||||||
|
def test_rpc_trade_statistics_closed(mocker, default_conf, ticker, fee, markets,
|
||||||
|
ticker_sell_up, limit_buy_order, limit_sell_order):
|
||||||
|
"""
|
||||||
|
Test rpc_trade_statistics() method
|
||||||
|
"""
|
||||||
|
patch_get_signal(mocker, (True, False))
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.fiat_convert.Market',
|
||||||
|
ticker=MagicMock(return_value={'price_usd': 15000.0}),
|
||||||
|
)
|
||||||
|
mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
get_fee=fee,
|
||||||
|
get_markets=markets
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtradebot = FreqtradeBot(default_conf)
|
||||||
|
stake_currency = default_conf['stake_currency']
|
||||||
|
fiat_display_currency = default_conf['fiat_display_currency']
|
||||||
|
|
||||||
|
rpc = RPC(freqtradebot)
|
||||||
|
|
||||||
|
# Create some test data
|
||||||
|
freqtradebot.create_trade()
|
||||||
|
trade = Trade.query.first()
|
||||||
|
# Simulate fulfilled LIMIT_BUY order for trade
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
# Update the ticker with a market going up
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker_sell_up,
|
||||||
|
get_fee=fee
|
||||||
|
)
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
trade.close_date = datetime.utcnow()
|
||||||
|
trade.is_open = False
|
||||||
|
|
||||||
|
for trade in Trade.query.order_by(Trade.id).all():
|
||||||
|
trade.open_rate = None
|
||||||
|
|
||||||
|
stats = rpc._rpc_trade_statistics(stake_currency, fiat_display_currency)
|
||||||
|
assert prec_satoshi(stats['profit_closed_coin'], 0)
|
||||||
|
assert prec_satoshi(stats['profit_closed_percent'], 0)
|
||||||
|
assert prec_satoshi(stats['profit_closed_fiat'], 0)
|
||||||
|
assert prec_satoshi(stats['profit_all_coin'], 0)
|
||||||
|
assert prec_satoshi(stats['profit_all_percent'], 0)
|
||||||
|
assert prec_satoshi(stats['profit_all_fiat'], 0)
|
||||||
|
assert stats['trade_count'] == 1
|
||||||
|
assert stats['first_trade_date'] == 'just now'
|
||||||
|
assert stats['latest_trade_date'] == 'just now'
|
||||||
|
assert stats['avg_duration'] == '0:00:00'
|
||||||
|
assert stats['best_pair'] == 'ETH/BTC'
|
||||||
|
assert prec_satoshi(stats['best_rate'], 6.2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_balance_handle(default_conf, mocker):
|
||||||
|
"""
|
||||||
|
Test rpc_balance() method
|
||||||
|
"""
|
||||||
|
mock_balance = {
|
||||||
|
'BTC': {
|
||||||
|
'free': 10.0,
|
||||||
|
'total': 12.0,
|
||||||
|
'used': 2.0,
|
||||||
|
},
|
||||||
|
'ETH': {
|
||||||
|
'free': 0.0,
|
||||||
|
'total': 0.0,
|
||||||
|
'used': 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
patch_get_signal(mocker, (True, False))
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.fiat_convert.Market',
|
||||||
|
ticker=MagicMock(return_value={'price_usd': 15000.0}),
|
||||||
|
)
|
||||||
|
mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_balances=MagicMock(return_value=mock_balance)
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtradebot = FreqtradeBot(default_conf)
|
||||||
|
rpc = RPC(freqtradebot)
|
||||||
|
|
||||||
|
output, total, symbol, value = rpc._rpc_balance(default_conf['fiat_display_currency'])
|
||||||
|
assert prec_satoshi(total, 12)
|
||||||
|
assert prec_satoshi(value, 180000)
|
||||||
|
assert 'USD' in symbol
|
||||||
|
assert len(output) == 1
|
||||||
|
assert 'BTC' in output[0]['currency']
|
||||||
|
assert prec_satoshi(output[0]['available'], 10)
|
||||||
|
assert prec_satoshi(output[0]['balance'], 12)
|
||||||
|
assert prec_satoshi(output[0]['pending'], 2)
|
||||||
|
assert prec_satoshi(output[0]['est_btc'], 12)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_start(mocker, default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test rpc_start() method
|
||||||
|
"""
|
||||||
|
patch_get_signal(mocker, (True, False))
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=MagicMock()
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtradebot = FreqtradeBot(default_conf)
|
||||||
|
rpc = RPC(freqtradebot)
|
||||||
|
freqtradebot.state = State.STOPPED
|
||||||
|
|
||||||
|
result = rpc._rpc_start()
|
||||||
|
assert '`Starting trader ...`' in result
|
||||||
|
assert freqtradebot.state == State.RUNNING
|
||||||
|
|
||||||
|
result = rpc._rpc_start()
|
||||||
|
assert '*Status:* `already running`' in result
|
||||||
|
assert freqtradebot.state == State.RUNNING
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_stop(mocker, default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test rpc_stop() method
|
||||||
|
"""
|
||||||
|
patch_get_signal(mocker, (True, False))
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=MagicMock()
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtradebot = FreqtradeBot(default_conf)
|
||||||
|
rpc = RPC(freqtradebot)
|
||||||
|
freqtradebot.state = State.RUNNING
|
||||||
|
|
||||||
|
result = rpc._rpc_stop()
|
||||||
|
assert '`Stopping trader ...`' in result
|
||||||
|
assert freqtradebot.state == State.STOPPED
|
||||||
|
|
||||||
|
result = rpc._rpc_stop()
|
||||||
|
assert '*Status:* `already stopped`' in result
|
||||||
|
assert freqtradebot.state == State.STOPPED
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_forcesell(default_conf, ticker, fee, mocker, markets) -> None:
|
||||||
|
"""
|
||||||
|
Test rpc_forcesell() method
|
||||||
|
"""
|
||||||
|
patch_get_signal(mocker, (True, False))
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
|
||||||
|
|
||||||
|
cancel_order_mock = MagicMock()
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_ticker=ticker,
|
||||||
|
cancel_order=cancel_order_mock,
|
||||||
|
get_order=MagicMock(
|
||||||
|
return_value={
|
||||||
|
'status': 'closed',
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'buy'
|
||||||
|
}
|
||||||
|
),
|
||||||
|
get_fee=fee,
|
||||||
|
get_markets=markets
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtradebot = FreqtradeBot(default_conf)
|
||||||
|
rpc = RPC(freqtradebot)
|
||||||
|
|
||||||
|
freqtradebot.state = State.STOPPED
|
||||||
|
with pytest.raises(RPCException, match=r'.*`trader is not running`*'):
|
||||||
|
rpc._rpc_forcesell(None)
|
||||||
|
|
||||||
|
freqtradebot.state = State.RUNNING
|
||||||
|
with pytest.raises(RPCException, match=r'.*Invalid argument.*'):
|
||||||
|
rpc._rpc_forcesell(None)
|
||||||
|
|
||||||
|
rpc._rpc_forcesell('all')
|
||||||
|
|
||||||
|
freqtradebot.create_trade()
|
||||||
|
rpc._rpc_forcesell('all')
|
||||||
|
|
||||||
|
rpc._rpc_forcesell('1')
|
||||||
|
|
||||||
|
freqtradebot.state = State.STOPPED
|
||||||
|
with pytest.raises(RPCException, match=r'.*`trader is not running`*'):
|
||||||
|
rpc._rpc_forcesell(None)
|
||||||
|
|
||||||
|
with pytest.raises(RPCException, match=r'.*`trader is not running`*'):
|
||||||
|
rpc._rpc_forcesell('all')
|
||||||
|
|
||||||
|
freqtradebot.state = State.RUNNING
|
||||||
|
assert cancel_order_mock.call_count == 0
|
||||||
|
# make an limit-buy open trade
|
||||||
|
trade = Trade.query.filter(Trade.id == '1').first()
|
||||||
|
filled_amount = trade.amount / 2
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.exchange.Exchange.get_order',
|
||||||
|
return_value={
|
||||||
|
'status': 'open',
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'buy',
|
||||||
|
'filled': filled_amount
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# check that the trade is called, which is done by ensuring exchange.cancel_order is called
|
||||||
|
# and trade amount is updated
|
||||||
|
rpc._rpc_forcesell('1')
|
||||||
|
assert cancel_order_mock.call_count == 1
|
||||||
|
assert trade.amount == filled_amount
|
||||||
|
|
||||||
|
freqtradebot.create_trade()
|
||||||
|
trade = Trade.query.filter(Trade.id == '2').first()
|
||||||
|
amount = trade.amount
|
||||||
|
# make an limit-buy open trade, if there is no 'filled', don't sell it
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.exchange.Exchange.get_order',
|
||||||
|
return_value={
|
||||||
|
'status': 'open',
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'buy',
|
||||||
|
'filled': None
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# check that the trade is called, which is done by ensuring exchange.cancel_order is called
|
||||||
|
rpc._rpc_forcesell('2')
|
||||||
|
assert cancel_order_mock.call_count == 2
|
||||||
|
assert trade.amount == amount
|
||||||
|
|
||||||
|
freqtradebot.create_trade()
|
||||||
|
# make an limit-sell open trade
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.exchange.Exchange.get_order',
|
||||||
|
return_value={
|
||||||
|
'status': 'open',
|
||||||
|
'type': 'limit',
|
||||||
|
'side': 'sell'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rpc._rpc_forcesell('3')
|
||||||
|
# status quo, no exchange calls
|
||||||
|
assert cancel_order_mock.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_performance_handle(default_conf, ticker, limit_buy_order, fee,
|
||||||
|
limit_sell_order, markets, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test rpc_performance() method
|
||||||
|
"""
|
||||||
|
patch_get_signal(mocker, (True, False))
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_balances=MagicMock(return_value=ticker),
|
||||||
|
get_ticker=ticker,
|
||||||
|
get_fee=fee,
|
||||||
|
get_markets=markets
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtradebot = FreqtradeBot(default_conf)
|
||||||
|
rpc = RPC(freqtradebot)
|
||||||
|
|
||||||
|
# Create some test data
|
||||||
|
freqtradebot.create_trade()
|
||||||
|
trade = Trade.query.first()
|
||||||
|
assert trade
|
||||||
|
|
||||||
|
# Simulate fulfilled LIMIT_BUY order for trade
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
|
||||||
|
# Simulate fulfilled LIMIT_SELL order for trade
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
|
||||||
|
trade.close_date = datetime.utcnow()
|
||||||
|
trade.is_open = False
|
||||||
|
res = rpc._rpc_performance()
|
||||||
|
assert len(res) == 1
|
||||||
|
assert res[0]['pair'] == 'ETH/BTC'
|
||||||
|
assert res[0]['count'] == 1
|
||||||
|
assert prec_satoshi(res[0]['profit'], 6.2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_count(mocker, default_conf, ticker, fee, markets) -> None:
|
||||||
|
"""
|
||||||
|
Test rpc_count() method
|
||||||
|
"""
|
||||||
|
patch_get_signal(mocker, (True, False))
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock())
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
validate_pairs=MagicMock(),
|
||||||
|
get_balances=MagicMock(return_value=ticker),
|
||||||
|
get_ticker=ticker,
|
||||||
|
get_fee=fee,
|
||||||
|
get_markets=markets
|
||||||
|
)
|
||||||
|
|
||||||
|
freqtradebot = FreqtradeBot(default_conf)
|
||||||
|
rpc = RPC(freqtradebot)
|
||||||
|
|
||||||
|
trades = rpc._rpc_count()
|
||||||
|
nb_trades = len(trades)
|
||||||
|
assert nb_trades == 0
|
||||||
|
|
||||||
|
# Create some test data
|
||||||
|
freqtradebot.create_trade()
|
||||||
|
trades = rpc._rpc_count()
|
||||||
|
nb_trades = len(trades)
|
||||||
|
assert nb_trades == 1
|
||||||
123
freqtrade/tests/rpc/test_rpc_manager.py
Normal file
123
freqtrade/tests/rpc/test_rpc_manager.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""
|
||||||
|
Unit test file for rpc/rpc_manager.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from freqtrade.rpc.rpc_manager import RPCManager
|
||||||
|
from freqtrade.tests.conftest import log_has, get_patched_freqtradebot
|
||||||
|
|
||||||
|
|
||||||
|
def test_rpc_manager_object() -> None:
|
||||||
|
""" Test the Arguments object has the mandatory methods """
|
||||||
|
assert hasattr(RPCManager, 'send_msg')
|
||||||
|
assert hasattr(RPCManager, 'cleanup')
|
||||||
|
|
||||||
|
|
||||||
|
def test__init__(mocker, default_conf) -> None:
|
||||||
|
""" Test __init__() method """
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['telegram']['enabled'] = False
|
||||||
|
|
||||||
|
rpc_manager = RPCManager(get_patched_freqtradebot(mocker, conf))
|
||||||
|
assert rpc_manager.registered_modules == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_telegram_disabled(mocker, default_conf, caplog) -> None:
|
||||||
|
""" Test _init() method with Telegram disabled """
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['telegram']['enabled'] = False
|
||||||
|
|
||||||
|
rpc_manager = RPCManager(get_patched_freqtradebot(mocker, conf))
|
||||||
|
|
||||||
|
assert not log_has('Enabling rpc.telegram ...', caplog.record_tuples)
|
||||||
|
assert rpc_manager.registered_modules == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_telegram_enabled(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test _init() method with Telegram enabled
|
||||||
|
"""
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock())
|
||||||
|
|
||||||
|
rpc_manager = RPCManager(get_patched_freqtradebot(mocker, default_conf))
|
||||||
|
|
||||||
|
assert log_has('Enabling rpc.telegram ...', caplog.record_tuples)
|
||||||
|
len_modules = len(rpc_manager.registered_modules)
|
||||||
|
assert len_modules == 1
|
||||||
|
assert 'telegram' in [mod.name for mod in rpc_manager.registered_modules]
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_telegram_disabled(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test cleanup() method with Telegram disabled
|
||||||
|
"""
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
telegram_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.cleanup', MagicMock())
|
||||||
|
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['telegram']['enabled'] = False
|
||||||
|
|
||||||
|
freqtradebot = get_patched_freqtradebot(mocker, conf)
|
||||||
|
rpc_manager = RPCManager(freqtradebot)
|
||||||
|
rpc_manager.cleanup()
|
||||||
|
|
||||||
|
assert not log_has('Cleaning up rpc.telegram ...', caplog.record_tuples)
|
||||||
|
assert telegram_mock.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_telegram_enabled(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test cleanup() method with Telegram enabled
|
||||||
|
"""
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock())
|
||||||
|
telegram_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.cleanup', MagicMock())
|
||||||
|
|
||||||
|
freqtradebot = get_patched_freqtradebot(mocker, default_conf)
|
||||||
|
rpc_manager = RPCManager(freqtradebot)
|
||||||
|
|
||||||
|
# Check we have Telegram as a registered modules
|
||||||
|
assert 'telegram' in [mod.name for mod in rpc_manager.registered_modules]
|
||||||
|
|
||||||
|
rpc_manager.cleanup()
|
||||||
|
assert log_has('Cleaning up rpc.telegram ...', caplog.record_tuples)
|
||||||
|
assert 'telegram' not in [mod.name for mod in rpc_manager.registered_modules]
|
||||||
|
assert telegram_mock.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_msg_telegram_disabled(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test send_msg() method with Telegram disabled
|
||||||
|
"""
|
||||||
|
telegram_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg', MagicMock())
|
||||||
|
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['telegram']['enabled'] = False
|
||||||
|
|
||||||
|
freqtradebot = get_patched_freqtradebot(mocker, conf)
|
||||||
|
rpc_manager = RPCManager(freqtradebot)
|
||||||
|
rpc_manager.send_msg('test')
|
||||||
|
|
||||||
|
assert log_has('Sending rpc message: test', caplog.record_tuples)
|
||||||
|
assert telegram_mock.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_msg_telegram_enabled(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test send_msg() method with Telegram disabled
|
||||||
|
"""
|
||||||
|
telegram_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg', MagicMock())
|
||||||
|
mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock())
|
||||||
|
|
||||||
|
freqtradebot = get_patched_freqtradebot(mocker, default_conf)
|
||||||
|
rpc_manager = RPCManager(freqtradebot)
|
||||||
|
rpc_manager.send_msg('test')
|
||||||
|
|
||||||
|
assert log_has('Sending rpc message: test', caplog.record_tuples)
|
||||||
|
assert telegram_mock.call_count == 1
|
||||||
1064
freqtrade/tests/rpc/test_rpc_telegram.py
Normal file
1064
freqtrade/tests/rpc/test_rpc_telegram.py
Normal file
File diff suppressed because it is too large
Load Diff
34
freqtrade/tests/strategy/test_default_strategy.py
Normal file
34
freqtrade/tests/strategy/test_default_strategy.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pandas import DataFrame
|
||||||
|
|
||||||
|
from freqtrade.analyze import Analyze
|
||||||
|
from freqtrade.strategy.default_strategy import DefaultStrategy
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def result():
|
||||||
|
with open('freqtrade/tests/testdata/ETH_BTC-1m.json') as data_file:
|
||||||
|
return Analyze.parse_ticker_dataframe(json.load(data_file))
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_strategy_structure():
|
||||||
|
assert hasattr(DefaultStrategy, 'minimal_roi')
|
||||||
|
assert hasattr(DefaultStrategy, 'stoploss')
|
||||||
|
assert hasattr(DefaultStrategy, 'ticker_interval')
|
||||||
|
assert hasattr(DefaultStrategy, 'populate_indicators')
|
||||||
|
assert hasattr(DefaultStrategy, 'populate_buy_trend')
|
||||||
|
assert hasattr(DefaultStrategy, 'populate_sell_trend')
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_strategy(result):
|
||||||
|
strategy = DefaultStrategy()
|
||||||
|
|
||||||
|
assert type(strategy.minimal_roi) is dict
|
||||||
|
assert type(strategy.stoploss) is float
|
||||||
|
assert type(strategy.ticker_interval) is str
|
||||||
|
indicators = strategy.populate_indicators(result)
|
||||||
|
assert type(indicators) is DataFrame
|
||||||
|
assert type(strategy.populate_buy_trend(indicators)) is DataFrame
|
||||||
|
assert type(strategy.populate_sell_trend(indicators)) is DataFrame
|
||||||
145
freqtrade/tests/strategy/test_strategy.py
Normal file
145
freqtrade/tests/strategy/test_strategy.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring, protected-access, C0103
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from freqtrade.strategy import import_strategy
|
||||||
|
from freqtrade.strategy.default_strategy import DefaultStrategy
|
||||||
|
from freqtrade.strategy.interface import IStrategy
|
||||||
|
from freqtrade.strategy.resolver import StrategyResolver
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_strategy(caplog):
|
||||||
|
caplog.set_level(logging.DEBUG)
|
||||||
|
|
||||||
|
strategy = DefaultStrategy()
|
||||||
|
strategy.some_method = lambda *args, **kwargs: 42
|
||||||
|
|
||||||
|
assert strategy.__module__ == 'freqtrade.strategy.default_strategy'
|
||||||
|
assert strategy.some_method() == 42
|
||||||
|
|
||||||
|
imported_strategy = import_strategy(strategy)
|
||||||
|
|
||||||
|
assert dir(strategy) == dir(imported_strategy)
|
||||||
|
|
||||||
|
assert imported_strategy.__module__ == 'freqtrade.strategy'
|
||||||
|
assert imported_strategy.some_method() == 42
|
||||||
|
|
||||||
|
assert (
|
||||||
|
'freqtrade.strategy',
|
||||||
|
logging.DEBUG,
|
||||||
|
'Imported strategy freqtrade.strategy.default_strategy.DefaultStrategy '
|
||||||
|
'as freqtrade.strategy.DefaultStrategy',
|
||||||
|
) in caplog.record_tuples
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_strategy():
|
||||||
|
default_location = os.path.join(os.path.dirname(
|
||||||
|
os.path.realpath(__file__)), '..', '..', 'strategy'
|
||||||
|
)
|
||||||
|
assert isinstance(
|
||||||
|
StrategyResolver._search_strategy(default_location, 'DefaultStrategy'), IStrategy
|
||||||
|
)
|
||||||
|
assert StrategyResolver._search_strategy(default_location, 'NotFoundStrategy') is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_strategy(result):
|
||||||
|
resolver = StrategyResolver({'strategy': 'TestStrategy'})
|
||||||
|
assert hasattr(resolver.strategy, 'populate_indicators')
|
||||||
|
assert 'adx' in resolver.strategy.populate_indicators(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_strategy_invalid_directory(result, caplog):
|
||||||
|
resolver = StrategyResolver()
|
||||||
|
extra_dir = os.path.join('some', 'path')
|
||||||
|
resolver._load_strategy('TestStrategy', extra_dir)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
'freqtrade.strategy.resolver',
|
||||||
|
logging.WARNING,
|
||||||
|
'Path "{}" does not exist'.format(extra_dir),
|
||||||
|
) in caplog.record_tuples
|
||||||
|
|
||||||
|
assert hasattr(resolver.strategy, 'populate_indicators')
|
||||||
|
assert 'adx' in resolver.strategy.populate_indicators(result)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_not_found_strategy():
|
||||||
|
strategy = StrategyResolver()
|
||||||
|
with pytest.raises(ImportError,
|
||||||
|
match=r'Impossible to load Strategy \'NotFoundStrategy\'.'
|
||||||
|
r' This class does not exist or contains Python code errors'):
|
||||||
|
strategy._load_strategy('NotFoundStrategy')
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy(result):
|
||||||
|
resolver = StrategyResolver({'strategy': 'DefaultStrategy'})
|
||||||
|
|
||||||
|
assert hasattr(resolver.strategy, 'minimal_roi')
|
||||||
|
assert resolver.strategy.minimal_roi[0] == 0.04
|
||||||
|
|
||||||
|
assert hasattr(resolver.strategy, 'stoploss')
|
||||||
|
assert resolver.strategy.stoploss == -0.10
|
||||||
|
|
||||||
|
assert hasattr(resolver.strategy, 'populate_indicators')
|
||||||
|
assert 'adx' in resolver.strategy.populate_indicators(result)
|
||||||
|
|
||||||
|
assert hasattr(resolver.strategy, 'populate_buy_trend')
|
||||||
|
dataframe = resolver.strategy.populate_buy_trend(resolver.strategy.populate_indicators(result))
|
||||||
|
assert 'buy' in dataframe.columns
|
||||||
|
|
||||||
|
assert hasattr(resolver.strategy, 'populate_sell_trend')
|
||||||
|
dataframe = resolver.strategy.populate_sell_trend(resolver.strategy.populate_indicators(result))
|
||||||
|
assert 'sell' in dataframe.columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_override_minimal_roi(caplog):
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
config = {
|
||||||
|
'strategy': 'DefaultStrategy',
|
||||||
|
'minimal_roi': {
|
||||||
|
"0": 0.5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolver = StrategyResolver(config)
|
||||||
|
|
||||||
|
assert hasattr(resolver.strategy, 'minimal_roi')
|
||||||
|
assert resolver.strategy.minimal_roi[0] == 0.5
|
||||||
|
assert ('freqtrade.strategy.resolver',
|
||||||
|
logging.INFO,
|
||||||
|
'Override strategy \'minimal_roi\' with value in config file.'
|
||||||
|
) in caplog.record_tuples
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_override_stoploss(caplog):
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
config = {
|
||||||
|
'strategy': 'DefaultStrategy',
|
||||||
|
'stoploss': -0.5
|
||||||
|
}
|
||||||
|
resolver = StrategyResolver(config)
|
||||||
|
|
||||||
|
assert hasattr(resolver.strategy, 'stoploss')
|
||||||
|
assert resolver.strategy.stoploss == -0.5
|
||||||
|
assert ('freqtrade.strategy.resolver',
|
||||||
|
logging.INFO,
|
||||||
|
'Override strategy \'stoploss\' with value in config file: -0.5.'
|
||||||
|
) in caplog.record_tuples
|
||||||
|
|
||||||
|
|
||||||
|
def test_strategy_override_ticker_interval(caplog):
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
|
||||||
|
config = {
|
||||||
|
'strategy': 'DefaultStrategy',
|
||||||
|
'ticker_interval': 60
|
||||||
|
}
|
||||||
|
resolver = StrategyResolver(config)
|
||||||
|
|
||||||
|
assert hasattr(resolver.strategy, 'ticker_interval')
|
||||||
|
assert resolver.strategy.ticker_interval == 60
|
||||||
|
assert ('freqtrade.strategy.resolver',
|
||||||
|
logging.INFO,
|
||||||
|
'Override strategy \'ticker_interval\' with value in config file: 60.'
|
||||||
|
) in caplog.record_tuples
|
||||||
89
freqtrade/tests/test_acl_pair.py
Normal file
89
freqtrade/tests/test_acl_pair.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring,C0103,protected-access
|
||||||
|
|
||||||
|
import freqtrade.tests.conftest as tt # test tools
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# whitelist, blacklist, filtering, all of that will
|
||||||
|
# eventually become some rules to run on a generic ACL engine
|
||||||
|
# perhaps try to anticipate that by using some python package
|
||||||
|
|
||||||
|
|
||||||
|
def whitelist_conf():
|
||||||
|
config = tt.default_conf()
|
||||||
|
|
||||||
|
config['stake_currency'] = 'BTC'
|
||||||
|
config['exchange']['pair_whitelist'] = [
|
||||||
|
'ETH/BTC',
|
||||||
|
'TKN/BTC',
|
||||||
|
'TRST/BTC',
|
||||||
|
'SWT/BTC',
|
||||||
|
'BCC/BTC'
|
||||||
|
]
|
||||||
|
|
||||||
|
config['exchange']['pair_blacklist'] = [
|
||||||
|
'BLK/BTC'
|
||||||
|
]
|
||||||
|
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_market_pair_not_in_whitelist(mocker, markets):
|
||||||
|
conf = whitelist_conf()
|
||||||
|
|
||||||
|
freqtradebot = tt.get_patched_freqtradebot(mocker, conf)
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_markets', markets)
|
||||||
|
refreshedwhitelist = freqtradebot._refresh_whitelist(
|
||||||
|
conf['exchange']['pair_whitelist'] + ['XXX/BTC']
|
||||||
|
)
|
||||||
|
# List ordered by BaseVolume
|
||||||
|
whitelist = ['ETH/BTC', 'TKN/BTC']
|
||||||
|
# Ensure all except those in whitelist are removed
|
||||||
|
assert whitelist == refreshedwhitelist
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_whitelist(mocker, markets):
|
||||||
|
conf = whitelist_conf()
|
||||||
|
freqtradebot = tt.get_patched_freqtradebot(mocker, conf)
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_markets', markets)
|
||||||
|
refreshedwhitelist = freqtradebot._refresh_whitelist(conf['exchange']['pair_whitelist'])
|
||||||
|
|
||||||
|
# List ordered by BaseVolume
|
||||||
|
whitelist = ['ETH/BTC', 'TKN/BTC']
|
||||||
|
# Ensure all except those in whitelist are removed
|
||||||
|
assert whitelist == refreshedwhitelist
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_whitelist_dynamic(mocker, markets, tickers):
|
||||||
|
conf = whitelist_conf()
|
||||||
|
freqtradebot = tt.get_patched_freqtradebot(mocker, conf)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
get_markets=markets,
|
||||||
|
get_tickers=tickers,
|
||||||
|
exchange_has=MagicMock(return_value=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# argument: use the whitelist dynamically by exchange-volume
|
||||||
|
whitelist = ['ETH/BTC', 'TKN/BTC']
|
||||||
|
|
||||||
|
refreshedwhitelist = freqtradebot._refresh_whitelist(
|
||||||
|
freqtradebot._gen_pair_whitelist(conf['stake_currency'])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert whitelist == refreshedwhitelist
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_whitelist_dynamic_empty(mocker, markets_empty):
|
||||||
|
conf = whitelist_conf()
|
||||||
|
freqtradebot = tt.get_patched_freqtradebot(mocker, conf)
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_markets', markets_empty)
|
||||||
|
|
||||||
|
# argument: use the whitelist dynamically by exchange-volume
|
||||||
|
whitelist = []
|
||||||
|
conf['exchange']['pair_whitelist'] = []
|
||||||
|
freqtradebot._refresh_whitelist(whitelist)
|
||||||
|
pairslist = conf['exchange']['pair_whitelist']
|
||||||
|
|
||||||
|
assert set(whitelist) == set(pairslist)
|
||||||
@@ -1,47 +1,197 @@
|
|||||||
# pragma pylint: disable=missing-docstring
|
# pragma pylint: disable=missing-docstring, C0103
|
||||||
import pytest
|
|
||||||
|
"""
|
||||||
|
Unit test file for analyse.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import logging
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from freqtrade.analyze import parse_ticker_dataframe, populate_buy_trend, populate_indicators, \
|
from freqtrade.analyze import Analyze, SignalType
|
||||||
get_buy_signal
|
from freqtrade.optimize.__init__ import load_tickerdata_file
|
||||||
|
from freqtrade.arguments import TimeRange
|
||||||
|
from freqtrade.tests.conftest import log_has, get_patched_exchange
|
||||||
|
|
||||||
RESULT_BITTREX = {
|
# Avoid to reinit the same object again and again
|
||||||
'success': True,
|
_ANALYZE = Analyze({'strategy': 'DefaultStrategy'})
|
||||||
'message': '',
|
|
||||||
'result': [
|
|
||||||
{'O': 0.00065311, 'H': 0.00065311, 'L': 0.00065311, 'C': 0.00065311, 'V': 22.17210568, 'T': '2017-08-30T10:40:00', 'BV': 0.01448082},
|
|
||||||
{'O': 0.00066194, 'H': 0.00066195, 'L': 0.00066194, 'C': 0.00066195, 'V': 33.4727437, 'T': '2017-08-30T10:34:00', 'BV': 0.02215696},
|
|
||||||
{'O': 0.00065311, 'H': 0.00065311, 'L': 0.00065311, 'C': 0.00065311, 'V': 53.85127609, 'T': '2017-08-30T10:37:00', 'BV': 0.0351708},
|
|
||||||
{'O': 0.00066194, 'H': 0.00066194, 'L': 0.00065311, 'C': 0.00065311, 'V': 46.29210665, 'T': '2017-08-30T10:42:00', 'BV': 0.03063118},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def result():
|
|
||||||
return parse_ticker_dataframe(RESULT_BITTREX['result'], arrow.get('2017-08-30T10:00:00'))
|
|
||||||
|
|
||||||
def test_dataframe_has_correct_columns(result):
|
def test_signaltype_object() -> None:
|
||||||
|
"""
|
||||||
|
Test the SignalType object has the mandatory Constants
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert hasattr(SignalType, 'BUY')
|
||||||
|
assert hasattr(SignalType, 'SELL')
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_object() -> None:
|
||||||
|
"""
|
||||||
|
Test the Analyze object has the mandatory methods
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert hasattr(Analyze, 'parse_ticker_dataframe')
|
||||||
|
assert hasattr(Analyze, 'populate_indicators')
|
||||||
|
assert hasattr(Analyze, 'populate_buy_trend')
|
||||||
|
assert hasattr(Analyze, 'populate_sell_trend')
|
||||||
|
assert hasattr(Analyze, 'analyze_ticker')
|
||||||
|
assert hasattr(Analyze, 'get_signal')
|
||||||
|
assert hasattr(Analyze, 'should_sell')
|
||||||
|
assert hasattr(Analyze, 'min_roi_reached')
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataframe_correct_length(result):
|
||||||
|
dataframe = Analyze.parse_ticker_dataframe(result)
|
||||||
|
assert len(result.index) - 1 == len(dataframe.index) # last partial candle removed
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataframe_correct_columns(result):
|
||||||
assert result.columns.tolist() == \
|
assert result.columns.tolist() == \
|
||||||
['close', 'high', 'low', 'open', 'date', 'volume']
|
['date', 'open', 'high', 'low', 'close', 'volume']
|
||||||
|
|
||||||
def test_orders_by_date(result):
|
|
||||||
assert result['date'].tolist() == \
|
|
||||||
['2017-08-30T10:34:00',
|
|
||||||
'2017-08-30T10:37:00',
|
|
||||||
'2017-08-30T10:40:00',
|
|
||||||
'2017-08-30T10:42:00']
|
|
||||||
|
|
||||||
def test_populates_buy_trend(result):
|
def test_populates_buy_trend(result):
|
||||||
dataframe = populate_buy_trend(populate_indicators(result))
|
# Load the default strategy for the unit test, because this logic is done in main.py
|
||||||
|
dataframe = _ANALYZE.populate_buy_trend(_ANALYZE.populate_indicators(result))
|
||||||
assert 'buy' in dataframe.columns
|
assert 'buy' in dataframe.columns
|
||||||
assert 'buy_price' in dataframe.columns
|
|
||||||
|
|
||||||
def test_returns_latest_buy_signal(mocker):
|
|
||||||
buydf = DataFrame([{'buy': 1, 'date': arrow.utcnow()}])
|
|
||||||
mocker.patch('freqtrade.analyze.analyze_ticker', return_value=buydf)
|
|
||||||
assert get_buy_signal('BTC-ETH')
|
|
||||||
|
|
||||||
buydf = DataFrame([{'buy': 0, 'date': arrow.utcnow()}])
|
def test_populates_sell_trend(result):
|
||||||
mocker.patch('freqtrade.analyze.analyze_ticker', return_value=buydf)
|
# Load the default strategy for the unit test, because this logic is done in main.py
|
||||||
assert not get_buy_signal('BTC-ETH')
|
dataframe = _ANALYZE.populate_sell_trend(_ANALYZE.populate_indicators(result))
|
||||||
|
assert 'sell' in dataframe.columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_latest_buy_signal(mocker, default_conf):
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=MagicMock())
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.analyze.Analyze',
|
||||||
|
analyze_ticker=MagicMock(
|
||||||
|
return_value=DataFrame([{'buy': 1, 'sell': 0, 'date': arrow.utcnow()}])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert _ANALYZE.get_signal(exchange, 'ETH/BTC', '5m') == (True, False)
|
||||||
|
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.analyze.Analyze',
|
||||||
|
analyze_ticker=MagicMock(
|
||||||
|
return_value=DataFrame([{'buy': 0, 'sell': 1, 'date': arrow.utcnow()}])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert _ANALYZE.get_signal(exchange, 'ETH/BTC', '5m') == (False, True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_returns_latest_sell_signal(mocker, default_conf):
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=MagicMock())
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.analyze.Analyze',
|
||||||
|
analyze_ticker=MagicMock(
|
||||||
|
return_value=DataFrame([{'sell': 1, 'buy': 0, 'date': arrow.utcnow()}])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _ANALYZE.get_signal(exchange, 'ETH/BTC', '5m') == (False, True)
|
||||||
|
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.analyze.Analyze',
|
||||||
|
analyze_ticker=MagicMock(
|
||||||
|
return_value=DataFrame([{'sell': 0, 'buy': 1, 'date': arrow.utcnow()}])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert _ANALYZE.get_signal(exchange, 'ETH/BTC', '5m') == (True, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_signal_empty(default_conf, mocker, caplog):
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=None)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
assert (False, False) == _ANALYZE.get_signal(exchange, 'foo', default_conf['ticker_interval'])
|
||||||
|
assert log_has('Empty ticker history for pair foo', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_signal_exception_valueerror(default_conf, mocker, caplog):
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=1)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.analyze.Analyze',
|
||||||
|
analyze_ticker=MagicMock(
|
||||||
|
side_effect=ValueError('xyz')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert (False, False) == _ANALYZE.get_signal(exchange, 'foo', default_conf['ticker_interval'])
|
||||||
|
assert log_has('Unable to analyze ticker for pair foo: xyz', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_signal_empty_dataframe(default_conf, mocker, caplog):
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=1)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.analyze.Analyze',
|
||||||
|
analyze_ticker=MagicMock(
|
||||||
|
return_value=DataFrame([])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert (False, False) == _ANALYZE.get_signal(exchange, 'xyz', default_conf['ticker_interval'])
|
||||||
|
assert log_has('Empty dataframe for pair xyz', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_signal_old_dataframe(default_conf, mocker, caplog):
|
||||||
|
caplog.set_level(logging.INFO)
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=1)
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
# FIX: The get_signal function has hardcoded 10, which we must inturn hardcode
|
||||||
|
oldtime = arrow.utcnow() - datetime.timedelta(minutes=11)
|
||||||
|
ticks = DataFrame([{'buy': 1, 'date': oldtime}])
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.analyze.Analyze',
|
||||||
|
analyze_ticker=MagicMock(
|
||||||
|
return_value=DataFrame(ticks)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert (False, False) == _ANALYZE.get_signal(exchange, 'xyz', default_conf['ticker_interval'])
|
||||||
|
assert log_has(
|
||||||
|
'Outdated history for pair xyz. Last tick is 11 minutes old',
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_signal_handles_exceptions(mocker, default_conf):
|
||||||
|
mocker.patch('freqtrade.exchange.Exchange.get_ticker_history', return_value=MagicMock())
|
||||||
|
exchange = get_patched_exchange(mocker, default_conf)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.analyze.Analyze',
|
||||||
|
analyze_ticker=MagicMock(
|
||||||
|
side_effect=Exception('invalid ticker history ')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert _ANALYZE.get_signal(exchange, 'ETH/BTC', '5m') == (False, False)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_ticker_dataframe(ticker_history):
|
||||||
|
columns = ['date', 'open', 'high', 'low', 'close', 'volume']
|
||||||
|
|
||||||
|
# Test file with BV data
|
||||||
|
dataframe = Analyze.parse_ticker_dataframe(ticker_history)
|
||||||
|
assert dataframe.columns.tolist() == columns
|
||||||
|
|
||||||
|
|
||||||
|
def test_tickerdata_to_dataframe(default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test Analyze.tickerdata_to_dataframe() method
|
||||||
|
"""
|
||||||
|
analyze = Analyze(default_conf)
|
||||||
|
|
||||||
|
timerange = TimeRange(None, 'line', 0, -100)
|
||||||
|
tick = load_tickerdata_file(None, 'UNITTEST/BTC', '1m', timerange=timerange)
|
||||||
|
tickerlist = {'UNITTEST/BTC': tick}
|
||||||
|
data = analyze.tickerdata_to_dataframe(tickerlist)
|
||||||
|
assert len(data['UNITTEST/BTC']) == 99 # partial candle was removed
|
||||||
|
|||||||
192
freqtrade/tests/test_arguments.py
Normal file
192
freqtrade/tests/test_arguments.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring, C0103
|
||||||
|
|
||||||
|
"""
|
||||||
|
Unit test file for arguments.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from freqtrade.arguments import Arguments, TimeRange
|
||||||
|
|
||||||
|
|
||||||
|
def test_arguments_object() -> None:
|
||||||
|
"""
|
||||||
|
Test the Arguments object has the mandatory methods
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert hasattr(Arguments, 'get_parsed_arg')
|
||||||
|
assert hasattr(Arguments, 'parse_args')
|
||||||
|
assert hasattr(Arguments, 'parse_timerange')
|
||||||
|
assert hasattr(Arguments, 'scripts_options')
|
||||||
|
|
||||||
|
|
||||||
|
# Parse common command-line-arguments. Used for all tools
|
||||||
|
def test_parse_args_none() -> None:
|
||||||
|
arguments = Arguments([], '')
|
||||||
|
assert isinstance(arguments, Arguments)
|
||||||
|
assert isinstance(arguments.parser, argparse.ArgumentParser)
|
||||||
|
assert isinstance(arguments.parser, argparse.ArgumentParser)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_defaults() -> None:
|
||||||
|
args = Arguments([], '').get_parsed_arg()
|
||||||
|
assert args.config == 'config.json'
|
||||||
|
assert args.dynamic_whitelist is None
|
||||||
|
assert args.loglevel == logging.INFO
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_config() -> None:
|
||||||
|
args = Arguments(['-c', '/dev/null'], '').get_parsed_arg()
|
||||||
|
assert args.config == '/dev/null'
|
||||||
|
|
||||||
|
args = Arguments(['--config', '/dev/null'], '').get_parsed_arg()
|
||||||
|
assert args.config == '/dev/null'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_db_url() -> None:
|
||||||
|
args = Arguments(['--db-url', 'sqlite:///test.sqlite'], '').get_parsed_arg()
|
||||||
|
assert args.db_url == 'sqlite:///test.sqlite'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_verbose() -> None:
|
||||||
|
args = Arguments(['-v'], '').get_parsed_arg()
|
||||||
|
assert args.loglevel == logging.DEBUG
|
||||||
|
|
||||||
|
args = Arguments(['--verbose'], '').get_parsed_arg()
|
||||||
|
assert args.loglevel == logging.DEBUG
|
||||||
|
|
||||||
|
|
||||||
|
def test_scripts_options() -> None:
|
||||||
|
arguments = Arguments(['-p', 'ETH/BTC'], '')
|
||||||
|
arguments.scripts_options()
|
||||||
|
args = arguments.get_parsed_arg()
|
||||||
|
assert args.pair == 'ETH/BTC'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_version() -> None:
|
||||||
|
with pytest.raises(SystemExit, match=r'0'):
|
||||||
|
Arguments(['--version'], '').get_parsed_arg()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_invalid() -> None:
|
||||||
|
with pytest.raises(SystemExit, match=r'2'):
|
||||||
|
Arguments(['-c'], '').get_parsed_arg()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_strategy() -> None:
|
||||||
|
args = Arguments(['--strategy', 'SomeStrategy'], '').get_parsed_arg()
|
||||||
|
assert args.strategy == 'SomeStrategy'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_strategy_invalid() -> None:
|
||||||
|
with pytest.raises(SystemExit, match=r'2'):
|
||||||
|
Arguments(['--strategy'], '').get_parsed_arg()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_strategy_path() -> None:
|
||||||
|
args = Arguments(['--strategy-path', '/some/path'], '').get_parsed_arg()
|
||||||
|
assert args.strategy_path == '/some/path'
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_strategy_path_invalid() -> None:
|
||||||
|
with pytest.raises(SystemExit, match=r'2'):
|
||||||
|
Arguments(['--strategy-path'], '').get_parsed_arg()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_dynamic_whitelist() -> None:
|
||||||
|
args = Arguments(['--dynamic-whitelist'], '').get_parsed_arg()
|
||||||
|
assert args.dynamic_whitelist == 20
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_dynamic_whitelist_10() -> None:
|
||||||
|
args = Arguments(['--dynamic-whitelist', '10'], '').get_parsed_arg()
|
||||||
|
assert args.dynamic_whitelist == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_dynamic_whitelist_invalid_values() -> None:
|
||||||
|
with pytest.raises(SystemExit, match=r'2'):
|
||||||
|
Arguments(['--dynamic-whitelist', 'abc'], '').get_parsed_arg()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_timerange_incorrect() -> None:
|
||||||
|
assert TimeRange(None, 'line', 0, -200) == Arguments.parse_timerange('-200')
|
||||||
|
assert TimeRange('line', None, 200, 0) == Arguments.parse_timerange('200-')
|
||||||
|
assert TimeRange('index', 'index', 200, 500) == Arguments.parse_timerange('200-500')
|
||||||
|
|
||||||
|
assert TimeRange('date', None, 1274486400, 0) == Arguments.parse_timerange('20100522-')
|
||||||
|
assert TimeRange(None, 'date', 0, 1274486400) == Arguments.parse_timerange('-20100522')
|
||||||
|
timerange = Arguments.parse_timerange('20100522-20150730')
|
||||||
|
assert timerange == TimeRange('date', 'date', 1274486400, 1438214400)
|
||||||
|
|
||||||
|
# Added test for unix timestamp - BTC genesis date
|
||||||
|
assert TimeRange('date', None, 1231006505, 0) == Arguments.parse_timerange('1231006505-')
|
||||||
|
assert TimeRange(None, 'date', 0, 1233360000) == Arguments.parse_timerange('-1233360000')
|
||||||
|
timerange = Arguments.parse_timerange('1231006505-1233360000')
|
||||||
|
assert TimeRange('date', 'date', 1231006505, 1233360000) == timerange
|
||||||
|
|
||||||
|
# TODO: Find solution for the following case (passing timestamp in ms)
|
||||||
|
timerange = Arguments.parse_timerange('1231006505000-1233360000000')
|
||||||
|
assert TimeRange('date', 'date', 1231006505, 1233360000) != timerange
|
||||||
|
|
||||||
|
with pytest.raises(Exception, match=r'Incorrect syntax.*'):
|
||||||
|
Arguments.parse_timerange('-')
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_backtesting_invalid() -> None:
|
||||||
|
with pytest.raises(SystemExit, match=r'2'):
|
||||||
|
Arguments(['backtesting --ticker-interval'], '').get_parsed_arg()
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit, match=r'2'):
|
||||||
|
Arguments(['backtesting --ticker-interval', 'abc'], '').get_parsed_arg()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_backtesting_custom() -> None:
|
||||||
|
args = [
|
||||||
|
'-c', 'test_conf.json',
|
||||||
|
'backtesting',
|
||||||
|
'--live',
|
||||||
|
'--ticker-interval', '1m',
|
||||||
|
'--refresh-pairs-cached']
|
||||||
|
call_args = Arguments(args, '').get_parsed_arg()
|
||||||
|
assert call_args.config == 'test_conf.json'
|
||||||
|
assert call_args.live is True
|
||||||
|
assert call_args.loglevel == logging.INFO
|
||||||
|
assert call_args.subparser == 'backtesting'
|
||||||
|
assert call_args.func is not None
|
||||||
|
assert call_args.ticker_interval == '1m'
|
||||||
|
assert call_args.refresh_pairs is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_args_hyperopt_custom() -> None:
|
||||||
|
args = [
|
||||||
|
'-c', 'test_conf.json',
|
||||||
|
'hyperopt',
|
||||||
|
'--epochs', '20',
|
||||||
|
'--spaces', 'buy'
|
||||||
|
]
|
||||||
|
call_args = Arguments(args, '').get_parsed_arg()
|
||||||
|
assert call_args.config == 'test_conf.json'
|
||||||
|
assert call_args.epochs == 20
|
||||||
|
assert call_args.loglevel == logging.INFO
|
||||||
|
assert call_args.subparser == 'hyperopt'
|
||||||
|
assert call_args.spaces == ['buy']
|
||||||
|
assert call_args.func is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_testdata_dl_options() -> None:
|
||||||
|
args = [
|
||||||
|
'--pairs-file', 'file_with_pairs',
|
||||||
|
'--export', 'export/folder',
|
||||||
|
'--days', '30',
|
||||||
|
'--exchange', 'binance'
|
||||||
|
]
|
||||||
|
arguments = Arguments(args, '')
|
||||||
|
arguments.testdata_dl_options()
|
||||||
|
args = arguments.parse_args()
|
||||||
|
assert args.pairs_file == 'file_with_pairs'
|
||||||
|
assert args.export == 'export/folder'
|
||||||
|
assert args.days == 30
|
||||||
|
assert args.exchange == 'binance'
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
# pragma pylint: disable=missing-docstring
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import arrow
|
|
||||||
from pandas import DataFrame
|
|
||||||
|
|
||||||
from freqtrade.analyze import analyze_ticker
|
|
||||||
from freqtrade.main import should_sell
|
|
||||||
from freqtrade.persistence import Trade
|
|
||||||
|
|
||||||
logging.disable(logging.DEBUG) # disable debug logs that slow backtesting a lot
|
|
||||||
|
|
||||||
def print_results(results):
|
|
||||||
print('Made {} buys. Average profit {:.2f}%. Total profit was {:.3f}. Average duration {:.1f} mins.'.format(
|
|
||||||
len(results.index),
|
|
||||||
results.profit.mean() * 100.0,
|
|
||||||
results.profit.sum(),
|
|
||||||
results.duration.mean()*5
|
|
||||||
))
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def pairs():
|
|
||||||
return ['btc-neo', 'btc-eth', 'btc-omg', 'btc-edg', 'btc-pay',
|
|
||||||
'btc-pivx', 'btc-qtum', 'btc-mtl', 'btc-etc', 'btc-ltc']
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def conf():
|
|
||||||
return {
|
|
||||||
"minimal_roi": {
|
|
||||||
"60": 0.0,
|
|
||||||
"40": 0.01,
|
|
||||||
"20": 0.02,
|
|
||||||
"0": 0.03
|
|
||||||
},
|
|
||||||
"stoploss": -0.40
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(not os.environ.get('BACKTEST', False), reason="BACKTEST not set")
|
|
||||||
def test_backtest(conf, pairs, mocker):
|
|
||||||
trades = []
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
|
||||||
for pair in pairs:
|
|
||||||
with open('freqtrade/tests/testdata/'+pair+'.json') as data_file:
|
|
||||||
data = json.load(data_file)
|
|
||||||
|
|
||||||
mocker.patch('freqtrade.analyze.get_ticker_history', return_value=data)
|
|
||||||
mocker.patch('arrow.utcnow', return_value=arrow.get('2017-08-20T14:50:00'))
|
|
||||||
ticker = analyze_ticker(pair)
|
|
||||||
# for each buy point
|
|
||||||
for index, row in ticker[ticker.buy == 1].iterrows():
|
|
||||||
trade = Trade(
|
|
||||||
open_rate=row['close'],
|
|
||||||
open_date=arrow.get(row['date']).datetime,
|
|
||||||
amount=1,
|
|
||||||
)
|
|
||||||
# calculate win/lose forwards from buy point
|
|
||||||
for index2, row2 in ticker[index:].iterrows():
|
|
||||||
if should_sell(trade, row2['close'], arrow.get(row2['date']).datetime):
|
|
||||||
current_profit = (row2['close'] - trade.open_rate) / trade.open_rate
|
|
||||||
|
|
||||||
trades.append((pair, current_profit, index2 - index))
|
|
||||||
break
|
|
||||||
|
|
||||||
labels = ['currency', 'profit', 'duration']
|
|
||||||
results = DataFrame.from_records(trades, columns=labels)
|
|
||||||
|
|
||||||
print('====================== BACKTESTING REPORT ================================')
|
|
||||||
|
|
||||||
for pair in pairs:
|
|
||||||
print('For currency {}:'.format(pair))
|
|
||||||
print_results(results[results.currency == pair])
|
|
||||||
print('TOTAL OVER ALL TRADES:')
|
|
||||||
print_results(results)
|
|
||||||
404
freqtrade/tests/test_configuration.py
Normal file
404
freqtrade/tests/test_configuration.py
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
# pragma pylint: disable=protected-access, invalid-name
|
||||||
|
|
||||||
|
"""
|
||||||
|
Unit test file for configuration.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from jsonschema import ValidationError
|
||||||
|
|
||||||
|
from freqtrade.arguments import Arguments
|
||||||
|
from freqtrade.configuration import Configuration
|
||||||
|
from freqtrade.constants import DEFAULT_DB_PROD_URL, DEFAULT_DB_DRYRUN_URL
|
||||||
|
from freqtrade.tests.conftest import log_has
|
||||||
|
from freqtrade import OperationalException
|
||||||
|
|
||||||
|
|
||||||
|
def test_configuration_object() -> None:
|
||||||
|
"""
|
||||||
|
Test the Constants object has the mandatory Constants
|
||||||
|
"""
|
||||||
|
assert hasattr(Configuration, 'load_config')
|
||||||
|
assert hasattr(Configuration, '_load_config_file')
|
||||||
|
assert hasattr(Configuration, '_validate_config')
|
||||||
|
assert hasattr(Configuration, '_load_common_config')
|
||||||
|
assert hasattr(Configuration, '_load_backtesting_config')
|
||||||
|
assert hasattr(Configuration, '_load_hyperopt_config')
|
||||||
|
assert hasattr(Configuration, 'get_config')
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_invalid_pair(default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test the configuration validator with an invalid PAIR format
|
||||||
|
"""
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['exchange']['pair_whitelist'].append('ETH-BTC')
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match=r'.*does not match.*'):
|
||||||
|
configuration = Configuration(Namespace())
|
||||||
|
configuration._validate_config(conf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_missing_attributes(default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test the configuration validator with a missing attribute
|
||||||
|
"""
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf.pop('exchange')
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match=r'.*\'exchange\' is a required property.*'):
|
||||||
|
configuration = Configuration(Namespace())
|
||||||
|
configuration._validate_config(conf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_incorrect_stake_amount(default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test the configuration validator with a missing attribute
|
||||||
|
"""
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['stake_amount'] = 'fake'
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match=r'.*\'fake\' does not match \'unlimited\'.*'):
|
||||||
|
configuration = Configuration(Namespace())
|
||||||
|
configuration._validate_config(conf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_file(default_conf, mocker, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test Configuration._load_config_file() method
|
||||||
|
"""
|
||||||
|
file_mock = mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(default_conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
configuration = Configuration(Namespace())
|
||||||
|
validated_conf = configuration._load_config_file('somefile')
|
||||||
|
assert file_mock.call_count == 1
|
||||||
|
assert validated_conf.items() >= default_conf.items()
|
||||||
|
assert 'internals' in validated_conf
|
||||||
|
assert log_has('Validating configuration ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_max_open_trades_zero(default_conf, mocker, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test Configuration._load_config_file() method
|
||||||
|
"""
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf['max_open_trades'] = 0
|
||||||
|
file_mock = mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
Configuration(Namespace())._load_config_file('somefile')
|
||||||
|
assert file_mock.call_count == 1
|
||||||
|
assert log_has('Validating configuration ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_file_exception(mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test Configuration._load_config_file() method
|
||||||
|
"""
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.configuration.open',
|
||||||
|
MagicMock(side_effect=FileNotFoundError('File not found'))
|
||||||
|
)
|
||||||
|
configuration = Configuration(Namespace())
|
||||||
|
|
||||||
|
with pytest.raises(OperationalException, match=r'.*Config file "somefile" not found!*'):
|
||||||
|
configuration._load_config_file('somefile')
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config(default_conf, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test Configuration.load_config() without any cli params
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(default_conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
args = Arguments([], '').get_parsed_arg()
|
||||||
|
configuration = Configuration(args)
|
||||||
|
validated_conf = configuration.load_config()
|
||||||
|
|
||||||
|
assert validated_conf.get('strategy') == 'DefaultStrategy'
|
||||||
|
assert validated_conf.get('strategy_path') is None
|
||||||
|
assert 'dynamic_whitelist' not in validated_conf
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_with_params(default_conf, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test Configuration.load_config() with cli params used
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(default_conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
arglist = [
|
||||||
|
'--dynamic-whitelist', '10',
|
||||||
|
'--strategy', 'TestStrategy',
|
||||||
|
'--strategy-path', '/some/path',
|
||||||
|
'--db-url', 'sqlite:///someurl',
|
||||||
|
]
|
||||||
|
args = Arguments(arglist, '').get_parsed_arg()
|
||||||
|
|
||||||
|
configuration = Configuration(args)
|
||||||
|
validated_conf = configuration.load_config()
|
||||||
|
|
||||||
|
assert validated_conf.get('dynamic_whitelist') == 10
|
||||||
|
assert validated_conf.get('strategy') == 'TestStrategy'
|
||||||
|
assert validated_conf.get('strategy_path') == '/some/path'
|
||||||
|
assert validated_conf.get('db_url') == 'sqlite:///someurl'
|
||||||
|
|
||||||
|
conf = default_conf.copy()
|
||||||
|
conf["dry_run"] = False
|
||||||
|
del conf["db_url"]
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
arglist = [
|
||||||
|
'--dynamic-whitelist', '10',
|
||||||
|
'--strategy', 'TestStrategy',
|
||||||
|
'--strategy-path', '/some/path'
|
||||||
|
]
|
||||||
|
args = Arguments(arglist, '').get_parsed_arg()
|
||||||
|
|
||||||
|
configuration = Configuration(args)
|
||||||
|
validated_conf = configuration.load_config()
|
||||||
|
assert validated_conf.get('db_url') == DEFAULT_DB_PROD_URL
|
||||||
|
|
||||||
|
# Test dry=run with ProdURL
|
||||||
|
conf = default_conf.copy()
|
||||||
|
conf["dry_run"] = True
|
||||||
|
conf["db_url"] = DEFAULT_DB_PROD_URL
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
arglist = [
|
||||||
|
'--dynamic-whitelist', '10',
|
||||||
|
'--strategy', 'TestStrategy',
|
||||||
|
'--strategy-path', '/some/path'
|
||||||
|
]
|
||||||
|
args = Arguments(arglist, '').get_parsed_arg()
|
||||||
|
|
||||||
|
configuration = Configuration(args)
|
||||||
|
validated_conf = configuration.load_config()
|
||||||
|
assert validated_conf.get('db_url') == DEFAULT_DB_DRYRUN_URL
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_custom_strategy(default_conf, mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test Configuration.load_config() without any cli params
|
||||||
|
"""
|
||||||
|
custom_conf = deepcopy(default_conf)
|
||||||
|
custom_conf.update({
|
||||||
|
'strategy': 'CustomStrategy',
|
||||||
|
'strategy_path': '/tmp/strategies',
|
||||||
|
})
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(custom_conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
args = Arguments([], '').get_parsed_arg()
|
||||||
|
configuration = Configuration(args)
|
||||||
|
validated_conf = configuration.load_config()
|
||||||
|
|
||||||
|
assert validated_conf.get('strategy') == 'CustomStrategy'
|
||||||
|
assert validated_conf.get('strategy_path') == '/tmp/strategies'
|
||||||
|
|
||||||
|
|
||||||
|
def test_show_info(default_conf, mocker, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test Configuration.show_info()
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(default_conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
arglist = [
|
||||||
|
'--dynamic-whitelist', '10',
|
||||||
|
'--strategy', 'TestStrategy',
|
||||||
|
'--db-url', 'sqlite:///tmp/testdb',
|
||||||
|
]
|
||||||
|
args = Arguments(arglist, '').get_parsed_arg()
|
||||||
|
|
||||||
|
configuration = Configuration(args)
|
||||||
|
configuration.get_config()
|
||||||
|
|
||||||
|
assert log_has(
|
||||||
|
'Parameter --dynamic-whitelist detected. '
|
||||||
|
'Using dynamically generated whitelist. '
|
||||||
|
'(not applicable with Backtesting and Hyperopt)',
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
|
||||||
|
assert log_has('Using DB: "sqlite:///tmp/testdb"', caplog.record_tuples)
|
||||||
|
assert log_has('Dry run is enabled', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_configuration_without_arguments(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test setup_configuration() function
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(default_conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
arglist = [
|
||||||
|
'--config', 'config.json',
|
||||||
|
'--strategy', 'DefaultStrategy',
|
||||||
|
'backtesting'
|
||||||
|
]
|
||||||
|
|
||||||
|
args = Arguments(arglist, '').get_parsed_arg()
|
||||||
|
|
||||||
|
configuration = Configuration(args)
|
||||||
|
config = configuration.get_config()
|
||||||
|
assert 'max_open_trades' in config
|
||||||
|
assert 'stake_currency' in config
|
||||||
|
assert 'stake_amount' in config
|
||||||
|
assert 'exchange' in config
|
||||||
|
assert 'pair_whitelist' in config['exchange']
|
||||||
|
assert 'datadir' in config
|
||||||
|
assert log_has(
|
||||||
|
'Using data folder: {} ...'.format(config['datadir']),
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
assert 'ticker_interval' in config
|
||||||
|
assert not log_has('Parameter -i/--ticker-interval detected ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'live' not in config
|
||||||
|
assert not log_has('Parameter -l/--live detected ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'realistic_simulation' not in config
|
||||||
|
assert not log_has('Parameter --realistic-simulation detected ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'refresh_pairs' not in config
|
||||||
|
assert not log_has('Parameter -r/--refresh-pairs-cached detected ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'timerange' not in config
|
||||||
|
assert 'export' not in config
|
||||||
|
|
||||||
|
|
||||||
|
def test_setup_configuration_with_arguments(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test setup_configuration() function
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(default_conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
arglist = [
|
||||||
|
'--config', 'config.json',
|
||||||
|
'--strategy', 'DefaultStrategy',
|
||||||
|
'--datadir', '/foo/bar',
|
||||||
|
'backtesting',
|
||||||
|
'--ticker-interval', '1m',
|
||||||
|
'--live',
|
||||||
|
'--realistic-simulation',
|
||||||
|
'--refresh-pairs-cached',
|
||||||
|
'--timerange', ':100',
|
||||||
|
'--export', '/bar/foo'
|
||||||
|
]
|
||||||
|
|
||||||
|
args = Arguments(arglist, '').get_parsed_arg()
|
||||||
|
|
||||||
|
configuration = Configuration(args)
|
||||||
|
config = configuration.get_config()
|
||||||
|
assert 'max_open_trades' in config
|
||||||
|
assert 'stake_currency' in config
|
||||||
|
assert 'stake_amount' in config
|
||||||
|
assert 'exchange' in config
|
||||||
|
assert 'pair_whitelist' in config['exchange']
|
||||||
|
assert 'datadir' in config
|
||||||
|
assert log_has(
|
||||||
|
'Using data folder: {} ...'.format(config['datadir']),
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
assert 'ticker_interval' in config
|
||||||
|
assert log_has('Parameter -i/--ticker-interval detected ...', caplog.record_tuples)
|
||||||
|
assert log_has(
|
||||||
|
'Using ticker_interval: 1m ...',
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'live' in config
|
||||||
|
assert log_has('Parameter -l/--live detected ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'realistic_simulation'in config
|
||||||
|
assert log_has('Parameter --realistic-simulation detected ...', caplog.record_tuples)
|
||||||
|
assert log_has('Using max_open_trades: 1 ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'refresh_pairs'in config
|
||||||
|
assert log_has('Parameter -r/--refresh-pairs-cached detected ...', caplog.record_tuples)
|
||||||
|
assert 'timerange' in config
|
||||||
|
assert log_has(
|
||||||
|
'Parameter --timerange detected: {} ...'.format(config['timerange']),
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
|
||||||
|
assert 'export' in config
|
||||||
|
assert log_has(
|
||||||
|
'Parameter --export detected: {} ...'.format(config['export']),
|
||||||
|
caplog.record_tuples
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_hyperopt_with_arguments(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test setup_configuration() function
|
||||||
|
"""
|
||||||
|
mocker.patch('freqtrade.configuration.open', mocker.mock_open(
|
||||||
|
read_data=json.dumps(default_conf)
|
||||||
|
))
|
||||||
|
|
||||||
|
arglist = [
|
||||||
|
'hyperopt',
|
||||||
|
'--epochs', '10',
|
||||||
|
'--spaces', 'all',
|
||||||
|
]
|
||||||
|
|
||||||
|
args = Arguments(arglist, '').get_parsed_arg()
|
||||||
|
|
||||||
|
configuration = Configuration(args)
|
||||||
|
config = configuration.get_config()
|
||||||
|
|
||||||
|
assert 'epochs' in config
|
||||||
|
assert int(config['epochs']) == 10
|
||||||
|
assert log_has('Parameter --epochs detected ...', caplog.record_tuples)
|
||||||
|
assert log_has('Will run Hyperopt with for 10 epochs ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
assert 'spaces' in config
|
||||||
|
assert config['spaces'] == ['all']
|
||||||
|
assert log_has('Parameter -s/--spaces detected: [\'all\']', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_exchange(default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test the configuration validator with a missing attribute
|
||||||
|
"""
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
configuration = Configuration(Namespace())
|
||||||
|
|
||||||
|
# Test a valid exchange
|
||||||
|
conf.get('exchange').update({'name': 'BITTREX'})
|
||||||
|
assert configuration.check_exchange(conf)
|
||||||
|
|
||||||
|
# Test a valid exchange
|
||||||
|
conf.get('exchange').update({'name': 'binance'})
|
||||||
|
assert configuration.check_exchange(conf)
|
||||||
|
|
||||||
|
# Test a invalid exchange
|
||||||
|
conf.get('exchange').update({'name': 'unknown_exchange'})
|
||||||
|
configuration.config = conf
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
OperationalException,
|
||||||
|
match=r'.*Exchange "unknown_exchange" not supported.*'
|
||||||
|
):
|
||||||
|
configuration.check_exchange(conf)
|
||||||
25
freqtrade/tests/test_constants.py
Normal file
25
freqtrade/tests/test_constants.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""
|
||||||
|
Unit test file for constants.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from freqtrade import constants
|
||||||
|
|
||||||
|
|
||||||
|
def test_constant_object() -> None:
|
||||||
|
"""
|
||||||
|
Test the Constants object has the mandatory Constants
|
||||||
|
"""
|
||||||
|
assert hasattr(constants, 'CONF_SCHEMA')
|
||||||
|
assert hasattr(constants, 'DYNAMIC_WHITELIST')
|
||||||
|
assert hasattr(constants, 'PROCESS_THROTTLE_SECS')
|
||||||
|
assert hasattr(constants, 'TICKER_INTERVAL')
|
||||||
|
assert hasattr(constants, 'HYPEROPT_EPOCH')
|
||||||
|
assert hasattr(constants, 'RETRY_TIMEOUT')
|
||||||
|
assert hasattr(constants, 'DEFAULT_STRATEGY')
|
||||||
|
|
||||||
|
|
||||||
|
def test_conf_schema() -> None:
|
||||||
|
"""
|
||||||
|
Test the CONF_SCHEMA is from the right type
|
||||||
|
"""
|
||||||
|
assert isinstance(constants.CONF_SCHEMA, dict)
|
||||||
34
freqtrade/tests/test_dataframe.py
Normal file
34
freqtrade/tests/test_dataframe.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring, C0103
|
||||||
|
|
||||||
|
import pandas
|
||||||
|
|
||||||
|
from freqtrade.analyze import Analyze
|
||||||
|
from freqtrade.optimize import load_data
|
||||||
|
from freqtrade.strategy.resolver import StrategyResolver
|
||||||
|
|
||||||
|
_pairs = ['ETH/BTC']
|
||||||
|
|
||||||
|
|
||||||
|
def load_dataframe_pair(pairs):
|
||||||
|
ld = load_data(None, ticker_interval='5m', pairs=pairs)
|
||||||
|
assert isinstance(ld, dict)
|
||||||
|
assert isinstance(pairs[0], str)
|
||||||
|
dataframe = ld[pairs[0]]
|
||||||
|
|
||||||
|
analyze = Analyze({'strategy': 'DefaultStrategy'})
|
||||||
|
dataframe = analyze.analyze_ticker(dataframe)
|
||||||
|
return dataframe
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataframe_load():
|
||||||
|
StrategyResolver({'strategy': 'DefaultStrategy'})
|
||||||
|
dataframe = load_dataframe_pair(_pairs)
|
||||||
|
assert isinstance(dataframe, pandas.core.frame.DataFrame)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dataframe_columns_exists():
|
||||||
|
StrategyResolver({'strategy': 'DefaultStrategy'})
|
||||||
|
dataframe = load_dataframe_pair(_pairs)
|
||||||
|
assert 'high' in dataframe.columns
|
||||||
|
assert 'low' in dataframe.columns
|
||||||
|
assert 'close' in dataframe.columns
|
||||||
204
freqtrade/tests/test_fiat_convert.py
Normal file
204
freqtrade/tests/test_fiat_convert.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring, too-many-arguments, too-many-ancestors,
|
||||||
|
# pragma pylint: disable=protected-access, C0103
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from requests.exceptions import RequestException
|
||||||
|
|
||||||
|
from freqtrade.fiat_convert import CryptoFiat, CryptoToFiatConverter
|
||||||
|
from freqtrade.tests.conftest import log_has, patch_coinmarketcap
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_convertion_object():
|
||||||
|
pair_convertion = CryptoFiat(
|
||||||
|
crypto_symbol='btc',
|
||||||
|
fiat_symbol='usd',
|
||||||
|
price=12345.0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check the cache duration is 6 hours
|
||||||
|
assert pair_convertion.CACHE_DURATION == 6 * 60 * 60
|
||||||
|
|
||||||
|
# Check a regular usage
|
||||||
|
assert pair_convertion.crypto_symbol == 'BTC'
|
||||||
|
assert pair_convertion.fiat_symbol == 'USD'
|
||||||
|
assert pair_convertion.price == 12345.0
|
||||||
|
assert pair_convertion.is_expired() is False
|
||||||
|
|
||||||
|
# Update the expiration time (- 2 hours) and check the behavior
|
||||||
|
pair_convertion._expiration = time.time() - 2 * 60 * 60
|
||||||
|
assert pair_convertion.is_expired() is True
|
||||||
|
|
||||||
|
# Check set price behaviour
|
||||||
|
time_reference = time.time() + pair_convertion.CACHE_DURATION
|
||||||
|
pair_convertion.set_price(price=30000.123)
|
||||||
|
assert pair_convertion.is_expired() is False
|
||||||
|
assert pair_convertion._expiration >= time_reference
|
||||||
|
assert pair_convertion.price == 30000.123
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiat_convert_is_supported(mocker):
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
fiat_convert = CryptoToFiatConverter()
|
||||||
|
assert fiat_convert._is_supported_fiat(fiat='USD') is True
|
||||||
|
assert fiat_convert._is_supported_fiat(fiat='usd') is True
|
||||||
|
assert fiat_convert._is_supported_fiat(fiat='abc') is False
|
||||||
|
assert fiat_convert._is_supported_fiat(fiat='ABC') is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiat_convert_add_pair(mocker):
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
|
||||||
|
fiat_convert = CryptoToFiatConverter()
|
||||||
|
|
||||||
|
pair_len = len(fiat_convert._pairs)
|
||||||
|
assert pair_len == 0
|
||||||
|
|
||||||
|
fiat_convert._add_pair(crypto_symbol='btc', fiat_symbol='usd', price=12345.0)
|
||||||
|
pair_len = len(fiat_convert._pairs)
|
||||||
|
assert pair_len == 1
|
||||||
|
assert fiat_convert._pairs[0].crypto_symbol == 'BTC'
|
||||||
|
assert fiat_convert._pairs[0].fiat_symbol == 'USD'
|
||||||
|
assert fiat_convert._pairs[0].price == 12345.0
|
||||||
|
|
||||||
|
fiat_convert._add_pair(crypto_symbol='btc', fiat_symbol='Eur', price=13000.2)
|
||||||
|
pair_len = len(fiat_convert._pairs)
|
||||||
|
assert pair_len == 2
|
||||||
|
assert fiat_convert._pairs[1].crypto_symbol == 'BTC'
|
||||||
|
assert fiat_convert._pairs[1].fiat_symbol == 'EUR'
|
||||||
|
assert fiat_convert._pairs[1].price == 13000.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiat_convert_find_price(mocker):
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
|
||||||
|
fiat_convert = CryptoToFiatConverter()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=r'The fiat ABC is not supported.'):
|
||||||
|
fiat_convert._find_price(crypto_symbol='BTC', fiat_symbol='ABC')
|
||||||
|
|
||||||
|
assert fiat_convert.get_price(crypto_symbol='XRP', fiat_symbol='USD') == 0.0
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=12345.0)
|
||||||
|
assert fiat_convert.get_price(crypto_symbol='BTC', fiat_symbol='USD') == 12345.0
|
||||||
|
assert fiat_convert.get_price(crypto_symbol='btc', fiat_symbol='usd') == 12345.0
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=13000.2)
|
||||||
|
assert fiat_convert.get_price(crypto_symbol='BTC', fiat_symbol='EUR') == 13000.2
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiat_convert_unsupported_crypto(mocker, caplog):
|
||||||
|
mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._cryptomap', return_value=[])
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
fiat_convert = CryptoToFiatConverter()
|
||||||
|
assert fiat_convert._find_price(crypto_symbol='CRYPTO_123', fiat_symbol='EUR') == 0.0
|
||||||
|
assert log_has('unsupported crypto-symbol CRYPTO_123 - returning 0.0', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiat_convert_get_price(mocker):
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
|
||||||
|
mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter._find_price', return_value=28000.0)
|
||||||
|
|
||||||
|
fiat_convert = CryptoToFiatConverter()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=r'The fiat US DOLLAR is not supported.'):
|
||||||
|
fiat_convert.get_price(crypto_symbol='BTC', fiat_symbol='US Dollar')
|
||||||
|
|
||||||
|
# Check the value return by the method
|
||||||
|
pair_len = len(fiat_convert._pairs)
|
||||||
|
assert pair_len == 0
|
||||||
|
assert fiat_convert.get_price(crypto_symbol='BTC', fiat_symbol='USD') == 28000.0
|
||||||
|
assert fiat_convert._pairs[0].crypto_symbol == 'BTC'
|
||||||
|
assert fiat_convert._pairs[0].fiat_symbol == 'USD'
|
||||||
|
assert fiat_convert._pairs[0].price == 28000.0
|
||||||
|
assert fiat_convert._pairs[0]._expiration is not 0
|
||||||
|
assert len(fiat_convert._pairs) == 1
|
||||||
|
|
||||||
|
# Verify the cached is used
|
||||||
|
fiat_convert._pairs[0].price = 9867.543
|
||||||
|
expiration = fiat_convert._pairs[0]._expiration
|
||||||
|
assert fiat_convert.get_price(crypto_symbol='BTC', fiat_symbol='USD') == 9867.543
|
||||||
|
assert fiat_convert._pairs[0]._expiration == expiration
|
||||||
|
|
||||||
|
# Verify the cache expiration
|
||||||
|
expiration = time.time() - 2 * 60 * 60
|
||||||
|
fiat_convert._pairs[0]._expiration = expiration
|
||||||
|
assert fiat_convert.get_price(crypto_symbol='BTC', fiat_symbol='USD') == 28000.0
|
||||||
|
assert fiat_convert._pairs[0]._expiration is not expiration
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiat_convert_same_currencies(mocker):
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
fiat_convert = CryptoToFiatConverter()
|
||||||
|
|
||||||
|
assert fiat_convert.get_price(crypto_symbol='USD', fiat_symbol='USD') == 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiat_convert_two_FIAT(mocker):
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
fiat_convert = CryptoToFiatConverter()
|
||||||
|
|
||||||
|
assert fiat_convert.get_price(crypto_symbol='USD', fiat_symbol='EUR') == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_loadcryptomap(mocker):
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
|
||||||
|
fiat_convert = CryptoToFiatConverter()
|
||||||
|
assert len(fiat_convert._cryptomap) == 2
|
||||||
|
|
||||||
|
assert fiat_convert._cryptomap["BTC"] == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiat_init_network_exception(mocker):
|
||||||
|
# Because CryptoToFiatConverter is a Singleton we reset the listings
|
||||||
|
listmock = MagicMock(side_effect=RequestException)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.fiat_convert.Market',
|
||||||
|
listings=listmock,
|
||||||
|
)
|
||||||
|
# with pytest.raises(RequestEsxception):
|
||||||
|
fiat_convert = CryptoToFiatConverter()
|
||||||
|
fiat_convert._cryptomap = {}
|
||||||
|
fiat_convert._load_cryptomap()
|
||||||
|
|
||||||
|
length_cryptomap = len(fiat_convert._cryptomap)
|
||||||
|
assert length_cryptomap == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_fiat_convert_without_network(mocker):
|
||||||
|
# Because CryptoToFiatConverter is a Singleton we reset the value of _coinmarketcap
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
|
||||||
|
fiat_convert = CryptoToFiatConverter()
|
||||||
|
|
||||||
|
cmc_temp = CryptoToFiatConverter._coinmarketcap
|
||||||
|
CryptoToFiatConverter._coinmarketcap = None
|
||||||
|
|
||||||
|
assert fiat_convert._coinmarketcap is None
|
||||||
|
assert fiat_convert._find_price(crypto_symbol='BTC', fiat_symbol='USD') == 0.0
|
||||||
|
CryptoToFiatConverter._coinmarketcap = cmc_temp
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_amount(mocker):
|
||||||
|
patch_coinmarketcap(mocker)
|
||||||
|
mocker.patch('freqtrade.fiat_convert.CryptoToFiatConverter.get_price', return_value=12345.0)
|
||||||
|
|
||||||
|
fiat_convert = CryptoToFiatConverter()
|
||||||
|
result = fiat_convert.convert_amount(
|
||||||
|
crypto_amount=1.23,
|
||||||
|
crypto_symbol="BTC",
|
||||||
|
fiat_symbol="USD"
|
||||||
|
)
|
||||||
|
assert result == 15184.35
|
||||||
|
|
||||||
|
result = fiat_convert.convert_amount(
|
||||||
|
crypto_amount=1.23,
|
||||||
|
crypto_symbol="BTC",
|
||||||
|
fiat_symbol="BTC"
|
||||||
|
)
|
||||||
|
assert result == 1.23
|
||||||
1929
freqtrade/tests/test_freqtradebot.py
Normal file
1929
freqtrade/tests/test_freqtradebot.py
Normal file
File diff suppressed because it is too large
Load Diff
13
freqtrade/tests/test_indicator_helpers.py
Normal file
13
freqtrade/tests/test_indicator_helpers.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
from freqtrade.indicator_helpers import went_up, went_down
|
||||||
|
|
||||||
|
|
||||||
|
def test_went_up():
|
||||||
|
series = pd.Series([1, 2, 3, 1])
|
||||||
|
assert went_up(series).equals(pd.Series([False, True, True, False]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_went_down():
|
||||||
|
series = pd.Series([1, 2, 3, 1])
|
||||||
|
assert went_down(series).equals(pd.Series([False, False, False, True]))
|
||||||
@@ -1,126 +1,217 @@
|
|||||||
# pragma pylint: disable=missing-docstring
|
"""
|
||||||
import copy
|
Unit test file for main.py
|
||||||
from unittest.mock import MagicMock, call
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from copy import deepcopy
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from jsonschema import validate
|
|
||||||
|
|
||||||
from freqtrade.exchange import Exchanges
|
from freqtrade import OperationalException
|
||||||
from freqtrade.main import create_trade, handle_trade, close_trade_if_fulfilled, init, \
|
from freqtrade.arguments import Arguments
|
||||||
get_target_bid
|
from freqtrade.freqtradebot import FreqtradeBot
|
||||||
from freqtrade.misc import CONF_SCHEMA
|
from freqtrade.main import main, set_loggers, reconfigure
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.state import State
|
||||||
|
from freqtrade.tests.conftest import log_has, patch_exchange
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
def test_parse_args_backtesting(mocker) -> None:
|
||||||
def conf():
|
"""
|
||||||
configuration = {
|
Test that main() can start backtesting and also ensure we can pass some specific arguments
|
||||||
"max_open_trades": 3,
|
further argument parsing is done in test_arguments.py
|
||||||
"stake_currency": "BTC",
|
"""
|
||||||
"stake_amount": 0.05,
|
backtesting_mock = mocker.patch('freqtrade.optimize.backtesting.start', MagicMock())
|
||||||
"dry_run": True,
|
main(['backtesting'])
|
||||||
"minimal_roi": {
|
assert backtesting_mock.call_count == 1
|
||||||
"2880": 0.005,
|
call_args = backtesting_mock.call_args[0][0]
|
||||||
"720": 0.01,
|
assert call_args.config == 'config.json'
|
||||||
"0": 0.02
|
assert call_args.live is False
|
||||||
},
|
assert call_args.loglevel == 20
|
||||||
"bid_strategy": {
|
assert call_args.subparser == 'backtesting'
|
||||||
"ask_last_balance": 0.0
|
assert call_args.func is not None
|
||||||
},
|
assert call_args.ticker_interval is None
|
||||||
"exchange": {
|
|
||||||
"name": "bittrex",
|
|
||||||
"enabled": True,
|
|
||||||
"key": "key",
|
|
||||||
"secret": "secret",
|
|
||||||
"pair_whitelist": [
|
|
||||||
"BTC_ETH",
|
|
||||||
"BTC_TKN",
|
|
||||||
"BTC_TRST",
|
|
||||||
"BTC_SWT",
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"telegram": {
|
|
||||||
"enabled": True,
|
|
||||||
"token": "token",
|
|
||||||
"chat_id": "chat_id"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
validate(configuration, CONF_SCHEMA)
|
|
||||||
return configuration
|
|
||||||
|
|
||||||
def test_create_trade(conf, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
|
||||||
buy_signal = mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=MagicMock(return_value={
|
|
||||||
'bid': 0.07256061,
|
|
||||||
'ask': 0.072661,
|
|
||||||
'last': 0.07256061
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
|
||||||
# Save state of current whitelist
|
|
||||||
whitelist = copy.deepcopy(conf['exchange']['pair_whitelist'])
|
|
||||||
|
|
||||||
init(conf, 'sqlite://')
|
def test_main_start_hyperopt(mocker) -> None:
|
||||||
for pair in ['BTC_ETH', 'BTC_TKN', 'BTC_TRST', 'BTC_SWT']:
|
"""
|
||||||
trade = create_trade(15.0)
|
Test that main() can start hyperopt
|
||||||
Trade.session.add(trade)
|
"""
|
||||||
Trade.session.flush()
|
hyperopt_mock = mocker.patch('freqtrade.optimize.hyperopt.start', MagicMock())
|
||||||
assert trade is not None
|
main(['hyperopt'])
|
||||||
assert trade.open_rate == 0.072661
|
assert hyperopt_mock.call_count == 1
|
||||||
assert trade.pair == pair
|
call_args = hyperopt_mock.call_args[0][0]
|
||||||
assert trade.exchange == Exchanges.BITTREX.name
|
assert call_args.config == 'config.json'
|
||||||
assert trade.amount == 206.43811673387373
|
assert call_args.loglevel == 20
|
||||||
assert trade.stake_amount == 15.0
|
assert call_args.subparser == 'hyperopt'
|
||||||
assert trade.is_open
|
assert call_args.func is not None
|
||||||
assert trade.open_date is not None
|
|
||||||
assert whitelist == conf['exchange']['pair_whitelist']
|
|
||||||
|
|
||||||
buy_signal.assert_has_calls(
|
|
||||||
[call('BTC_ETH'), call('BTC_TKN'), call('BTC_TRST'), call('BTC_SWT')]
|
def test_set_loggers() -> None:
|
||||||
|
"""
|
||||||
|
Test set_loggers() update the logger level for third-party libraries
|
||||||
|
"""
|
||||||
|
previous_value1 = logging.getLogger('requests.packages.urllib3').level
|
||||||
|
previous_value2 = logging.getLogger('telegram').level
|
||||||
|
|
||||||
|
set_loggers()
|
||||||
|
|
||||||
|
value1 = logging.getLogger('requests.packages.urllib3').level
|
||||||
|
assert previous_value1 is not value1
|
||||||
|
assert value1 is logging.INFO
|
||||||
|
|
||||||
|
value2 = logging.getLogger('telegram').level
|
||||||
|
assert previous_value2 is not value2
|
||||||
|
assert value2 is logging.INFO
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_fatal_exception(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test main() function
|
||||||
|
In this test we are skipping the while True loop by throwing an exception.
|
||||||
|
"""
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.freqtradebot.FreqtradeBot',
|
||||||
|
_init_modules=MagicMock(),
|
||||||
|
worker=MagicMock(side_effect=Exception),
|
||||||
|
cleanup=MagicMock(),
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.configuration.Configuration._load_config_file',
|
||||||
|
lambda *args, **kwargs: default_conf
|
||||||
|
)
|
||||||
|
mocker.patch('freqtrade.freqtradebot.CryptoToFiatConverter', MagicMock())
|
||||||
|
mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock())
|
||||||
|
|
||||||
|
args = ['-c', 'config.json.example']
|
||||||
|
|
||||||
|
# Test Main + the KeyboardInterrupt exception
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
main(args)
|
||||||
|
assert log_has('Using config: config.json.example ...', caplog.record_tuples)
|
||||||
|
assert log_has('Fatal exception!', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_keyboard_interrupt(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test main() function
|
||||||
|
In this test we are skipping the while True loop by throwing an exception.
|
||||||
|
"""
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.freqtradebot.FreqtradeBot',
|
||||||
|
_init_modules=MagicMock(),
|
||||||
|
worker=MagicMock(side_effect=KeyboardInterrupt),
|
||||||
|
cleanup=MagicMock(),
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.configuration.Configuration._load_config_file',
|
||||||
|
lambda *args, **kwargs: default_conf
|
||||||
|
)
|
||||||
|
mocker.patch('freqtrade.freqtradebot.CryptoToFiatConverter', MagicMock())
|
||||||
|
mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock())
|
||||||
|
|
||||||
|
args = ['-c', 'config.json.example']
|
||||||
|
|
||||||
|
# Test Main + the KeyboardInterrupt exception
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
main(args)
|
||||||
|
assert log_has('Using config: config.json.example ...', caplog.record_tuples)
|
||||||
|
assert log_has('SIGINT received, aborting ...', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_operational_exception(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test main() function
|
||||||
|
In this test we are skipping the while True loop by throwing an exception.
|
||||||
|
"""
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.freqtradebot.FreqtradeBot',
|
||||||
|
_init_modules=MagicMock(),
|
||||||
|
worker=MagicMock(side_effect=OperationalException('Oh snap!')),
|
||||||
|
cleanup=MagicMock(),
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.configuration.Configuration._load_config_file',
|
||||||
|
lambda *args, **kwargs: default_conf
|
||||||
|
)
|
||||||
|
mocker.patch('freqtrade.freqtradebot.CryptoToFiatConverter', MagicMock())
|
||||||
|
mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock())
|
||||||
|
|
||||||
|
args = ['-c', 'config.json.example']
|
||||||
|
|
||||||
|
# Test Main + the KeyboardInterrupt exception
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
main(args)
|
||||||
|
assert log_has('Using config: config.json.example ...', caplog.record_tuples)
|
||||||
|
assert log_has('Oh snap!', caplog.record_tuples)
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_reload_conf(mocker, default_conf, caplog) -> None:
|
||||||
|
"""
|
||||||
|
Test main() function
|
||||||
|
In this test we are skipping the while True loop by throwing an exception.
|
||||||
|
"""
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.freqtradebot.FreqtradeBot',
|
||||||
|
_init_modules=MagicMock(),
|
||||||
|
worker=MagicMock(return_value=State.RELOAD_CONF),
|
||||||
|
cleanup=MagicMock(),
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.configuration.Configuration._load_config_file',
|
||||||
|
lambda *args, **kwargs: default_conf
|
||||||
|
)
|
||||||
|
mocker.patch('freqtrade.freqtradebot.CryptoToFiatConverter', MagicMock())
|
||||||
|
mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock())
|
||||||
|
|
||||||
|
# Raise exception as side effect to avoid endless loop
|
||||||
|
reconfigure_mock = mocker.patch(
|
||||||
|
'freqtrade.main.reconfigure', MagicMock(side_effect=Exception)
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_handle_trade(conf, mocker):
|
with pytest.raises(SystemExit):
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
main(['-c', 'config.json.example'])
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', init=MagicMock(), send_msg=MagicMock())
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=MagicMock(return_value={
|
|
||||||
'bid': 0.17256061,
|
|
||||||
'ask': 0.172661,
|
|
||||||
'last': 0.17256061
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
|
||||||
trade = Trade.query.filter(Trade.is_open.is_(True)).first()
|
|
||||||
assert trade
|
|
||||||
handle_trade(trade)
|
|
||||||
assert trade.close_rate == 0.17256061
|
|
||||||
assert trade.close_profit == 137.4872490056564
|
|
||||||
assert trade.close_date is not None
|
|
||||||
assert trade.open_order_id == 'dry_run'
|
|
||||||
|
|
||||||
def test_close_trade(conf, mocker):
|
assert reconfigure_mock.call_count == 1
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
assert log_has('Using config: config.json.example ...', caplog.record_tuples)
|
||||||
trade = Trade.query.filter(Trade.is_open.is_(True)).first()
|
|
||||||
assert trade
|
|
||||||
|
|
||||||
# Simulate that there is no open order
|
|
||||||
trade.open_order_id = None
|
|
||||||
|
|
||||||
closed = close_trade_if_fulfilled(trade)
|
def test_reconfigure(mocker, default_conf) -> None:
|
||||||
assert closed
|
""" Test recreate() function """
|
||||||
assert not trade.is_open
|
patch_exchange(mocker)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.freqtradebot.FreqtradeBot',
|
||||||
|
_init_modules=MagicMock(),
|
||||||
|
worker=MagicMock(side_effect=OperationalException('Oh snap!')),
|
||||||
|
cleanup=MagicMock(),
|
||||||
|
)
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.configuration.Configuration._load_config_file',
|
||||||
|
lambda *args, **kwargs: default_conf
|
||||||
|
)
|
||||||
|
mocker.patch('freqtrade.freqtradebot.CryptoToFiatConverter', MagicMock())
|
||||||
|
mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock())
|
||||||
|
|
||||||
def test_balance_fully_ask_side(mocker):
|
freqtrade = FreqtradeBot(default_conf)
|
||||||
mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 0.0}})
|
|
||||||
assert get_target_bid({'ask': 20, 'last': 10}) == 20
|
|
||||||
|
|
||||||
def test_balance_fully_last_side(mocker):
|
# Renew mock to return modified data
|
||||||
mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}})
|
conf = deepcopy(default_conf)
|
||||||
assert get_target_bid({'ask': 20, 'last': 10}) == 10
|
conf['stake_amount'] += 1
|
||||||
|
mocker.patch(
|
||||||
|
'freqtrade.configuration.Configuration._load_config_file',
|
||||||
|
lambda *args, **kwargs: conf
|
||||||
|
)
|
||||||
|
|
||||||
def test_balance_when_last_bigger_than_ask(mocker):
|
# reconfigure should return a new instance
|
||||||
mocker.patch.dict('freqtrade.main._CONF', {'bid_strategy': {'ask_last_balance': 1.0}})
|
freqtrade2 = reconfigure(
|
||||||
assert get_target_bid({'ask': 5, 'last': 10}) == 5
|
freqtrade,
|
||||||
|
Arguments(['-c', 'config.json.example'], '').get_parsed_arg()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify we have a new instance with the new config
|
||||||
|
assert freqtrade is not freqtrade2
|
||||||
|
assert freqtrade.config['stake_amount'] + 1 == freqtrade2.config['stake_amount']
|
||||||
|
|||||||
93
freqtrade/tests/test_misc.py
Normal file
93
freqtrade/tests/test_misc.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# pragma pylint: disable=missing-docstring,C0103
|
||||||
|
|
||||||
|
"""
|
||||||
|
Unit test file for misc.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from freqtrade.analyze import Analyze
|
||||||
|
from freqtrade.misc import (shorten_date, datesarray_to_datetimearray,
|
||||||
|
common_datearray, file_dump_json, format_ms_time)
|
||||||
|
from freqtrade.optimize.__init__ import load_tickerdata_file
|
||||||
|
|
||||||
|
|
||||||
|
def test_shorten_date() -> None:
|
||||||
|
"""
|
||||||
|
Test shorten_date() function
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
str_data = '1 day, 2 hours, 3 minutes, 4 seconds ago'
|
||||||
|
str_shorten_data = '1 d, 2 h, 3 min, 4 sec ago'
|
||||||
|
assert shorten_date(str_data) == str_shorten_data
|
||||||
|
|
||||||
|
|
||||||
|
def test_datesarray_to_datetimearray(ticker_history):
|
||||||
|
"""
|
||||||
|
Test datesarray_to_datetimearray() function
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
dataframes = Analyze.parse_ticker_dataframe(ticker_history)
|
||||||
|
dates = datesarray_to_datetimearray(dataframes['date'])
|
||||||
|
|
||||||
|
assert isinstance(dates[0], datetime.datetime)
|
||||||
|
assert dates[0].year == 2017
|
||||||
|
assert dates[0].month == 11
|
||||||
|
assert dates[0].day == 26
|
||||||
|
assert dates[0].hour == 8
|
||||||
|
assert dates[0].minute == 50
|
||||||
|
|
||||||
|
date_len = len(dates)
|
||||||
|
assert date_len == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_common_datearray(default_conf) -> None:
|
||||||
|
"""
|
||||||
|
Test common_datearray()
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
analyze = Analyze(default_conf)
|
||||||
|
tick = load_tickerdata_file(None, 'UNITTEST/BTC', '1m')
|
||||||
|
tickerlist = {'UNITTEST/BTC': tick}
|
||||||
|
dataframes = analyze.tickerdata_to_dataframe(tickerlist)
|
||||||
|
|
||||||
|
dates = common_datearray(dataframes)
|
||||||
|
|
||||||
|
assert dates.size == dataframes['UNITTEST/BTC']['date'].size
|
||||||
|
assert dates[0] == dataframes['UNITTEST/BTC']['date'][0]
|
||||||
|
assert dates[-1] == dataframes['UNITTEST/BTC']['date'][-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_dump_json(mocker) -> None:
|
||||||
|
"""
|
||||||
|
Test file_dump_json()
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
file_open = mocker.patch('freqtrade.misc.open', MagicMock())
|
||||||
|
json_dump = mocker.patch('json.dump', MagicMock())
|
||||||
|
file_dump_json('somefile', [1, 2, 3])
|
||||||
|
assert file_open.call_count == 1
|
||||||
|
assert json_dump.call_count == 1
|
||||||
|
file_open = mocker.patch('freqtrade.misc.gzip.open', MagicMock())
|
||||||
|
json_dump = mocker.patch('json.dump', MagicMock())
|
||||||
|
file_dump_json('somefile', [1, 2, 3], True)
|
||||||
|
assert file_open.call_count == 1
|
||||||
|
assert json_dump.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_ms_time() -> None:
|
||||||
|
"""
|
||||||
|
test format_ms_time()
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
# Date 2018-04-10 18:02:01
|
||||||
|
date_in_epoch_ms = 1523383321000
|
||||||
|
date = format_ms_time(date_in_epoch_ms)
|
||||||
|
assert type(date) is str
|
||||||
|
res = datetime.datetime(2018, 4, 10, 18, 2, 1, tzinfo=datetime.timezone.utc)
|
||||||
|
assert date == res.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S')
|
||||||
|
res = datetime.datetime(2017, 12, 13, 8, 2, 1, tzinfo=datetime.timezone.utc)
|
||||||
|
# Date 2017-12-13 08:02:01
|
||||||
|
date_in_epoch_ms = 1513152121000
|
||||||
|
assert format_ms_time(date_in_epoch_ms) == res.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S')
|
||||||
@@ -1,20 +1,455 @@
|
|||||||
# pragma pylint: disable=missing-docstring
|
# pragma pylint: disable=missing-docstring, C0103
|
||||||
from freqtrade.exchange import Exchanges
|
from copy import deepcopy
|
||||||
from freqtrade.persistence import Trade
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
|
||||||
|
from freqtrade import constants, OperationalException
|
||||||
|
from freqtrade.persistence import Trade, init, clean_dry_run_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope='function')
|
||||||
|
def init_persistence(default_conf):
|
||||||
|
init(default_conf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_create_session(default_conf):
|
||||||
|
# Check if init create a session
|
||||||
|
init(default_conf)
|
||||||
|
assert hasattr(Trade, 'session')
|
||||||
|
assert 'Session' in type(Trade.session).__name__
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_custom_db_url(default_conf, mocker):
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
|
||||||
|
# Update path to a value other than default, but still in-memory
|
||||||
|
conf.update({'db_url': 'sqlite:///tmp/freqtrade2_test.sqlite'})
|
||||||
|
create_engine_mock = mocker.patch('freqtrade.persistence.create_engine', MagicMock())
|
||||||
|
|
||||||
|
init(conf)
|
||||||
|
assert create_engine_mock.call_count == 1
|
||||||
|
assert create_engine_mock.mock_calls[0][1][0] == 'sqlite:///tmp/freqtrade2_test.sqlite'
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_invalid_db_url(default_conf):
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
|
||||||
|
# Update path to a value other than default, but still in-memory
|
||||||
|
conf.update({'db_url': 'unknown:///some.url'})
|
||||||
|
with pytest.raises(OperationalException, match=r'.*no valid database URL*'):
|
||||||
|
init(conf)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_prod_db(default_conf, mocker):
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf.update({'dry_run': False})
|
||||||
|
conf.update({'db_url': constants.DEFAULT_DB_PROD_URL})
|
||||||
|
|
||||||
|
create_engine_mock = mocker.patch('freqtrade.persistence.create_engine', MagicMock())
|
||||||
|
|
||||||
|
init(conf)
|
||||||
|
assert create_engine_mock.call_count == 1
|
||||||
|
assert create_engine_mock.mock_calls[0][1][0] == 'sqlite:///tradesv3.sqlite'
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_dryrun_db(default_conf, mocker):
|
||||||
|
conf = deepcopy(default_conf)
|
||||||
|
conf.update({'dry_run': True})
|
||||||
|
conf.update({'db_url': constants.DEFAULT_DB_DRYRUN_URL})
|
||||||
|
|
||||||
|
create_engine_mock = mocker.patch('freqtrade.persistence.create_engine', MagicMock())
|
||||||
|
|
||||||
|
init(conf)
|
||||||
|
assert create_engine_mock.call_count == 1
|
||||||
|
assert create_engine_mock.mock_calls[0][1][0] == 'sqlite://'
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_update_with_bittrex(limit_buy_order, limit_sell_order, fee):
|
||||||
|
"""
|
||||||
|
On this test we will buy and sell a crypto currency.
|
||||||
|
|
||||||
|
Buy
|
||||||
|
- Buy: 90.99181073 Crypto at 0.00001099 BTC
|
||||||
|
(90.99181073*0.00001099 = 0.0009999 BTC)
|
||||||
|
- Buying fee: 0.25%
|
||||||
|
- Total cost of buy trade: 0.001002500 BTC
|
||||||
|
((90.99181073*0.00001099) + ((90.99181073*0.00001099)*0.0025))
|
||||||
|
|
||||||
|
Sell
|
||||||
|
- Sell: 90.99181073 Crypto at 0.00001173 BTC
|
||||||
|
(90.99181073*0.00001173 = 0,00106733394 BTC)
|
||||||
|
- Selling fee: 0.25%
|
||||||
|
- Total cost of sell trade: 0.001064666 BTC
|
||||||
|
((90.99181073*0.00001173) - ((90.99181073*0.00001173)*0.0025))
|
||||||
|
|
||||||
|
Profit/Loss: +0.000062166 BTC
|
||||||
|
(Sell:0.001064666 - Buy:0.001002500)
|
||||||
|
Profit/Loss percentage: 0.0620
|
||||||
|
((0.001064666/0.001002500)-1 = 6.20%)
|
||||||
|
|
||||||
|
:param limit_buy_order:
|
||||||
|
:param limit_sell_order:
|
||||||
|
:return:
|
||||||
|
"""
|
||||||
|
|
||||||
def test_exec_sell_order(mocker):
|
|
||||||
api_mock = mocker.patch('freqtrade.main.exchange.sell', side_effect='mocked_order_id')
|
|
||||||
trade = Trade(
|
trade = Trade(
|
||||||
pair='BTC_ETH',
|
pair='ETH/BTC',
|
||||||
stake_amount=1.00,
|
stake_amount=0.001,
|
||||||
open_rate=0.50,
|
fee_open=fee.return_value,
|
||||||
amount=10.00,
|
fee_close=fee.return_value,
|
||||||
exchange=Exchanges.BITTREX,
|
exchange='bittrex',
|
||||||
open_order_id='mocked'
|
|
||||||
)
|
)
|
||||||
profit = trade.exec_sell_order(1.00, 10.00)
|
assert trade.open_order_id is None
|
||||||
api_mock.assert_called_once_with('BTC_ETH', 1.0, 10.0)
|
assert trade.open_rate is None
|
||||||
assert profit == 100.0
|
assert trade.close_profit is None
|
||||||
assert trade.close_rate == 1.0
|
assert trade.close_date is None
|
||||||
assert trade.close_profit == profit
|
|
||||||
|
trade.open_order_id = 'something'
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
assert trade.open_order_id is None
|
||||||
|
assert trade.open_rate == 0.00001099
|
||||||
|
assert trade.close_profit is None
|
||||||
|
assert trade.close_date is None
|
||||||
|
|
||||||
|
trade.open_order_id = 'something'
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
assert trade.open_order_id is None
|
||||||
|
assert trade.close_rate == 0.00001173
|
||||||
|
assert trade.close_profit == 0.06201057
|
||||||
assert trade.close_date is not None
|
assert trade.close_date is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_calc_open_close_trade_price(limit_buy_order, limit_sell_order, fee):
|
||||||
|
trade = Trade(
|
||||||
|
pair='ETH/BTC',
|
||||||
|
stake_amount=0.001,
|
||||||
|
fee_open=fee.return_value,
|
||||||
|
fee_close=fee.return_value,
|
||||||
|
exchange='bittrex',
|
||||||
|
)
|
||||||
|
|
||||||
|
trade.open_order_id = 'something'
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
assert trade.calc_open_trade_price() == 0.001002500
|
||||||
|
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
assert trade.calc_close_trade_price() == 0.0010646656
|
||||||
|
|
||||||
|
# Profit in BTC
|
||||||
|
assert trade.calc_profit() == 0.00006217
|
||||||
|
|
||||||
|
# Profit in percent
|
||||||
|
assert trade.calc_profit_percent() == 0.06201057
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_calc_close_trade_price_exception(limit_buy_order, fee):
|
||||||
|
trade = Trade(
|
||||||
|
pair='ETH/BTC',
|
||||||
|
stake_amount=0.001,
|
||||||
|
fee_open=fee.return_value,
|
||||||
|
fee_close=fee.return_value,
|
||||||
|
exchange='bittrex',
|
||||||
|
)
|
||||||
|
|
||||||
|
trade.open_order_id = 'something'
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
assert trade.calc_close_trade_price() == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_update_open_order(limit_buy_order):
|
||||||
|
trade = Trade(
|
||||||
|
pair='ETH/BTC',
|
||||||
|
stake_amount=1.00,
|
||||||
|
fee_open=0.1,
|
||||||
|
fee_close=0.1,
|
||||||
|
exchange='bittrex',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert trade.open_order_id is None
|
||||||
|
assert trade.open_rate is None
|
||||||
|
assert trade.close_profit is None
|
||||||
|
assert trade.close_date is None
|
||||||
|
|
||||||
|
limit_buy_order['status'] = 'open'
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
|
||||||
|
assert trade.open_order_id is None
|
||||||
|
assert trade.open_rate is None
|
||||||
|
assert trade.close_profit is None
|
||||||
|
assert trade.close_date is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_update_invalid_order(limit_buy_order):
|
||||||
|
trade = Trade(
|
||||||
|
pair='ETH/BTC',
|
||||||
|
stake_amount=1.00,
|
||||||
|
fee_open=0.1,
|
||||||
|
fee_close=0.1,
|
||||||
|
exchange='bittrex',
|
||||||
|
)
|
||||||
|
limit_buy_order['type'] = 'invalid'
|
||||||
|
with pytest.raises(ValueError, match=r'Unknown order type'):
|
||||||
|
trade.update(limit_buy_order)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_calc_open_trade_price(limit_buy_order, fee):
|
||||||
|
trade = Trade(
|
||||||
|
pair='ETH/BTC',
|
||||||
|
stake_amount=0.001,
|
||||||
|
fee_open=fee.return_value,
|
||||||
|
fee_close=fee.return_value,
|
||||||
|
exchange='bittrex',
|
||||||
|
)
|
||||||
|
trade.open_order_id = 'open_trade'
|
||||||
|
trade.update(limit_buy_order) # Buy @ 0.00001099
|
||||||
|
|
||||||
|
# Get the open rate price with the standard fee rate
|
||||||
|
assert trade.calc_open_trade_price() == 0.001002500
|
||||||
|
|
||||||
|
# Get the open rate price with a custom fee rate
|
||||||
|
assert trade.calc_open_trade_price(fee=0.003) == 0.001003000
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_calc_close_trade_price(limit_buy_order, limit_sell_order, fee):
|
||||||
|
trade = Trade(
|
||||||
|
pair='ETH/BTC',
|
||||||
|
stake_amount=0.001,
|
||||||
|
fee_open=fee.return_value,
|
||||||
|
fee_close=fee.return_value,
|
||||||
|
exchange='bittrex',
|
||||||
|
)
|
||||||
|
trade.open_order_id = 'close_trade'
|
||||||
|
trade.update(limit_buy_order) # Buy @ 0.00001099
|
||||||
|
|
||||||
|
# Get the close rate price with a custom close rate and a regular fee rate
|
||||||
|
assert trade.calc_close_trade_price(rate=0.00001234) == 0.0011200318
|
||||||
|
|
||||||
|
# Get the close rate price with a custom close rate and a custom fee rate
|
||||||
|
assert trade.calc_close_trade_price(rate=0.00001234, fee=0.003) == 0.0011194704
|
||||||
|
|
||||||
|
# Test when we apply a Sell order, and ask price with a custom fee rate
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
assert trade.calc_close_trade_price(fee=0.005) == 0.0010619972
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_calc_profit(limit_buy_order, limit_sell_order, fee):
|
||||||
|
trade = Trade(
|
||||||
|
pair='ETH/BTC',
|
||||||
|
stake_amount=0.001,
|
||||||
|
fee_open=fee.return_value,
|
||||||
|
fee_close=fee.return_value,
|
||||||
|
exchange='bittrex',
|
||||||
|
)
|
||||||
|
trade.open_order_id = 'profit_percent'
|
||||||
|
trade.update(limit_buy_order) # Buy @ 0.00001099
|
||||||
|
|
||||||
|
# Custom closing rate and regular fee rate
|
||||||
|
# Higher than open rate
|
||||||
|
assert trade.calc_profit(rate=0.00001234) == 0.00011753
|
||||||
|
# Lower than open rate
|
||||||
|
assert trade.calc_profit(rate=0.00000123) == -0.00089086
|
||||||
|
|
||||||
|
# Custom closing rate and custom fee rate
|
||||||
|
# Higher than open rate
|
||||||
|
assert trade.calc_profit(rate=0.00001234, fee=0.003) == 0.00011697
|
||||||
|
# Lower than open rate
|
||||||
|
assert trade.calc_profit(rate=0.00000123, fee=0.003) == -0.00089092
|
||||||
|
|
||||||
|
# Test when we apply a Sell order. Sell higher than open rate @ 0.00001173
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
assert trade.calc_profit() == 0.00006217
|
||||||
|
|
||||||
|
# Test with a custom fee rate on the close trade
|
||||||
|
assert trade.calc_profit(fee=0.003) == 0.00006163
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
|
def test_calc_profit_percent(limit_buy_order, limit_sell_order, fee):
|
||||||
|
trade = Trade(
|
||||||
|
pair='ETH/BTC',
|
||||||
|
stake_amount=0.001,
|
||||||
|
fee_open=fee.return_value,
|
||||||
|
fee_close=fee.return_value,
|
||||||
|
exchange='bittrex',
|
||||||
|
)
|
||||||
|
trade.open_order_id = 'profit_percent'
|
||||||
|
trade.update(limit_buy_order) # Buy @ 0.00001099
|
||||||
|
|
||||||
|
# Get percent of profit with a custom rate (Higher than open rate)
|
||||||
|
assert trade.calc_profit_percent(rate=0.00001234) == 0.1172387
|
||||||
|
|
||||||
|
# Get percent of profit with a custom rate (Lower than open rate)
|
||||||
|
assert trade.calc_profit_percent(rate=0.00000123) == -0.88863827
|
||||||
|
|
||||||
|
# Test when we apply a Sell order. Sell higher than open rate @ 0.00001173
|
||||||
|
trade.update(limit_sell_order)
|
||||||
|
assert trade.calc_profit_percent() == 0.06201057
|
||||||
|
|
||||||
|
# Test with a custom fee rate on the close trade
|
||||||
|
assert trade.calc_profit_percent(fee=0.003) == 0.0614782
|
||||||
|
|
||||||
|
|
||||||
|
def test_clean_dry_run_db(default_conf, fee):
|
||||||
|
init(default_conf)
|
||||||
|
|
||||||
|
# Simulate dry_run entries
|
||||||
|
trade = Trade(
|
||||||
|
pair='ETH/BTC',
|
||||||
|
stake_amount=0.001,
|
||||||
|
amount=123.0,
|
||||||
|
fee_open=fee.return_value,
|
||||||
|
fee_close=fee.return_value,
|
||||||
|
open_rate=0.123,
|
||||||
|
exchange='bittrex',
|
||||||
|
open_order_id='dry_run_buy_12345'
|
||||||
|
)
|
||||||
|
Trade.session.add(trade)
|
||||||
|
|
||||||
|
trade = Trade(
|
||||||
|
pair='ETC/BTC',
|
||||||
|
stake_amount=0.001,
|
||||||
|
amount=123.0,
|
||||||
|
fee_open=fee.return_value,
|
||||||
|
fee_close=fee.return_value,
|
||||||
|
open_rate=0.123,
|
||||||
|
exchange='bittrex',
|
||||||
|
open_order_id='dry_run_sell_12345'
|
||||||
|
)
|
||||||
|
Trade.session.add(trade)
|
||||||
|
|
||||||
|
# Simulate prod entry
|
||||||
|
trade = Trade(
|
||||||
|
pair='ETC/BTC',
|
||||||
|
stake_amount=0.001,
|
||||||
|
amount=123.0,
|
||||||
|
fee_open=fee.return_value,
|
||||||
|
fee_close=fee.return_value,
|
||||||
|
open_rate=0.123,
|
||||||
|
exchange='bittrex',
|
||||||
|
open_order_id='prod_buy_12345'
|
||||||
|
)
|
||||||
|
Trade.session.add(trade)
|
||||||
|
|
||||||
|
# We have 3 entries: 2 dry_run, 1 prod
|
||||||
|
assert len(Trade.query.filter(Trade.open_order_id.isnot(None)).all()) == 3
|
||||||
|
|
||||||
|
clean_dry_run_db()
|
||||||
|
|
||||||
|
# We have now only the prod
|
||||||
|
assert len(Trade.query.filter(Trade.open_order_id.isnot(None)).all()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_old(mocker, default_conf, fee):
|
||||||
|
"""
|
||||||
|
Test Database migration(starting with old pairformat)
|
||||||
|
"""
|
||||||
|
amount = 103.223
|
||||||
|
create_table_old = """CREATE TABLE IF NOT EXISTS "trades" (
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
exchange VARCHAR NOT NULL,
|
||||||
|
pair VARCHAR NOT NULL,
|
||||||
|
is_open BOOLEAN NOT NULL,
|
||||||
|
fee FLOAT NOT NULL,
|
||||||
|
open_rate FLOAT,
|
||||||
|
close_rate FLOAT,
|
||||||
|
close_profit FLOAT,
|
||||||
|
stake_amount FLOAT NOT NULL,
|
||||||
|
amount FLOAT,
|
||||||
|
open_date DATETIME NOT NULL,
|
||||||
|
close_date DATETIME,
|
||||||
|
open_order_id VARCHAR,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CHECK (is_open IN (0, 1))
|
||||||
|
);"""
|
||||||
|
insert_table_old = """INSERT INTO trades (exchange, pair, is_open, fee,
|
||||||
|
open_rate, stake_amount, amount, open_date)
|
||||||
|
VALUES ('BITTREX', 'BTC_ETC', 1, {fee},
|
||||||
|
0.00258580, {stake}, {amount},
|
||||||
|
'2017-11-28 12:44:24.000000')
|
||||||
|
""".format(fee=fee.return_value,
|
||||||
|
stake=default_conf.get("stake_amount"),
|
||||||
|
amount=amount
|
||||||
|
)
|
||||||
|
engine = create_engine('sqlite://')
|
||||||
|
mocker.patch('freqtrade.persistence.create_engine', lambda *args, **kwargs: engine)
|
||||||
|
|
||||||
|
# Create table using the old format
|
||||||
|
engine.execute(create_table_old)
|
||||||
|
engine.execute(insert_table_old)
|
||||||
|
# Run init to test migration
|
||||||
|
init(default_conf)
|
||||||
|
|
||||||
|
assert len(Trade.query.filter(Trade.id == 1).all()) == 1
|
||||||
|
trade = Trade.query.filter(Trade.id == 1).first()
|
||||||
|
assert trade.fee_open == fee.return_value
|
||||||
|
assert trade.fee_close == fee.return_value
|
||||||
|
assert trade.open_rate_requested is None
|
||||||
|
assert trade.close_rate_requested is None
|
||||||
|
assert trade.is_open == 1
|
||||||
|
assert trade.amount == amount
|
||||||
|
assert trade.stake_amount == default_conf.get("stake_amount")
|
||||||
|
assert trade.pair == "ETC/BTC"
|
||||||
|
assert trade.exchange == "bittrex"
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrate_new(mocker, default_conf, fee):
|
||||||
|
"""
|
||||||
|
Test Database migration (starting with new pairformat)
|
||||||
|
"""
|
||||||
|
amount = 103.223
|
||||||
|
create_table_old = """CREATE TABLE IF NOT EXISTS "trades" (
|
||||||
|
id INTEGER NOT NULL,
|
||||||
|
exchange VARCHAR NOT NULL,
|
||||||
|
pair VARCHAR NOT NULL,
|
||||||
|
is_open BOOLEAN NOT NULL,
|
||||||
|
fee FLOAT NOT NULL,
|
||||||
|
open_rate FLOAT,
|
||||||
|
close_rate FLOAT,
|
||||||
|
close_profit FLOAT,
|
||||||
|
stake_amount FLOAT NOT NULL,
|
||||||
|
amount FLOAT,
|
||||||
|
open_date DATETIME NOT NULL,
|
||||||
|
close_date DATETIME,
|
||||||
|
open_order_id VARCHAR,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
CHECK (is_open IN (0, 1))
|
||||||
|
);"""
|
||||||
|
insert_table_old = """INSERT INTO trades (exchange, pair, is_open, fee,
|
||||||
|
open_rate, stake_amount, amount, open_date)
|
||||||
|
VALUES ('binance', 'ETC/BTC', 1, {fee},
|
||||||
|
0.00258580, {stake}, {amount},
|
||||||
|
'2019-11-28 12:44:24.000000')
|
||||||
|
""".format(fee=fee.return_value,
|
||||||
|
stake=default_conf.get("stake_amount"),
|
||||||
|
amount=amount
|
||||||
|
)
|
||||||
|
engine = create_engine('sqlite://')
|
||||||
|
mocker.patch('freqtrade.persistence.create_engine', lambda *args, **kwargs: engine)
|
||||||
|
|
||||||
|
# Create table using the old format
|
||||||
|
engine.execute(create_table_old)
|
||||||
|
engine.execute(insert_table_old)
|
||||||
|
# Run init to test migration
|
||||||
|
init(default_conf)
|
||||||
|
|
||||||
|
assert len(Trade.query.filter(Trade.id == 1).all()) == 1
|
||||||
|
trade = Trade.query.filter(Trade.id == 1).first()
|
||||||
|
assert trade.fee_open == fee.return_value
|
||||||
|
assert trade.fee_close == fee.return_value
|
||||||
|
assert trade.open_rate_requested is None
|
||||||
|
assert trade.close_rate_requested is None
|
||||||
|
assert trade.is_open == 1
|
||||||
|
assert trade.amount == amount
|
||||||
|
assert trade.stake_amount == default_conf.get("stake_amount")
|
||||||
|
assert trade.pair == "ETC/BTC"
|
||||||
|
assert trade.exchange == "binance"
|
||||||
|
|||||||
14
freqtrade/tests/test_state.py
Normal file
14
freqtrade/tests/test_state.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
"""
|
||||||
|
Unit test file for constants.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
from freqtrade.state import State
|
||||||
|
|
||||||
|
|
||||||
|
def test_state_object() -> None:
|
||||||
|
"""
|
||||||
|
Test the State object has the mandatory states
|
||||||
|
:return: None
|
||||||
|
"""
|
||||||
|
assert hasattr(State, 'RUNNING')
|
||||||
|
assert hasattr(State, 'STOPPED')
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
# pragma pylint: disable=missing-docstring
|
|
||||||
from datetime import datetime
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from jsonschema import validate
|
|
||||||
from telegram import Bot, Update, Message, Chat
|
|
||||||
|
|
||||||
from freqtrade.main import init, create_trade
|
|
||||||
from freqtrade.misc import update_state, State, get_state, CONF_SCHEMA
|
|
||||||
from freqtrade.persistence import Trade
|
|
||||||
from freqtrade.rpc.telegram import _status, _profit, _forcesell, _performance, _start, _stop
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def conf():
|
|
||||||
configuration = {
|
|
||||||
"max_open_trades": 3,
|
|
||||||
"stake_currency": "BTC",
|
|
||||||
"stake_amount": 0.05,
|
|
||||||
"dry_run": True,
|
|
||||||
"minimal_roi": {
|
|
||||||
"2880": 0.005,
|
|
||||||
"720": 0.01,
|
|
||||||
"0": 0.02
|
|
||||||
},
|
|
||||||
"bid_strategy": {
|
|
||||||
"ask_last_balance": 0.0
|
|
||||||
},
|
|
||||||
"exchange": {
|
|
||||||
"name": "bittrex",
|
|
||||||
"enabled": True,
|
|
||||||
"key": "key",
|
|
||||||
"secret": "secret",
|
|
||||||
"pair_whitelist": [
|
|
||||||
"BTC_ETH"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"telegram": {
|
|
||||||
"enabled": True,
|
|
||||||
"token": "token",
|
|
||||||
"chat_id": "0"
|
|
||||||
},
|
|
||||||
"initial_state": "running"
|
|
||||||
}
|
|
||||||
validate(configuration, CONF_SCHEMA)
|
|
||||||
return configuration
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def update():
|
|
||||||
_update = Update(0)
|
|
||||||
_update.message = Message(0, 0, datetime.utcnow(), Chat(0, 0))
|
|
||||||
return _update
|
|
||||||
|
|
||||||
|
|
||||||
class MagicBot(MagicMock, Bot):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def test_status_handle(conf, update, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
|
||||||
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
|
||||||
msg_mock = MagicMock()
|
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=MagicMock(return_value={
|
|
||||||
'bid': 0.07256061,
|
|
||||||
'ask': 0.072661,
|
|
||||||
'last': 0.07256061
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
|
||||||
init(conf, 'sqlite://')
|
|
||||||
|
|
||||||
# Create some test data
|
|
||||||
trade = create_trade(15.0)
|
|
||||||
assert trade
|
|
||||||
Trade.session.add(trade)
|
|
||||||
Trade.session.flush()
|
|
||||||
|
|
||||||
_status(bot=MagicBot(), update=update)
|
|
||||||
assert msg_mock.call_count == 2
|
|
||||||
assert '[BTC_ETH]' in msg_mock.call_args_list[-1][0][0]
|
|
||||||
|
|
||||||
def test_profit_handle(conf, update, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
|
||||||
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
|
||||||
msg_mock = MagicMock()
|
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=MagicMock(return_value={
|
|
||||||
'bid': 0.07256061,
|
|
||||||
'ask': 0.072661,
|
|
||||||
'last': 0.07256061
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
|
||||||
init(conf, 'sqlite://')
|
|
||||||
|
|
||||||
# Create some test data
|
|
||||||
trade = create_trade(15.0)
|
|
||||||
assert trade
|
|
||||||
trade.close_rate = 0.07256061
|
|
||||||
trade.close_profit = 100.00
|
|
||||||
trade.close_date = datetime.utcnow()
|
|
||||||
trade.open_order_id = None
|
|
||||||
trade.is_open = False
|
|
||||||
Trade.session.add(trade)
|
|
||||||
Trade.session.flush()
|
|
||||||
|
|
||||||
_profit(bot=MagicBot(), update=update)
|
|
||||||
assert msg_mock.call_count == 2
|
|
||||||
assert '(100.00%)' in msg_mock.call_args_list[-1][0][0]
|
|
||||||
|
|
||||||
def test_forcesell_handle(conf, update, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
|
||||||
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
|
||||||
msg_mock = MagicMock()
|
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=MagicMock(return_value={
|
|
||||||
'bid': 0.07256061,
|
|
||||||
'ask': 0.072661,
|
|
||||||
'last': 0.07256061
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
|
||||||
init(conf, 'sqlite://')
|
|
||||||
|
|
||||||
# Create some test data
|
|
||||||
trade = create_trade(15.0)
|
|
||||||
assert trade
|
|
||||||
Trade.session.add(trade)
|
|
||||||
Trade.session.flush()
|
|
||||||
|
|
||||||
update.message.text = '/forcesell 1'
|
|
||||||
_forcesell(bot=MagicBot(), update=update)
|
|
||||||
|
|
||||||
assert msg_mock.call_count == 2
|
|
||||||
assert 'Selling [BTC/ETH]' in msg_mock.call_args_list[-1][0][0]
|
|
||||||
assert '0.072561' in msg_mock.call_args_list[-1][0][0]
|
|
||||||
|
|
||||||
def test_performance_handle(conf, update, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
|
||||||
mocker.patch('freqtrade.main.get_buy_signal', side_effect=lambda _: True)
|
|
||||||
msg_mock = MagicMock()
|
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange',
|
|
||||||
validate_pairs=MagicMock(),
|
|
||||||
get_ticker=MagicMock(return_value={
|
|
||||||
'bid': 0.07256061,
|
|
||||||
'ask': 0.072661,
|
|
||||||
'last': 0.07256061
|
|
||||||
}),
|
|
||||||
buy=MagicMock(return_value='mocked_order_id'))
|
|
||||||
init(conf, 'sqlite://')
|
|
||||||
|
|
||||||
# Create some test data
|
|
||||||
trade = create_trade(15.0)
|
|
||||||
assert trade
|
|
||||||
trade.close_rate = 0.07256061
|
|
||||||
trade.close_profit = 100.00
|
|
||||||
trade.close_date = datetime.utcnow()
|
|
||||||
trade.open_order_id = None
|
|
||||||
trade.is_open = False
|
|
||||||
Trade.session.add(trade)
|
|
||||||
Trade.session.flush()
|
|
||||||
|
|
||||||
_performance(bot=MagicBot(), update=update)
|
|
||||||
assert msg_mock.call_count == 2
|
|
||||||
assert 'Performance' in msg_mock.call_args_list[-1][0][0]
|
|
||||||
assert 'BTC_ETH 100.00%' in msg_mock.call_args_list[-1][0][0]
|
|
||||||
|
|
||||||
def test_start_handle(conf, update, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
|
||||||
msg_mock = MagicMock()
|
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange', _CONF=conf, init=MagicMock())
|
|
||||||
init(conf, 'sqlite://')
|
|
||||||
|
|
||||||
update_state(State.STOPPED)
|
|
||||||
assert get_state() == State.STOPPED
|
|
||||||
_start(bot=MagicBot(), update=update)
|
|
||||||
assert get_state() == State.RUNNING
|
|
||||||
assert msg_mock.call_count == 0
|
|
||||||
|
|
||||||
def test_stop_handle(conf, update, mocker):
|
|
||||||
mocker.patch.dict('freqtrade.main._CONF', conf)
|
|
||||||
msg_mock = MagicMock()
|
|
||||||
mocker.patch.multiple('freqtrade.main.telegram', _CONF=conf, init=MagicMock(), send_msg=msg_mock)
|
|
||||||
mocker.patch.multiple('freqtrade.main.exchange', _CONF=conf, init=MagicMock())
|
|
||||||
init(conf, 'sqlite://')
|
|
||||||
|
|
||||||
update_state(State.RUNNING)
|
|
||||||
assert get_state() == State.RUNNING
|
|
||||||
_stop(bot=MagicBot(), update=update)
|
|
||||||
assert get_state() == State.STOPPED
|
|
||||||
assert msg_mock.call_count == 1
|
|
||||||
assert 'Stopping trader' in msg_mock.call_args_list[0][0][0]
|
|
||||||
1
freqtrade/tests/testdata/ADA_BTC-1m.json
vendored
Normal file
1
freqtrade/tests/testdata/ADA_BTC-1m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/ADA_BTC-5m.json
vendored
Normal file
1
freqtrade/tests/testdata/ADA_BTC-5m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/DASH_BTC-1m.json
vendored
Normal file
1
freqtrade/tests/testdata/DASH_BTC-1m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/DASH_BTC-5m.json
vendored
Normal file
1
freqtrade/tests/testdata/DASH_BTC-5m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/ETC_BTC-1m.json
vendored
Normal file
1
freqtrade/tests/testdata/ETC_BTC-1m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/ETC_BTC-5m.json
vendored
Normal file
1
freqtrade/tests/testdata/ETC_BTC-5m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/ETH_BTC-1m.json
vendored
Normal file
1
freqtrade/tests/testdata/ETH_BTC-1m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/ETH_BTC-5m.json
vendored
Normal file
1
freqtrade/tests/testdata/ETH_BTC-5m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/LTC_BTC-1m.json
vendored
Normal file
1
freqtrade/tests/testdata/LTC_BTC-1m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/LTC_BTC-5m.json
vendored
Normal file
1
freqtrade/tests/testdata/LTC_BTC-5m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/NXT_BTC-1m.json
vendored
Normal file
1
freqtrade/tests/testdata/NXT_BTC-1m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/NXT_BTC-5m.json
vendored
Normal file
1
freqtrade/tests/testdata/NXT_BTC-5m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/POWR_BTC-1m.json
vendored
Normal file
1
freqtrade/tests/testdata/POWR_BTC-1m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/POWR_BTC-5m.json
vendored
Normal file
1
freqtrade/tests/testdata/POWR_BTC-5m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/UNITTEST_BTC-1m.json
vendored
Normal file
1
freqtrade/tests/testdata/UNITTEST_BTC-1m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/UNITTEST_BTC-30m.json
vendored
Normal file
1
freqtrade/tests/testdata/UNITTEST_BTC-30m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
freqtrade/tests/testdata/UNITTEST_BTC-5m.json
vendored
Normal file
1
freqtrade/tests/testdata/UNITTEST_BTC-5m.json
vendored
Normal file
File diff suppressed because one or more lines are too long
0
freqtrade/tests/testdata/UNITTEST_BTC-8m.json
vendored
Normal file
0
freqtrade/tests/testdata/UNITTEST_BTC-8m.json
vendored
Normal file
BIN
freqtrade/tests/testdata/UNITTEST_BTC-8m.json.gz
vendored
Normal file
BIN
freqtrade/tests/testdata/UNITTEST_BTC-8m.json.gz
vendored
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user