Compare commits
661 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
a568548192 | ||
|
f9d10a7fad | ||
|
cbc2b00ee6 | ||
|
7883160ce0 | ||
|
018c620057 | ||
|
a0b42c7aa2 | ||
|
8f7b857ae9 | ||
|
5826698c04 | ||
|
e88b022cd4 | ||
|
bfb738f69f | ||
|
3c88b4cf0c | ||
|
00dd8e76ee | ||
|
4b7271df46 | ||
|
3b1b66bee8 | ||
|
42df65d4ec | ||
|
53452c8d64 | ||
|
731eb99713 | ||
|
afd2be06d8 | ||
|
5a4f30d1bd | ||
|
1f9ed0beff | ||
|
02ce0dc02e | ||
|
a2960d8505 | ||
|
2b16606dbc | ||
|
c6a9c0805c | ||
|
fdad14d852 | ||
|
b9a99bd0b7 | ||
|
7b6a0f7a19 | ||
|
0bd621ece8 | ||
|
df04612549 | ||
|
d354f1f84c | ||
|
317487fefc | ||
|
dc8e9bab44 | ||
|
d1cded3532 | ||
|
21b5f56f7d | ||
|
fddacfedaa | ||
|
a24586cd41 | ||
|
6fb5b22a8e | ||
|
dc7bcf5dda | ||
|
db540dc990 | ||
|
e9f451406c | ||
|
874c161f78 | ||
|
508e677d70 | ||
|
1b1216fc87 | ||
|
c13eed2178 | ||
|
d610b6305d | ||
|
a7a25bb285 | ||
|
42bb33811c | ||
|
a32aed2225 | ||
|
3785f04be7 | ||
|
0bbbe2e96c | ||
|
5705ff7f82 | ||
|
60d1e7fc65 | ||
|
95d4a11bb1 | ||
|
eb88c0f71b | ||
|
e60553b8f7 | ||
|
877a0750ce | ||
|
e7bfb4fd5c | ||
|
a77c11c7e0 | ||
|
b043697d70 | ||
|
78a93b6052 | ||
|
3787b747ae | ||
|
64b98989d2 | ||
|
dfd5d3b8b2 | ||
|
7b2e33b0bc | ||
|
30f6dbfc40 | ||
|
acd7f26a9d | ||
|
cd54f1536e | ||
|
35e800a84b | ||
|
7e2e9272cc | ||
|
8ba149a2af | ||
|
1ad41f0efc | ||
|
9c62ffe4f6 | ||
|
5cc6c2afe1 | ||
|
6290fb6d10 | ||
|
067378d7fc | ||
|
0c632555d6 | ||
|
e63ef86e9e | ||
|
ecb93f14b1 | ||
|
5062c17ac0 | ||
|
04c20afece | ||
|
7f8e956b44 | ||
|
22036d69d8 | ||
|
b18e44bc43 | ||
|
1674beed91 | ||
|
03d4002be8 | ||
|
be8accebd8 | ||
|
ca62914794 | ||
|
b1b8167b5e | ||
|
109440a6bf | ||
|
c769e9757d | ||
|
119d4d5204 | ||
|
08803524bd | ||
|
d0adc4ee62 | ||
|
c9cfc246f1 | ||
|
6511b3bec2 | ||
|
d563bfc3d0 | ||
|
6a59103869 | ||
|
be84a028c1 | ||
|
af984bdc0d | ||
|
2e41d80a2c | ||
|
7252cf47fb | ||
|
9f47853661 | ||
|
45c03f1440 | ||
|
a6a041526a | ||
|
1ba9b70afc | ||
|
6d3803fa22 | ||
|
4e2f06fe9c | ||
|
6191288ff9 | ||
|
1d10d2c87c | ||
|
172e018d2d | ||
|
dcf8ad36f9 | ||
|
926b017981 | ||
|
118ae8a3d0 | ||
|
b192c82731 | ||
|
d2dbe8f8d0 | ||
|
535bbd681f | ||
|
85767d0d70 | ||
|
036c2888b4 | ||
|
380e383eee | ||
|
3a60709f16 | ||
|
4bce64b427 | ||
|
5f886e7ffe | ||
|
92d1f2b945 | ||
|
7811a36ae9 | ||
|
5047492f5a | ||
|
36dad186fd | ||
|
2d979b84bf | ||
|
48ff2b3baa | ||
|
8cdb6e0774 | ||
|
39a0cef922 | ||
|
f31fa07b3f | ||
|
548b9e75f3 | ||
|
37ea07a45d | ||
|
5221194318 | ||
|
2893d0b50d | ||
|
94b546228b | ||
|
b8af4bf8fe | ||
|
110a270a0b | ||
|
576d5a5b48 | ||
|
1e43683283 | ||
|
22e395af87 | ||
|
e24c837e1f | ||
|
099a03f190 | ||
|
6d91a5ecbd | ||
|
7d3b80fbde | ||
|
fe33b86308 | ||
|
6b5f63d4d6 | ||
|
ee2a7a968b | ||
|
5eb5029856 | ||
|
ef086d438c | ||
|
c19f3950da | ||
|
0b01fcf047 | ||
|
303b12efd8 | ||
|
b657d2d8de | ||
|
7232324eb7 | ||
|
da73e754b4 | ||
|
8f2425e49f | ||
|
644442e2f9 | ||
|
17d748dd4c | ||
|
c5e0daf2d3 | ||
|
2a3ab1ef61 | ||
|
6b9696057d | ||
|
4cf514e293 | ||
|
131b2d68d8 | ||
|
0477070faa | ||
|
c4a54cc9cd | ||
|
cfaf13c90f | ||
|
82006ff1db | ||
|
22173851d6 | ||
|
2a59ef7311 | ||
|
808cefe526 | ||
|
9bf86bbe27 | ||
|
58fad72778 | ||
|
e08006ea25 | ||
|
4ea79a32e4 | ||
|
1e603985c5 | ||
|
6637dacd7f | ||
|
7ac44380f7 | ||
|
090554f197 | ||
|
f4149ee462 | ||
|
44e616c264 | ||
|
49cecf1cb2 | ||
|
9140679bf4 | ||
|
15698dd1ca | ||
|
f7a1cabe23 | ||
|
c12e5a3b6c | ||
|
6ed237a72a | ||
|
06387478b5 | ||
|
761f7fdefb | ||
|
e84a58de28 | ||
|
a3e045f69d | ||
|
f8faf748df | ||
|
1e6362debf | ||
|
29879bb415 | ||
|
d6482066ef | ||
|
a733a74dd9 | ||
|
a4e1aaa9bd | ||
|
2d45163f8f | ||
|
e95fb7ef3e | ||
|
0058abcc2d | ||
|
5aa683006c | ||
|
64d0c75bbb | ||
|
d96a354a3e | ||
|
479b560549 | ||
|
1a838680e7 | ||
|
2c492abc1e | ||
|
f35c6545c1 | ||
|
b3e36def34 | ||
|
c53122ed02 | ||
|
d10d84adf3 | ||
|
aac6d15a1d | ||
|
faa23949b5 | ||
|
677a14ddde | ||
|
d4ca2e5767 | ||
|
ab3c6a7ee4 | ||
|
bc5adc0188 | ||
|
65526e9803 | ||
|
8a9b70cc49 | ||
|
ab5c1e6c1e | ||
|
15dbdfe130 | ||
|
dbf2226841 | ||
|
7d066d81c1 | ||
|
287e90af8e | ||
|
cafda31869 | ||
|
7aae9565c7 | ||
|
da39ca6650 | ||
|
aea84dc117 | ||
|
326ba46bf8 | ||
|
d1d520769e | ||
|
e7409e74c2 | ||
|
fb3c67d86b | ||
|
571ddceaf6 | ||
|
9ae14f0b56 | ||
|
2ba2144df1 | ||
|
15d5389564 | ||
|
660f474ab8 | ||
|
e062188a18 | ||
|
5d0c2bcb44 | ||
|
138e867a68 | ||
|
9df7014de3 | ||
|
bf8ef58439 | ||
|
b8f29802e5 | ||
|
cbd213bc0a | ||
|
bd1b991448 | ||
|
31211a33fd | ||
|
82e193d9f0 | ||
|
4b9d55dbe2 | ||
|
002226f5fd | ||
|
18168cba7a | ||
|
4a2914d72e | ||
|
396ebebdc1 | ||
|
ed71f777a3 | ||
|
1f26709aca | ||
|
4408f97a00 | ||
|
b6943f3bca | ||
|
12c79967f5 | ||
|
30b27ae736 | ||
|
2e2b1e2470 | ||
|
f7a5b2cb71 | ||
|
6e47d06733 | ||
|
d9347e9900 | ||
|
eb677ad9b6 | ||
|
0fa7986369 | ||
|
7c975df42a | ||
|
aacddf64cf | ||
|
e72c3ec19f | ||
|
78986a0def | ||
|
acf6e94591 | ||
|
1d59a6b7e3 | ||
|
ac71d79364 | ||
|
b8377b9e30 | ||
|
2823da977d | ||
|
45a2298929 | ||
|
381bda1e4a | ||
|
194a5ce3cc | ||
|
e252830229 | ||
|
d3d4894ec5 | ||
|
6d91ceb28c | ||
|
ce7dff405f | ||
|
1aa7c193ba | ||
|
0990e5d472 | ||
|
9ae6bbb8d2 | ||
|
b8413410d1 | ||
|
fd1828c283 | ||
|
cf79cef7ba | ||
|
95c7d48684 | ||
|
12fabba784 | ||
|
013262f7e2 | ||
|
138fd9440a | ||
|
bf62fc9b25 | ||
|
451eca51c8 | ||
|
e67a54f7a9 | ||
|
daee59f4f1 | ||
|
57067ce88d | ||
|
7429f535c1 | ||
|
62df044618 | ||
|
6492e1cd76 | ||
|
4b6f9121ca | ||
|
6613e3757a | ||
|
09db4bcadd | ||
|
51b94889b2 | ||
|
8c79d55739 | ||
|
480ed90a02 | ||
|
821a9d9cdc | ||
|
cc3852daf3 | ||
|
56daafd6b7 | ||
|
01b331ee42 | ||
|
7bef9a9b3e | ||
|
82f0d4d056 | ||
|
bd4014e1e6 | ||
|
a35b0b519a | ||
|
fe5f61694b | ||
|
1505ad451c | ||
|
9ecd7400c8 | ||
|
314a544881 | ||
|
f79decdb9c | ||
|
05046b9eef | ||
|
a43c088448 | ||
|
3d94d7df5c | ||
|
c265f39323 | ||
|
19948a6f89 | ||
|
5dca183b7b | ||
|
bb1d8fb54f | ||
|
3249f9fb98 | ||
|
f3a152a5a2 | ||
|
730d2e3574 | ||
|
d02acb21c2 | ||
|
f4487c7711 | ||
|
748381c5cd | ||
|
e35a1e4a01 | ||
|
a9f14ac119 | ||
|
8ce5536dd8 | ||
|
4e9f0d89af | ||
|
d549905856 | ||
|
a6c7f45545 | ||
|
e9baabce6f | ||
|
f30580e5f2 | ||
|
5fb9511556 | ||
|
62ea1a445e | ||
|
2e537df358 | ||
|
847e8977ca | ||
|
afe46a55f7 | ||
|
d319204dea | ||
|
7c010b3058 | ||
|
ac93eea585 | ||
|
5fffc5033a | ||
|
5525fdae1a | ||
|
3925e8a7e3 | ||
|
a4dbdb549d | ||
|
a6a127f596 | ||
|
407c20412d | ||
|
301b2e8a0f | ||
|
d918d24f08 | ||
|
3c06d31bbf | ||
|
d813fef95b | ||
|
9c9c9f0171 | ||
|
91236c1876 | ||
|
a156101d5c | ||
|
3de843ab2c | ||
|
8d67caafb3 | ||
|
f9a935b9a3 | ||
|
d0dc9e26b0 | ||
|
3a5841bc03 | ||
|
cf41f71f39 | ||
|
f2984e9d0e | ||
|
fa605e6a50 | ||
|
0f51192575 | ||
|
0629dc866d | ||
|
da134d3ad1 | ||
|
4f9af81b50 | ||
|
543a561019 | ||
|
9092596a1f | ||
|
096d2d7313 | ||
|
81b8008047 | ||
|
7073b36421 | ||
|
108f79ad39 | ||
|
d384184784 | ||
|
9fbb9332f9 | ||
|
c403464bb4 | ||
|
3d9f34b064 | ||
|
e4af162f38 | ||
|
5afa975839 | ||
|
6be6515ecc | ||
|
b6ad0f52e9 | ||
|
edd2ea3699 | ||
|
3cdb672ac3 | ||
|
c02497e4b8 | ||
|
2bcfc0c90c | ||
|
d08885ed92 | ||
|
69c00db7cd | ||
|
b96b0f89bd | ||
|
acda9571d1 | ||
|
6c4b261469 | ||
|
eabeb87ceb | ||
|
39184e1f95 | ||
|
270d7ebbf5 | ||
|
062d00e8f2 | ||
|
2b7405470a | ||
|
9becce9897 | ||
|
526ed7fa9a | ||
|
16861db653 | ||
|
6684bff963 | ||
|
caea8967d5 | ||
|
66a479c26a | ||
|
6c0eef94bb | ||
|
9f9e2a8722 | ||
|
93adb436f8 | ||
|
766c69734d | ||
|
320c9ccf90 | ||
|
1e324d208e | ||
|
08cae6f067 | ||
|
7699fde380 | ||
|
ffe69535d8 | ||
|
13bc5c5d8f | ||
|
678be0b773 | ||
|
faa35cb167 | ||
|
13651fd3be | ||
|
c826c9c2b9 | ||
|
814a343ed3 | ||
|
a22e1b6500 | ||
|
33cb9e9002 | ||
|
ffab70d869 | ||
|
7344f88ad5 | ||
|
7cd8448656 | ||
|
8643b20a0e | ||
|
af3d220ffc | ||
|
db3483c827 | ||
|
775b1201d2 | ||
|
e50b07ecb4 | ||
|
fec95277bb | ||
|
94f2c99989 | ||
|
73840e1d91 | ||
|
58b77bd15f | ||
|
438a083602 | ||
|
fbf026ac43 | ||
|
3b7167ab07 | ||
|
26f2db4777 | ||
|
30d293bfec | ||
|
0dc7c389a0 | ||
|
78921824c6 | ||
|
0d00da8dab | ||
|
e0b05c4df2 | ||
|
ef06ede3bd | ||
|
42b5e8dac6 | ||
|
11d74da1e0 | ||
|
fc1069cfe0 | ||
|
1e35f54709 | ||
|
d80216ca05 | ||
|
d2c7ff3f0f | ||
|
d90745651a | ||
|
130275faff | ||
|
29457078e7 | ||
|
8d554585f1 | ||
|
000e29113f | ||
|
8057929817 | ||
|
858a65e308 | ||
|
fe067994e3 | ||
|
d349d2743a | ||
|
2d930d081c | ||
|
96f8338496 | ||
|
7bc50dff7a | ||
|
a4d2cf2f06 | ||
|
af60b9db59 | ||
|
bc95e1e151 | ||
|
626970b32e | ||
|
a0d378fb7e | ||
|
b8e8a31f84 | ||
|
3fc44aa1bd | ||
|
59ffb98779 | ||
|
cbd449f710 | ||
|
91b89c8c42 | ||
|
0424b44667 | ||
|
195d601b8e | ||
|
c929d428b2 | ||
|
0bca07a32a | ||
|
813a2cd23b | ||
|
cf077b15c2 | ||
|
94631c7d64 | ||
|
8e424f7c73 | ||
|
43f8087f32 | ||
|
827b8d3e4c | ||
|
04976658da | ||
|
b82e63cb62 | ||
|
11ace0f867 | ||
|
7f20f6834b | ||
|
cd144cdfc9 | ||
|
e540959c27 | ||
|
1203d08d1e | ||
|
b77943af0d | ||
|
560b3d5dbe | ||
|
d64f9030c1 | ||
|
9a3d0528a3 | ||
|
b3a4ecaf77 | ||
|
28011a3907 | ||
|
72f486289a | ||
|
24ec78b11c | ||
|
326e3d1f8e | ||
|
bb29c44462 | ||
|
7451b60501 | ||
|
a0f9c1bf7b | ||
|
e88a1ab209 | ||
|
addba6597a | ||
|
5451972456 | ||
|
2a2392fd73 | ||
|
33d95d245e | ||
|
a9a6cf13f8 | ||
|
4e2b9203d7 | ||
|
2ca90577a6 | ||
|
2ecaf9f8b4 | ||
|
6abd6bceb9 | ||
|
67e4dda5b3 | ||
|
8373a4e713 | ||
|
4d9b4ddc28 | ||
|
09fae25c94 | ||
|
7a2b50ce8b | ||
|
42579c0268 | ||
|
7bf735dbfc | ||
|
937f5e3d0f | ||
|
7ea5b0e359 | ||
|
15cb3792cf | ||
|
fa620d3f7b | ||
|
7adb7f90a6 | ||
|
5536410ed0 | ||
|
de2a7c1956 | ||
|
079dbc7997 | ||
|
05ac09b38e | ||
|
028636b4a9 | ||
|
8ed30fc9c1 | ||
|
2469dc0424 | ||
|
33991e8de9 | ||
|
5407a06254 | ||
|
616d5bbaed | ||
|
3f4c5a7902 | ||
|
a253ad5ec1 | ||
|
ce1780ca3f | ||
|
6d3747d9e6 | ||
|
0da31cff72 | ||
|
f7d3c50213 | ||
|
6b0a7a81a9 | ||
|
8c0f7321c3 | ||
|
fac6956eeb | ||
|
711a6a6dbc | ||
|
2116b0729f | ||
|
209ecc8732 | ||
|
f3784f2149 | ||
|
08ba5b0451 | ||
|
fb06a673e0 | ||
|
78ba2d3fc7 | ||
|
a2d97eecfe | ||
|
45a02beea8 | ||
|
8b49bec649 | ||
|
c29469decf | ||
|
9becd20f20 | ||
|
713b884d9b | ||
|
515e1040c2 | ||
|
670aed06bf | ||
|
0277d93a64 | ||
|
c9296dc9a0 | ||
|
550a1eef91 | ||
|
880ee016a4 | ||
|
39f8c5719b | ||
|
a715083fc0 | ||
|
78ccaae318 | ||
|
ee774f12bd | ||
|
b1b2eebd11 | ||
|
b63491fb9c | ||
|
1bc2c71757 | ||
|
6b22f84d30 | ||
|
505d4bacd5 | ||
|
5b2a1b9e7a | ||
|
8edc84bf25 | ||
|
bd98637ae9 | ||
|
77afb7b5e2 | ||
|
2b94fbfa74 | ||
|
3d336a736e | ||
|
f965e9177c | ||
|
4b654b2713 | ||
|
093f98d368 | ||
|
2a728c676e | ||
|
05a488a7a0 | ||
|
bb65621134 | ||
|
ef2b326262 | ||
|
54858a0bbb | ||
|
314e10596b | ||
|
53ef37d5fc | ||
|
17f037cec6 | ||
|
f77b8cbb7a | ||
|
bc8fc3ab09 | ||
|
bd5520bee2 | ||
|
099dc07baf | ||
|
817a65b656 | ||
|
045225beef | ||
|
d3f3c49b13 | ||
|
6509c38717 | ||
|
fbaf46901e | ||
|
96fbf63d0b | ||
|
aa54592ec7 | ||
|
ea79eb55e9 | ||
|
3cbb2ff31f | ||
|
e3181748dc | ||
|
f61aaa8c0d | ||
|
ad247b2f07 | ||
|
de79d25caf | ||
|
ac690e9215 | ||
|
0c4664e8f4 | ||
|
bc60139ae3 | ||
|
8393c99b62 | ||
|
8bf1001b33 | ||
|
ace0a83c0c | ||
|
2e23e88fc1 | ||
|
d70ddeef9a | ||
|
e439ae1fea | ||
|
da2e07b7fe | ||
|
76e7bf6cd2 | ||
|
7df3e7ada4 | ||
|
fa01cbf546 | ||
|
4862cdb296 | ||
|
c9243fb4f6 | ||
|
f6d36ce56b | ||
|
d9f5694965 | ||
|
b8b5e93000 | ||
|
f28d95ffb5 | ||
|
5da38f3613 | ||
|
3aca3a7133 | ||
|
1eb83f9a62 | ||
|
db2f0660fa | ||
|
b094430c26 | ||
|
30673f84f9 | ||
|
cc28f73d7f | ||
|
d10fb95fce | ||
|
cea023399e | ||
|
462270bc5a | ||
|
337af44901 | ||
|
7200659b35 | ||
|
a7c67e8c7c | ||
|
9be29c6e92 | ||
|
468076cf54 | ||
|
d4b31263ca | ||
|
6f6e7467f5 | ||
|
1362bd9626 | ||
|
2c3e5fa080 | ||
|
1017b68af9 | ||
|
98255c18cf | ||
|
c6256aba35 | ||
|
8dacd987b9 | ||
|
64558e60d3 | ||
|
2e13893341 | ||
|
9176e2f1f6 | ||
|
71147d2899 | ||
|
7d42f42405 | ||
|
f11a40f144 | ||
|
f97662e816 | ||
|
b7bf3247b8 | ||
|
1e3fc5e984 | ||
|
c179951cca | ||
|
b2c2852f86 | ||
|
00366c5c88 | ||
|
28d0b5165a | ||
|
fde6779873 | ||
|
88792852e4 | ||
|
fd875786fd |
3
.github/FUNDING.yml
vendored
Normal file
3
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [xmatthias]
|
25
.github/workflows/ci.yml
vendored
25
.github/workflows/ci.yml
vendored
@@ -3,9 +3,9 @@ name: Freqtrade CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- stable
|
||||
- develop
|
||||
- ci/*
|
||||
tags:
|
||||
release:
|
||||
types: [published]
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ ubuntu-18.04, ubuntu-20.04 ]
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
- name: pip cache (linux)
|
||||
uses: actions/cache@v2
|
||||
if: startsWith(matrix.os, 'ubuntu')
|
||||
if: runner.os == 'Linux'
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: test-${{ matrix.os }}-${{ matrix.python-version }}-pip
|
||||
@@ -50,8 +50,9 @@ jobs:
|
||||
cd build_helpers && ./install_ta-lib.sh ${HOME}/dependencies/; cd ..
|
||||
|
||||
- name: Installation - *nix
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --upgrade pip wheel
|
||||
export LD_LIBRARY_PATH=${HOME}/dependencies/lib:$LD_LIBRARY_PATH
|
||||
export TA_LIBRARY_PATH=${HOME}/dependencies/lib
|
||||
export TA_INCLUDE_PATH=${HOME}/dependencies/include
|
||||
@@ -69,7 +70,7 @@ jobs:
|
||||
if: matrix.python-version == '3.9'
|
||||
|
||||
- name: Coveralls
|
||||
if: (startsWith(matrix.os, 'ubuntu-20') && matrix.python-version == '3.8')
|
||||
if: (runner.os == 'Linux' && matrix.python-version == '3.8')
|
||||
env:
|
||||
# Coveralls token. Not used as secret due to github not providing secrets to forked repositories
|
||||
COVERALLS_REPO_TOKEN: 6D1m0xupS3FgutfuGao8keFf9Hc0FpIXu
|
||||
@@ -114,7 +115,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ macos-latest ]
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -133,7 +134,7 @@ jobs:
|
||||
|
||||
- name: pip cache (macOS)
|
||||
uses: actions/cache@v2
|
||||
if: startsWith(matrix.os, 'macOS')
|
||||
if: runner.os == 'macOS'
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
key: test-${{ matrix.os }}-${{ matrix.python-version }}-pip
|
||||
@@ -144,10 +145,11 @@ jobs:
|
||||
cd build_helpers && ./install_ta-lib.sh ${HOME}/dependencies/; cd ..
|
||||
|
||||
- name: Installation - macOS
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
brew update
|
||||
brew install hdf5 c-blosc
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --upgrade pip wheel
|
||||
export LD_LIBRARY_PATH=${HOME}/dependencies/lib:$LD_LIBRARY_PATH
|
||||
export TA_LIBRARY_PATH=${HOME}/dependencies/lib
|
||||
export TA_INCLUDE_PATH=${HOME}/dependencies/include
|
||||
@@ -159,7 +161,7 @@ jobs:
|
||||
pytest --random-order --cov=freqtrade --cov-config=.coveragerc
|
||||
|
||||
- name: Coveralls
|
||||
if: (startsWith(matrix.os, 'ubuntu-20') && matrix.python-version == '3.8')
|
||||
if: (runner.os == 'Linux' && matrix.python-version == '3.8')
|
||||
env:
|
||||
# Coveralls token. Not used as secret due to github not providing secrets to forked repositories
|
||||
COVERALLS_REPO_TOKEN: 6D1m0xupS3FgutfuGao8keFf9Hc0FpIXu
|
||||
@@ -195,7 +197,7 @@ jobs:
|
||||
uses: rjstone/discord-webhook-notify@v1
|
||||
if: failure() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
|
||||
with:
|
||||
severity: error
|
||||
severity: info
|
||||
details: Test Succeeded!
|
||||
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
@@ -205,7 +207,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ windows-latest ]
|
||||
python-version: [3.7, 3.8]
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
@@ -217,7 +219,6 @@ jobs:
|
||||
|
||||
- name: Pip cache (Windows)
|
||||
uses: actions/cache@preview
|
||||
if: startsWith(runner.os, 'Windows')
|
||||
with:
|
||||
path: ~\AppData\Local\pip\Cache
|
||||
key: ${{ matrix.os }}-${{ matrix.python-version }}-pip
|
||||
|
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
# Freqtrade rules
|
||||
config*.json
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
logfile.txt
|
||||
user_data/*
|
||||
!user_data/strategy/sample_strategy.py
|
||||
@@ -10,6 +12,9 @@ freqtrade-plot.html
|
||||
freqtrade-profit-plot.html
|
||||
freqtrade/rpc/api_server/ui/*
|
||||
|
||||
# Macos related
|
||||
.DS_Store
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
16
README.md
16
README.md
@@ -5,10 +5,14 @@
|
||||
[](https://www.freqtrade.io)
|
||||
[](https://codeclimate.com/github/freqtrade/freqtrade/maintainability)
|
||||
|
||||
Freqtrade is a free and open source crypto trading bot written in Python. It is designed to support all major exchanges and be controlled via Telegram. It contains backtesting, plotting and money management tools as well as strategy optimization by machine learning.
|
||||
Freqtrade is a free and open source crypto trading bot written in Python. It is designed to support all major exchanges and be controlled via Telegram or webUI. It contains backtesting, plotting and money management tools as well as strategy optimization by machine learning.
|
||||
|
||||

|
||||
|
||||
## Sponsored promotion
|
||||
|
||||
[](https://tokenbot.com/?utm_source=github&utm_medium=freqtrade&utm_campaign=algodevs)
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This software is for educational purposes only. Do not risk money which
|
||||
@@ -31,7 +35,7 @@ Please read the [exchange specific notes](docs/exchanges.md) to learn about even
|
||||
- [X] [FTX](https://ftx.com)
|
||||
- [X] [Gate.io](https://www.gate.io/ref/6266643)
|
||||
- [X] [Kraken](https://kraken.com/)
|
||||
- [X] [OKEX](https://www.okex.com/)
|
||||
- [X] [OKX](https://www.okx.com/)
|
||||
- [ ] [potentially many others](https://github.com/ccxt/ccxt/). _(We cannot guarantee they will work)_
|
||||
|
||||
### Community tested
|
||||
@@ -49,7 +53,7 @@ Please find the complete documentation on the [freqtrade website](https://www.fr
|
||||
|
||||
## Features
|
||||
|
||||
- [x] **Based on Python 3.7+**: For botting on any operating system - Windows, macOS and Linux.
|
||||
- [x] **Based on Python 3.8+**: For botting on any operating system - Windows, macOS and Linux.
|
||||
- [x] **Persistence**: Persistence is achieved through sqlite.
|
||||
- [x] **Dry-run**: Run the bot without paying money.
|
||||
- [x] **Backtesting**: Run a simulation of your buy/sell strategy.
|
||||
@@ -57,9 +61,9 @@ Please find the complete documentation on the [freqtrade website](https://www.fr
|
||||
- [x] **Edge position sizing** Calculate your win rate, risk reward ratio, the best stoploss and adjust your position size before taking a position for each specific market. [Learn more](https://www.freqtrade.io/en/stable/edge/).
|
||||
- [x] **Whitelist crypto-currencies**: Select which crypto-currency you want to trade or use dynamic whitelists.
|
||||
- [x] **Blacklist crypto-currencies**: Select which crypto-currency you want to avoid.
|
||||
- [x] **Builtin WebUI**: Builtin web UI to manage your bot.
|
||||
- [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] **Display profit/loss in fiat**: Display your profit/loss in fiat currency.
|
||||
- [x] **Performance status report**: Provide a performance status of your current trades.
|
||||
|
||||
## Quick start
|
||||
@@ -197,7 +201,7 @@ To run this bot we recommend you a cloud instance with a minimum of:
|
||||
|
||||
### Software requirements
|
||||
|
||||
- [Python 3.7.x](http://docs.python-guide.org/en/latest/starting/installation/)
|
||||
- [Python >= 3.8](http://docs.python-guide.org/en/latest/starting/installation/)
|
||||
- [pip](https://pip.pypa.io/en/stable/installing/)
|
||||
- [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)
|
||||
- [TA-Lib](https://mrjbq7.github.io/ta-lib/install.html)
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
build_helpers/TA_Lib-0.4.24-cp310-cp310-win_amd64.whl
Normal file
BIN
build_helpers/TA_Lib-0.4.24-cp310-cp310-win_amd64.whl
Normal file
Binary file not shown.
BIN
build_helpers/TA_Lib-0.4.24-cp38-cp38-win_amd64.whl
Normal file
BIN
build_helpers/TA_Lib-0.4.24-cp38-cp38-win_amd64.whl
Normal file
Binary file not shown.
BIN
build_helpers/TA_Lib-0.4.24-cp39-cp39-win_amd64.whl
Normal file
BIN
build_helpers/TA_Lib-0.4.24-cp39-cp39-win_amd64.whl
Normal file
Binary file not shown.
@@ -1,19 +1,18 @@
|
||||
# Downloads don't work automatically, since the URL is regenerated via javascript.
|
||||
# Downloaded from https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib
|
||||
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --upgrade pip wheel
|
||||
|
||||
$pyv = python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')"
|
||||
|
||||
if ($pyv -eq '3.7') {
|
||||
pip install build_helpers\TA_Lib-0.4.22-cp37-cp37m-win_amd64.whl
|
||||
}
|
||||
if ($pyv -eq '3.8') {
|
||||
pip install build_helpers\TA_Lib-0.4.22-cp38-cp38-win_amd64.whl
|
||||
pip install build_helpers\TA_Lib-0.4.24-cp38-cp38-win_amd64.whl
|
||||
}
|
||||
if ($pyv -eq '3.9') {
|
||||
pip install build_helpers\TA_Lib-0.4.22-cp39-cp39-win_amd64.whl
|
||||
pip install build_helpers\TA_Lib-0.4.24-cp39-cp39-win_amd64.whl
|
||||
}
|
||||
if ($pyv -eq '3.10') {
|
||||
pip install build_helpers\TA_Lib-0.4.24-cp310-cp310-win_amd64.whl
|
||||
}
|
||||
|
||||
pip install -r requirements-dev.txt
|
||||
pip install -e .
|
||||
|
@@ -9,7 +9,9 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
"ask_last_balance": 0.0,
|
||||
|
@@ -9,7 +9,9 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
"use_order_book": true,
|
||||
|
@@ -9,7 +9,9 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
"ask_last_balance": 0.0,
|
||||
|
@@ -8,6 +8,7 @@
|
||||
"amend_last_stake_amount": false,
|
||||
"last_stake_amount_min_ratio": 0.5,
|
||||
"dry_run": true,
|
||||
"dry_run_wallet": 1000,
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"timeframe": "5m",
|
||||
"trailing_stop": false,
|
||||
@@ -18,6 +19,7 @@
|
||||
"sell_profit_only": false,
|
||||
"sell_profit_offset": 0.0,
|
||||
"ignore_roi_if_buy_signal": false,
|
||||
"ignore_buying_expired_candle_after": 300,
|
||||
"minimal_roi": {
|
||||
"40": 0.0,
|
||||
"30": 0.01,
|
||||
@@ -27,7 +29,7 @@
|
||||
"stoploss": -0.10,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30,
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
@@ -85,6 +87,7 @@
|
||||
"key": "your_exchange_key",
|
||||
"secret": "your_exchange_secret",
|
||||
"password": "",
|
||||
"log_responses": false,
|
||||
"ccxt_config": {},
|
||||
"ccxt_async_config": {},
|
||||
"pair_whitelist": [
|
||||
|
@@ -9,7 +9,9 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
"use_order_book": true,
|
||||
|
@@ -105,7 +105,7 @@ You can define your own estimator for Hyperopt by implementing `generate_estimat
|
||||
```python
|
||||
class MyAwesomeStrategy(IStrategy):
|
||||
class HyperOpt:
|
||||
def generate_estimator():
|
||||
def generate_estimator(dimensions: List['Dimension'], **kwargs):
|
||||
return "RF"
|
||||
|
||||
```
|
||||
@@ -119,13 +119,34 @@ Example for `ExtraTreesRegressor` ("ET") with additional parameters:
|
||||
```python
|
||||
class MyAwesomeStrategy(IStrategy):
|
||||
class HyperOpt:
|
||||
def generate_estimator():
|
||||
def generate_estimator(dimensions: List['Dimension'], **kwargs):
|
||||
from skopt.learning import ExtraTreesRegressor
|
||||
# Corresponds to "ET" - but allows additional parameters.
|
||||
return ExtraTreesRegressor(n_estimators=100)
|
||||
|
||||
```
|
||||
|
||||
The `dimensions` parameter is the list of `skopt.space.Dimension` objects corresponding to the parameters to be optimized. It can be used to create isotropic kernels for the `skopt.learning.GaussianProcessRegressor` estimator. Here's an example:
|
||||
|
||||
```python
|
||||
class MyAwesomeStrategy(IStrategy):
|
||||
class HyperOpt:
|
||||
def generate_estimator(dimensions: List['Dimension'], **kwargs):
|
||||
from skopt.utils import cook_estimator
|
||||
from skopt.learning.gaussian_process.kernels import (Matern, ConstantKernel)
|
||||
kernel_bounds = (0.0001, 10000)
|
||||
kernel = (
|
||||
ConstantKernel(1.0, kernel_bounds) *
|
||||
Matern(length_scale=np.ones(len(dimensions)), length_scale_bounds=[kernel_bounds for d in dimensions], nu=2.5)
|
||||
)
|
||||
kernel += (
|
||||
ConstantKernel(1.0, kernel_bounds) *
|
||||
Matern(length_scale=np.ones(len(dimensions)), length_scale_bounds=[kernel_bounds for d in dimensions], nu=1.5)
|
||||
)
|
||||
|
||||
return cook_estimator("GP", space=dimensions, kernel=kernel, n_restarts_optimizer=2)
|
||||
```
|
||||
|
||||
!!! Note
|
||||
While custom estimators can be provided, it's up to you as User to do research on possible parameters and analyze / understand which ones should be used.
|
||||
If you're unsure about this, best use one of the Defaults (`"ET"` has proven to be the most versatile) without further parameters.
|
||||
|
@@ -176,12 +176,15 @@ Log messages are send to `syslog` with the `user` facility. So you can see them
|
||||
On many systems `syslog` (`rsyslog`) fetches data from `journald` (and vice versa), so both `--logfile syslog` or `--logfile journald` can be used and the messages be viewed with both `journalctl` and a syslog viewer utility. You can combine this in any way which suites you better.
|
||||
|
||||
For `rsyslog` the messages from the bot can be redirected into a separate dedicated log file. To achieve this, add
|
||||
|
||||
```
|
||||
if $programname startswith "freqtrade" then -/var/log/freqtrade.log
|
||||
```
|
||||
|
||||
to one of the rsyslog configuration files, for example at the end of the `/etc/rsyslog.d/50-default.conf`.
|
||||
|
||||
For `syslog` (`rsyslog`), the reduction mode can be switched on. This will reduce the number of repeating messages. For instance, multiple bot Heartbeat messages will be reduced to a single message when nothing else happens with the bot. To achieve this, set in `/etc/rsyslog.conf`:
|
||||
|
||||
```
|
||||
# Filter duplicated messages
|
||||
$RepeatedMsgReduction on
|
||||
|
BIN
docs/assets/TokenBot-Freqtrade-banner.png
Normal file
BIN
docs/assets/TokenBot-Freqtrade-banner.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 143 KiB |
BIN
docs/assets/windows_install.png
Normal file
BIN
docs/assets/windows_install.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 92 KiB |
@@ -22,6 +22,7 @@ usage: freqtrade backtesting [-h] [-v] [--logfile FILE] [-V] [-c PATH]
|
||||
[--strategy-list STRATEGY_LIST [STRATEGY_LIST ...]]
|
||||
[--export {none,trades}] [--export-filename PATH]
|
||||
[--breakdown {day,week,month} [{day,week,month} ...]]
|
||||
[--cache {none,day,week,month}]
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
@@ -76,6 +77,9 @@ optional arguments:
|
||||
_today.json`
|
||||
--breakdown {day,week,month} [{day,week,month} ...]
|
||||
Show backtesting breakdown per [day, week, month].
|
||||
--cache {none,day,week,month}
|
||||
Load a cached backtest result no older than specified
|
||||
age (default: day).
|
||||
|
||||
Common arguments:
|
||||
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
|
||||
@@ -309,10 +313,11 @@ A backtesting result will look like that:
|
||||
| Avg. Duration Winners | 4:23:00 |
|
||||
| Avg. Duration Loser | 6:55:00 |
|
||||
| Rejected Buy signals | 3089 |
|
||||
| Entry/Exit Timeouts | 0 / 0 |
|
||||
| | |
|
||||
| Min balance | 0.00945123 BTC |
|
||||
| Max balance | 0.01846651 BTC |
|
||||
| Drawdown | 50.63% |
|
||||
| Drawdown (Account) | 13.33% |
|
||||
| Drawdown | 0.0015 BTC |
|
||||
| Drawdown high | 0.0013 BTC |
|
||||
| Drawdown low | -0.0002 BTC |
|
||||
@@ -396,10 +401,11 @@ It contains some useful key metrics about performance of your strategy on backte
|
||||
| Avg. Duration Winners | 4:23:00 |
|
||||
| Avg. Duration Loser | 6:55:00 |
|
||||
| Rejected Buy signals | 3089 |
|
||||
| Entry/Exit Timeouts | 0 / 0 |
|
||||
| | |
|
||||
| Min balance | 0.00945123 BTC |
|
||||
| Max balance | 0.01846651 BTC |
|
||||
| Drawdown | 50.63% |
|
||||
| Drawdown (Account) | 13.33% |
|
||||
| Drawdown | 0.0015 BTC |
|
||||
| Drawdown high | 0.0013 BTC |
|
||||
| Drawdown low | -0.0002 BTC |
|
||||
@@ -425,8 +431,10 @@ It contains some useful key metrics about performance of your strategy on backte
|
||||
- `Days win/draw/lose`: Winning / Losing days (draws are usually days without closed trade).
|
||||
- `Avg. Duration Winners` / `Avg. Duration Loser`: Average durations for winning and losing trades.
|
||||
- `Rejected Buy signals`: Buy signals that could not be acted upon due to max_open_trades being reached.
|
||||
- `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used).
|
||||
- `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period.
|
||||
- `Drawdown`: Maximum drawdown experienced. For example, the value of 50% means that from highest to subsequent lowest point, a 50% drop was experienced).
|
||||
- `Drawdown (Account)`: Maximum Account Drawdown experienced. Calculated as $(Absolute Drawdown) / (DrawdownHigh + startingBalance)$.
|
||||
- `Drawdown`: Maximum, absolute drawdown experienced. Difference between Drawdown High and Subsequent Low point.
|
||||
- `Drawdown high` / `Drawdown low`: Profit at the beginning and end of the largest drawdown period. A negative low value means initial capital lost.
|
||||
- `Drawdown Start` / `Drawdown End`: Start and end datetime for this largest drawdown (can also be visualized via the `plot-dataframe` sub-command).
|
||||
- `Market change`: Change of the market during the backtest period. Calculated as average of all pairs changes from the first to the last candle using the "close" column.
|
||||
@@ -456,6 +464,14 @@ freqtrade backtesting --strategy MyAwesomeStrategy --breakdown day month
|
||||
|
||||
The output will show a table containing the realized absolute Profit (in stake currency) for the given timeperiod, as well as wins, draws and losses that materialized (closed) on this day.
|
||||
|
||||
### Backtest result caching
|
||||
|
||||
To save time, by default backtest will reuse a cached result from within the last day when the backtested strategy and config match that of a previous backtest. To force a new backtest despite existing result for an identical run specify `--cache none` parameter.
|
||||
|
||||
!!! Warning
|
||||
Caching is automatically disabled for open-ended timeranges (`--timerange 20210101-`), as freqtrade cannot ensure reliably that the underlying data didn't change. It can also use cached results where it shouldn't if the original backtest had missing data at the end, which was fixed by downloading more data.
|
||||
In this instance, please use `--cache none` once to force a fresh backtest.
|
||||
|
||||
### Further backtest-result analysis
|
||||
|
||||
To further analyze your backtest results, you can [export the trades](#exporting-trades-to-file).
|
||||
@@ -484,8 +500,8 @@ Since backtesting lacks some detailed information about what happens within a ca
|
||||
- ROI applies before trailing-stop, ensuring profits are "top-capped" at ROI if both ROI and trailing stop applies
|
||||
- Sell-reason does not explain if a trade was positive or negative, just what triggered the sell (this can look odd if negative ROI values are used)
|
||||
- Evaluation sequence (if multiple signals happen on the same candle)
|
||||
- ROI (if not stoploss)
|
||||
- Sell-signal
|
||||
- ROI (if not stoploss)
|
||||
- Stoploss
|
||||
|
||||
Taking these assumptions, backtesting tries to mirror real trading as closely as possible. However, backtesting will **never** replace running a strategy in dry-run mode.
|
||||
|
@@ -38,6 +38,7 @@ By default, loop runs every few seconds (`internals.process_throttle_secs`) and
|
||||
* Considers stoploss, ROI and sell-signal, `custom_sell()` and `custom_stoploss()`.
|
||||
* Determine sell-price based on `ask_strategy` configuration setting or by using the `custom_exit_price()` callback.
|
||||
* Before a sell order is placed, `confirm_trade_exit()` strategy callback is called.
|
||||
* Check position adjustments for open trades if enabled by calling `adjust_trade_position()` and place additional order if required.
|
||||
* Check if trade-slots are still available (if `max_open_trades` is reached).
|
||||
* Verifies buy signal trying to enter new positions.
|
||||
* Determine buy-price based on `bid_strategy` configuration setting, or by using the `custom_entry_price()` callback.
|
||||
@@ -58,9 +59,10 @@ This loop will be repeated again and again until the bot is stopped.
|
||||
* Confirm trade buy / sell (calls `confirm_trade_entry()` and `confirm_trade_exit()` if implemented in the strategy).
|
||||
* Call `custom_entry_price()` (if implemented in the strategy) to determine entry price (Prices are moved to be within the opening candle).
|
||||
* Determine stake size by calling the `custom_stake_amount()` callback.
|
||||
* Check position adjustments for open trades if enabled and call `adjust_trade_position()` to determine if an additional order is requested.
|
||||
* Call `custom_stoploss()` and `custom_sell()` to find custom exit points.
|
||||
* For sells based on sell-signal and custom-sell: Call `custom_exit_price()` to determine exit price (Prices are moved to be within the closing candle).
|
||||
|
||||
* Check for Order timeouts, either via the `unfilledtimeout` configuration, or via `check_buy_timeout()` / `check_sell_timeout()` strategy callbacks.
|
||||
* Generate backtest report output
|
||||
|
||||
!!! Note
|
||||
|
@@ -172,6 +172,8 @@ Mandatory parameters are marked as **Required**, which means that they are requi
|
||||
| `user_data_dir` | Directory containing user data. <br> *Defaults to `./user_data/`*. <br> **Datatype:** String
|
||||
| `dataformat_ohlcv` | Data format to use to store historical candle (OHLCV) data. <br> *Defaults to `json`*. <br> **Datatype:** String
|
||||
| `dataformat_trades` | Data format to use to store historical trades data. <br> *Defaults to `jsongz`*. <br> **Datatype:** String
|
||||
| `position_adjustment_enable` | Enables the strategy to use position adjustments (additional buys or sells). [More information here](strategy-callbacks.md#adjust-trade-position). <br> [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `false`.*<br> **Datatype:** Boolean
|
||||
| `max_entry_position_adjustment` | Maximum additional order(s) for each open trade on top of the first entry Order. Set it to `-1` for unlimited additional orders. [More information here](strategy-callbacks.md#adjust-trade-position). <br> [Strategy Override](#parameters-in-the-strategy). <br>*Defaults to `-1`.*<br> **Datatype:** Positive Integer or -1
|
||||
|
||||
### Parameters in the strategy
|
||||
|
||||
@@ -196,6 +198,8 @@ Values set in the configuration file always overwrite values set in the strategy
|
||||
* `sell_profit_offset`
|
||||
* `ignore_roi_if_buy_signal`
|
||||
* `ignore_buying_expired_candle_after`
|
||||
* `position_adjustment_enable`
|
||||
* `max_entry_position_adjustment`
|
||||
|
||||
### Configuring amount per trade
|
||||
|
||||
@@ -302,6 +306,15 @@ To allow the bot to trade all the available `stake_currency` in your account (mi
|
||||
When using `"stake_amount" : "unlimited",` in combination with Dry-Run, Backtesting or Hyperopt, the balance will be simulated starting with a stake of `dry_run_wallet` which will evolve.
|
||||
It is therefore important to set `dry_run_wallet` to a sensible value (like 0.05 or 0.01 for BTC and 1000 or 100 for USDT, for example), otherwise, it may simulate trades with 100 BTC (or more) or 0.05 USDT (or less) at once - which may not correspond to your real available balance or is less than the exchange minimal limit for the order amount for the stake currency.
|
||||
|
||||
#### Dynamic stake amount with position adjustment
|
||||
|
||||
When you want to use position adjustment with unlimited stakes, you must also implement `custom_stake_amount` to a return a value depending on your strategy.
|
||||
Typical value would be in the range of 25% - 50% of the proposed stakes, but depends highly on your strategy and how much you wish to leave into the wallet as position adjustment buffer.
|
||||
|
||||
For example if your position adjustment assumes it can do 2 additional buys with the same stake amounts then your buffer should be 66.6667% of the initially proposed unlimited stake amount.
|
||||
|
||||
Or another example if your position adjustment assumes it can do 1 additional buy with 3x the original stake amount then `custom_stake_amount` should return 25% of proposed stake amount and leave 75% for possible later position adjustments.
|
||||
|
||||
--8<-- "includes/pricing.md"
|
||||
|
||||
### Understand minimal_roi
|
||||
|
@@ -1,6 +1,6 @@
|
||||
# Analyzing bot data with Jupyter notebooks
|
||||
# Analyzing bot data with Jupyter notebooks
|
||||
|
||||
You can analyze the results of backtests and trading history easily using Jupyter notebooks. Sample notebooks are located at `user_data/notebooks/` after initializing the user directory with `freqtrade create-userdir --userdir user_data`.
|
||||
You can analyze the results of backtests and trading history easily using Jupyter notebooks. Sample notebooks are located at `user_data/notebooks/` after initializing the user directory with `freqtrade create-userdir --userdir user_data`.
|
||||
|
||||
## Quick start with docker
|
||||
|
||||
@@ -41,32 +41,35 @@ ipython kernel install --user --name=freqtrade
|
||||
!!! Warning
|
||||
Some tasks don't work especially well in notebooks. For example, anything using asynchronous execution is a problem for Jupyter. Also, freqtrade's primary entry point is the shell cli, so using pure python in a notebook bypasses arguments that provide required objects and parameters to helper functions. You may need to set those values or create expected objects manually.
|
||||
|
||||
## Recommended workflow
|
||||
## Recommended workflow
|
||||
|
||||
| Task | Tool |
|
||||
--- | ---
|
||||
Bot operations | CLI
|
||||
| Task | Tool |
|
||||
--- | ---
|
||||
Bot operations | CLI
|
||||
Repetitive tasks | Shell scripts
|
||||
Data analysis & visualization | Notebook
|
||||
Data analysis & visualization | Notebook
|
||||
|
||||
1. Use the CLI to
|
||||
|
||||
* download historical data
|
||||
* run a backtest
|
||||
* run with real-time data
|
||||
* export results
|
||||
* export results
|
||||
|
||||
1. Collect these actions in shell scripts
|
||||
|
||||
* save complicated commands with arguments
|
||||
* execute multi-step operations
|
||||
* execute multi-step operations
|
||||
* automate testing strategies and preparing data for analysis
|
||||
|
||||
1. Use a notebook to
|
||||
|
||||
* visualize data
|
||||
* munge and plot to generate insights
|
||||
* mangle and plot to generate insights
|
||||
|
||||
## Example utility snippets
|
||||
## Example utility snippets
|
||||
|
||||
### Change directory to root
|
||||
### Change directory to root
|
||||
|
||||
Jupyter notebooks execute from the notebook directory. The following snippet searches for the project root, so relative paths remain consistent.
|
||||
|
||||
|
@@ -15,8 +15,8 @@ This command line option was deprecated in 2019.7-dev (develop branch) and remov
|
||||
|
||||
### The **--dynamic-whitelist** command line option
|
||||
|
||||
This command line option was deprecated in 2018 and removed freqtrade 2019.6-dev (develop branch)
|
||||
and in freqtrade 2019.7.
|
||||
This command line option was deprecated in 2018 and removed freqtrade 2019.6-dev (develop branch) and in freqtrade 2019.7.
|
||||
Please refer to [pairlists](plugins.md#pairlists-and-pairlist-handlers) instead.
|
||||
|
||||
### the `--live` command line option
|
||||
|
||||
|
@@ -126,6 +126,12 @@ All freqtrade arguments will be available by running `docker-compose run --rm fr
|
||||
!!! Note "`docker-compose run --rm`"
|
||||
Including `--rm` will remove the container after completion, and is highly recommended for all modes except trading mode (running with `freqtrade trade` command).
|
||||
|
||||
??? Note "Using docker without docker-compose"
|
||||
"`docker-compose run --rm`" will require a compose file to be provided.
|
||||
Some freqtrade commands that don't require authentication such as `list-pairs` can be run with "`docker run --rm`" instead.
|
||||
For example `docker run --rm freqtradeorg/freqtrade:stable list-pairs --exchange binance --quote BTC --print-json`.
|
||||
This can be useful for fetching exchange information to add to your `config.json` without affecting your running containers.
|
||||
|
||||
#### Example: Download data with docker-compose
|
||||
|
||||
Download backtesting data for 5 days for the pair ETH/BTC and 1h timeframe from Binance. The data will be stored in the directory `user_data/data/` on the host.
|
||||
|
@@ -182,13 +182,13 @@ Kucoin supports [time_in_force](configuration.md#understand-order_time_in_force)
|
||||
For Kucoin, please add `"KCS/<STAKE>"` to your blacklist to avoid issues.
|
||||
Accounts having KCS accounts use this to pay for fees - if your first trade happens to be on `KCS`, further trades will consume this position and make the initial KCS trade unsellable as the expected amount is not there anymore.
|
||||
|
||||
## OKEX
|
||||
## OKX
|
||||
|
||||
OKEX requires a passphrase for each api key, you will therefore need to add this key into the configuration so your exchange section looks as follows:
|
||||
OKX requires a passphrase for each api key, you will therefore need to add this key into the configuration so your exchange section looks as follows:
|
||||
|
||||
```json
|
||||
"exchange": {
|
||||
"name": "okex",
|
||||
"name": "okx",
|
||||
"key": "your_exchange_key",
|
||||
"secret": "your_exchange_secret",
|
||||
"password": "your_exchange_api_key_password",
|
||||
@@ -197,7 +197,7 @@ OKEX requires a passphrase for each api key, you will therefore need to add this
|
||||
```
|
||||
|
||||
!!! Warning
|
||||
OKEX only provides 100 candles per api call. Therefore, the strategy will only have a pretty low amount of data available in backtesting mode.
|
||||
OKX only provides 100 candles per api call. Therefore, the strategy will only have a pretty low amount of data available in backtesting mode.
|
||||
|
||||
## Gate.io
|
||||
|
||||
|
@@ -188,12 +188,12 @@ There is however nothing preventing you from using GPU-enabled indicators within
|
||||
Per default Hyperopt called without the `-e`/`--epochs` command line option will only
|
||||
run 100 epochs, means 100 evaluations 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
|
||||
have to run it for 10000 or more. But it will take an eternity to
|
||||
compute.
|
||||
|
||||
Since hyperopt uses Bayesian search, running for too many epochs may not produce greater results.
|
||||
|
||||
It's therefore recommended to run between 500-1000 epochs over and over until you hit at least 10.000 epochs in total (or are satisfied with the result). You can best judge by looking at the results - if the bot keeps discovering better strategies, it's best to keep on going.
|
||||
It's therefore recommended to run between 500-1000 epochs over and over until you hit at least 10000 epochs in total (or are satisfied with the result). You can best judge by looking at the results - if the bot keeps discovering better strategies, it's best to keep on going.
|
||||
|
||||
```bash
|
||||
freqtrade hyperopt --hyperopt-loss SharpeHyperOptLossDaily --strategy SampleStrategy -e 1000
|
||||
@@ -217,9 +217,9 @@ already 8\*10^9\*10 evaluations. A roughly total of 80 billion evaluations.
|
||||
Did you run 100 000 evaluations? Congrats, you've done roughly 1 / 100 000 th
|
||||
of the search space, assuming that the bot never tests the same parameters more than once.
|
||||
|
||||
* The time it takes to run 1000 hyperopt epochs depends on things like: The available cpu, hard-disk, ram, timeframe, timerange, indicator settings, indicator count, amount of coins that hyperopt test strategies on and the resulting trade count - which can be 650 trades in a year or 10.0000 trades depending if the strategy aims for big profits by trading rarely or for many low profit trades.
|
||||
* The time it takes to run 1000 hyperopt epochs depends on things like: The available cpu, hard-disk, ram, timeframe, timerange, indicator settings, indicator count, amount of coins that hyperopt test strategies on and the resulting trade count - which can be 650 trades in a year or 100000 trades depending if the strategy aims for big profits by trading rarely or for many low profit trades.
|
||||
|
||||
Example: 4% profit 650 times vs 0,3% profit a trade 10.000 times in a year. If we assume you set the --timerange to 365 days.
|
||||
Example: 4% profit 650 times vs 0,3% profit a trade 10000 times in a year. If we assume you set the --timerange to 365 days.
|
||||
|
||||
Example:
|
||||
`freqtrade --config config.json --strategy SampleStrategy --hyperopt SampleHyperopt -e 1000 --timerange 20190601-20200601`
|
||||
|
@@ -116,7 +116,7 @@ optional arguments:
|
||||
ShortTradeDurHyperOptLoss, OnlyProfitHyperOptLoss,
|
||||
SharpeHyperOptLoss, SharpeHyperOptLossDaily,
|
||||
SortinoHyperOptLoss, SortinoHyperOptLossDaily,
|
||||
CalmarHyperOptLoss, MaxDrawDownHyperOptLoss
|
||||
CalmarHyperOptLoss, MaxDrawDownHyperOptLoss, ProfitDrawDownHyperOptLoss
|
||||
--disable-param-export
|
||||
Disable automatic hyperopt parameter export.
|
||||
--ignore-missing-spaces, --ignore-unparameterized-spaces
|
||||
@@ -508,6 +508,46 @@ class MyAwesomeStrategy(IStrategy):
|
||||
|
||||
You will then obviously also change potential interesting entries to parameters to allow hyper-optimization.
|
||||
|
||||
### Optimizing `max_entry_position_adjustment`
|
||||
|
||||
While `max_entry_position_adjustment` is not a separate space, it can still be used in hyperopt by using the property approach shown above.
|
||||
|
||||
``` python
|
||||
from pandas import DataFrame
|
||||
from functools import reduce
|
||||
|
||||
import talib.abstract as ta
|
||||
|
||||
from freqtrade.strategy import (BooleanParameter, CategoricalParameter, DecimalParameter,
|
||||
IStrategy, IntParameter)
|
||||
import freqtrade.vendor.qtpylib.indicators as qtpylib
|
||||
|
||||
class MyAwesomeStrategy(IStrategy):
|
||||
stoploss = -0.05
|
||||
timeframe = '15m'
|
||||
|
||||
# Define the parameter spaces
|
||||
max_epa = CategoricalParameter([-1, 0, 1, 3, 5, 10], default=1, space="buy", optimize=True)
|
||||
|
||||
@property
|
||||
def max_entry_position_adjustment(self):
|
||||
return self.max_epa.value
|
||||
|
||||
|
||||
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||
# ...
|
||||
```
|
||||
|
||||
??? Tip "Using `IntParameter`"
|
||||
You can also use the `IntParameter` for this optimization, but you must explicitly return an integer:
|
||||
``` python
|
||||
max_epa = IntParameter(-1, 10, default=1, space="buy", optimize=True)
|
||||
|
||||
@property
|
||||
def max_entry_position_adjustment(self):
|
||||
return int(self.max_epa.value)
|
||||
```
|
||||
|
||||
## Loss-functions
|
||||
|
||||
Each hyperparameter tuning requires a target. This is usually defined as a loss function (sometimes also called objective function), which should decrease for more desirable results, and increase for bad results.
|
||||
@@ -525,6 +565,7 @@ Currently, the following loss functions are builtin:
|
||||
* `SortinoHyperOptLossDaily` - optimizes Sortino Ratio calculated on **daily** trade returns relative to **downside** standard deviation.
|
||||
* `MaxDrawDownHyperOptLoss` - Optimizes Maximum drawdown.
|
||||
* `CalmarHyperOptLoss` - Optimizes Calmar Ratio calculated on trade returns relative to max drawdown.
|
||||
* `ProfitDrawDownHyperOptLoss` - Optimizes by max Profit & min Drawdown objective. `DRAWDOWN_MULT` variable within the hyperoptloss file can be adjusted to be stricter or more flexible on drawdown purposes.
|
||||
|
||||
Creation of a custom loss function is covered in the [Advanced Hyperopt](advanced-hyperopt.md) part of the documentation.
|
||||
|
||||
|
@@ -246,7 +246,7 @@ On exchanges that deduct fees from the receiving currency (e.g. FTX) - this can
|
||||
The `low_price_ratio` setting removes pairs where a raise of 1 price unit (pip) is above the `low_price_ratio` ratio.
|
||||
This option is disabled by default, and will only apply if set to > 0.
|
||||
|
||||
For `PriceFiler` at least one of its `min_price`, `max_price` or `low_price_ratio` settings must be applied.
|
||||
For `PriceFilter` at least one of its `min_price`, `max_price` or `low_price_ratio` settings must be applied.
|
||||
|
||||
Calculation example:
|
||||
|
||||
|
@@ -11,7 +11,7 @@
|
||||
|
||||
## Introduction
|
||||
|
||||
Freqtrade is a crypto-currency algorithmic trading software developed in python (3.7+) and supported on Windows, macOS and Linux.
|
||||
Freqtrade is a free and open source crypto trading bot written in Python. It is designed to support all major exchanges and be controlled via Telegram or webUI. It contains backtesting, plotting and money management tools as well as strategy optimization by machine learning.
|
||||
|
||||
!!! Danger "DISCLAIMER"
|
||||
This software is for educational purposes only. Do not risk money which you are afraid to lose. USE THE SOFTWARE AT YOUR OWN RISK. THE AUTHORS AND ALL AFFILIATES ASSUME NO RESPONSIBILITY FOR YOUR TRADING RESULTS.
|
||||
@@ -20,6 +20,12 @@ Freqtrade is a crypto-currency algorithmic trading software developed in python
|
||||
|
||||
We strongly recommend you to have basic coding skills and Python knowledge. Do not hesitate to read the source code and understand the mechanisms of this bot, algorithms and techniques implemented in it.
|
||||
|
||||

|
||||
|
||||
## Sponsored promotion
|
||||
|
||||
[](https://tokenbot.com/?utm_source=github&utm_medium=freqtrade&utm_campaign=algodevs)
|
||||
|
||||
## Features
|
||||
|
||||
- Develop your Strategy: Write your strategy in python, using [pandas](https://pandas.pydata.org/). Example strategies to inspire you are available in the [strategy repository](https://github.com/freqtrade/freqtrade-strategies).
|
||||
@@ -29,7 +35,7 @@ Freqtrade is a crypto-currency algorithmic trading software developed in python
|
||||
- Select markets: Create your static list or use an automatic one based on top traded volumes and/or prices (not available during backtesting). You can also explicitly blacklist markets you don't want to trade.
|
||||
- Run: Test your strategy with simulated money (Dry-Run mode) or deploy it with real money (Live-Trade mode).
|
||||
- Run using Edge (optional module): The concept is to find the best historical [trade expectancy](edge.md#expectancy) by markets based on variation of the stop-loss and then allow/reject markets to trade. The sizing of the trade is based on a risk of a percentage of your capital.
|
||||
- Control/Monitor: Use Telegram or a REST API (start/stop the bot, show profit/loss, daily summary, current open trades results, etc.).
|
||||
- Control/Monitor: Use Telegram or a WebUI (start/stop the bot, show profit/loss, daily summary, current open trades results, etc.).
|
||||
- Analyse: Further analysis can be performed on either Backtesting data or Freqtrade trading history (SQL database), including automated standard plots, and methods to load the data into [interactive environments](data-analysis.md).
|
||||
|
||||
## Supported exchange marketplaces
|
||||
@@ -41,7 +47,7 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual,
|
||||
- [X] [FTX](https://ftx.com)
|
||||
- [X] [Gate.io](https://www.gate.io/ref/6266643)
|
||||
- [X] [Kraken](https://kraken.com/)
|
||||
- [X] [OKEX](https://www.okex.com/)
|
||||
- [X] [OKX](https://www.okx.com/)
|
||||
- [ ] [potentially many others through <img alt="ccxt" width="30px" src="assets/ccxt-logo.svg" />](https://github.com/ccxt/ccxt/). _(We cannot guarantee they will work)_
|
||||
|
||||
### Community tested
|
||||
@@ -67,7 +73,7 @@ To run this bot we recommend you a linux cloud instance with a minimum of:
|
||||
|
||||
Alternatively
|
||||
|
||||
- Python 3.7+
|
||||
- Python 3.8+
|
||||
- pip (pip3)
|
||||
- git
|
||||
- TA-Lib
|
||||
|
@@ -24,7 +24,7 @@ The easiest way to install and run Freqtrade is to clone the bot Github reposito
|
||||
The `stable` branch contains the code of the last release (done usually once per month on an approximately one week old snapshot of the `develop` branch to prevent packaging bugs, so potentially it's more stable).
|
||||
|
||||
!!! Note
|
||||
Python3.7 or higher and the corresponding `pip` are assumed to be available. The install-script will warn you and stop if that's not the case. `git` is also needed to clone the Freqtrade repository.
|
||||
Python3.8 or higher and the corresponding `pip` are assumed to be available. The install-script will warn you and stop if that's not the case. `git` is also needed to clone the Freqtrade repository.
|
||||
Also, python headers (`python<yourversion>-dev` / `python<yourversion>-devel`) must be available for the installation to complete successfully.
|
||||
|
||||
!!! Warning "Up-to-date clock"
|
||||
@@ -42,7 +42,7 @@ These requirements apply to both [Script Installation](#script-installation) and
|
||||
|
||||
### Install guide
|
||||
|
||||
* [Python >= 3.7.x](http://docs.python-guide.org/en/latest/starting/installation/)
|
||||
* [Python >= 3.8.x](http://docs.python-guide.org/en/latest/starting/installation/)
|
||||
* [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.html) (Recommended)
|
||||
@@ -54,11 +54,7 @@ We've included/collected install instructions for Ubuntu, MacOS, and Windows. Th
|
||||
OS Specific steps are listed first, the [Common](#common) section below is necessary for all systems.
|
||||
|
||||
!!! Note
|
||||
Python3.7 or higher and the corresponding pip are assumed to be available.
|
||||
|
||||
!!! Warning "Python 3.10 support"
|
||||
Due to issues with dependencies, freqtrade is currently unable to support python 3.10.
|
||||
We're working on supporting python 3.10, are however dependant on support from dependencies.
|
||||
Python3.8 or higher and the corresponding pip are assumed to be available.
|
||||
|
||||
=== "Debian/Ubuntu"
|
||||
#### Install necessary dependencies
|
||||
@@ -73,7 +69,7 @@ OS Specific steps are listed first, the [Common](#common) section below is neces
|
||||
|
||||
=== "RaspberryPi/Raspbian"
|
||||
The following assumes the latest [Raspbian Buster lite image](https://www.raspberrypi.org/downloads/raspbian/).
|
||||
This image comes with python3.7 preinstalled, making it easy to get freqtrade up and running.
|
||||
This image comes with python3.9 preinstalled, making it easy to get freqtrade up and running.
|
||||
|
||||
Tested using a Raspberry Pi 3 with the Raspbian Buster lite image, all updates applied.
|
||||
|
||||
@@ -173,7 +169,7 @@ You can as well update, configure and reset the codebase of your bot with `./scr
|
||||
** --install **
|
||||
|
||||
With this option, the script will install the bot and most dependencies:
|
||||
You will need to have git and python3.7+ installed beforehand for this to work.
|
||||
You will need to have git and python3.8+ installed beforehand for this to work.
|
||||
|
||||
* Mandatory software as: `ta-lib`
|
||||
* Setup your virtualenv under `.env/`
|
||||
@@ -424,16 +420,3 @@ open /Library/Developer/CommandLineTools/Packages/macOS_SDK_headers_for_macOS_10
|
||||
```
|
||||
|
||||
If this file is inexistent, then you're probably on a different version of MacOS, so you may need to consult the internet for specific resolution details.
|
||||
|
||||
### MacOS installation error with python 3.9
|
||||
|
||||
When using python 3.9 on macOS, it's currently necessary to install some os-level modules to allow dependencies to compile.
|
||||
The errors you'll see happen during installation and are related to the installation of `tables` or `blosc`.
|
||||
|
||||
You can install the necessary libraries with the following command:
|
||||
|
||||
```bash
|
||||
brew install hdf5 c-blosc
|
||||
```
|
||||
|
||||
After this, please run the installation (script) again.
|
||||
|
@@ -273,6 +273,9 @@ def plot_config(self):
|
||||
!!! Warning
|
||||
`plotly` arguments are only supported with plotly library and will not work with freq-ui.
|
||||
|
||||
!!! Note "Trade position adjustments"
|
||||
If `position_adjustment_enable` / `adjust_trade_position()` is used, the trade initial buy price is averaged over multiple orders and the trade start price will most likely appear outside the candle range.
|
||||
|
||||
## Plot profit
|
||||
|
||||

|
||||
@@ -283,6 +286,8 @@ The `plot-profit` subcommand shows an interactive graph with three plots:
|
||||
* The summarized profit made by backtesting.
|
||||
Note that this is not the real-world profit, but more of an estimate.
|
||||
* Profit for each individual pair.
|
||||
* Parallelism of trades.
|
||||
* Underwater (Periods of drawdown).
|
||||
|
||||
The first graph is good to get a grip of how the overall market progresses.
|
||||
|
||||
@@ -292,6 +297,8 @@ This graph will also highlight the start (and end) of the Max drawdown period.
|
||||
|
||||
The third graph can be useful to spot outliers, events in pairs that cause profit spikes.
|
||||
|
||||
The forth graph can help you analyze trade parallelism, showing how often max_open_trades have been maxed out.
|
||||
|
||||
Possible options for the `freqtrade plot-profit` subcommand:
|
||||
|
||||
```
|
||||
@@ -311,8 +318,8 @@ optional arguments:
|
||||
Specify what timerange of data to use.
|
||||
--export EXPORT Export backtest results, argument are: trades.
|
||||
Example: `--export=trades`
|
||||
--export-filename PATH
|
||||
Save backtest results to the file with this filename.
|
||||
--export-filename PATH, --backtest-filename PATH
|
||||
Use backtest results from this filename.
|
||||
Requires `--export` to be set as well. Example:
|
||||
`--export-filename=user_data/backtest_results/backtest
|
||||
_today.json`
|
||||
|
@@ -1,4 +1,4 @@
|
||||
mkdocs==1.2.3
|
||||
mkdocs-material==8.1.3
|
||||
mkdocs-material==8.2.1
|
||||
mdx_truly_sane_lists==1.2
|
||||
pymdown-extensions==9.1
|
||||
pymdown-extensions==9.2
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
The `stoploss` configuration parameter is loss as ratio 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.
|
||||
Stoploss calculations do include fees, so a stoploss of -10% is placed exactly 10% below the entry point.
|
||||
|
||||
Most of the strategy files already include the optimal `stoploss` value.
|
||||
|
||||
@@ -30,7 +31,7 @@ These modes can be configured with these values:
|
||||
### stoploss_on_exchange and stoploss_on_exchange_limit_ratio
|
||||
|
||||
Enable or Disable stop loss on exchange.
|
||||
If the stoploss is *on exchange* it means a stoploss limit order is placed on the exchange immediately after buy order happens successfully. This will protect you against sudden crashes in market as the order will be in the queue immediately and if market goes down then the order has more chance of being fulfilled.
|
||||
If the stoploss is *on exchange* it means a stoploss limit order is placed on the exchange immediately after buy order fills. This will protect you against sudden crashes in market, as the order execution happens purely within the exchange, and has no potential network overhead.
|
||||
|
||||
If `stoploss_on_exchange` uses limit orders, the exchange needs 2 prices, the stoploss_price and the Limit price.
|
||||
`stoploss` defines the stop-price where the limit order is placed - and limit should be slightly below this.
|
||||
|
@@ -222,9 +222,9 @@ should be rewritten to
|
||||
```python
|
||||
frames = [dataframe]
|
||||
for val in self.buy_ema_short.range:
|
||||
frames.append({
|
||||
frames.append(DataFrame({
|
||||
f'ema_short_{val}': ta.EMA(dataframe, timeperiod=val)
|
||||
})
|
||||
}))
|
||||
|
||||
# Append columns to existing dataframe
|
||||
merged_frame = pd.concat(frames, axis=1)
|
||||
|
@@ -15,6 +15,7 @@ Currently available callbacks:
|
||||
* [`check_buy_timeout()` and `check_sell_timeout()](#custom-order-timeout-rules)
|
||||
* [`confirm_trade_entry()`](#trade-entry-buy-order-confirmation)
|
||||
* [`confirm_trade_exit()`](#trade-exit-sell-order-confirmation)
|
||||
* [`adjust_trade_position()`](#adjust-trade-position)
|
||||
|
||||
!!! Tip "Callback calling sequence"
|
||||
You can find the callback calling sequence in [bot-basics](bot-basics.md#bot-execution-logic)
|
||||
@@ -53,7 +54,7 @@ Called before entering a trade, makes it possible to manage your position size w
|
||||
class AwesomeStrategy(IStrategy):
|
||||
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_stake: float, min_stake: float, max_stake: float,
|
||||
**kwargs) -> float:
|
||||
entry_tag: Optional[str], **kwargs) -> float:
|
||||
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe)
|
||||
current_candle = dataframe.iloc[-1].squeeze()
|
||||
@@ -73,7 +74,7 @@ class AwesomeStrategy(IStrategy):
|
||||
Freqtrade will fall back to the `proposed_stake` value should your code raise an exception. The exception itself will be logged.
|
||||
|
||||
!!! Tip
|
||||
You do not _have_ to ensure that `min_stake <= returned_value <= max_stake`. Trades will succeed as the returned value will be clamped to supported range and this acton will be logged.
|
||||
You do not _have_ to ensure that `min_stake <= returned_value <= max_stake`. Trades will succeed as the returned value will be clamped to supported range and this action will be logged.
|
||||
|
||||
!!! Tip
|
||||
Returning `0` or `None` will prevent trades from being placed.
|
||||
@@ -361,8 +362,8 @@ class AwesomeStrategy(IStrategy):
|
||||
|
||||
# ... populate_* methods
|
||||
|
||||
def custom_entry_price(self, pair: str, current_time: datetime,
|
||||
proposed_rate, **kwargs) -> float:
|
||||
def custom_entry_price(self, pair: str, current_time: datetime, proposed_rate: float,
|
||||
entry_tag: Optional[str], **kwargs) -> float:
|
||||
|
||||
dataframe, last_updated = self.dp.get_analyzed_dataframe(pair=pair,
|
||||
timeframe=self.timeframe)
|
||||
@@ -388,8 +389,8 @@ class AwesomeStrategy(IStrategy):
|
||||
If the new_entryprice is 97, the proposed_rate is 100 and the `custom_price_max_distance_ratio` is set to 2%, The retained valid custom entry price will be 98, which is 2% below the current (proposed) rate.
|
||||
|
||||
!!! Warning "Backtesting"
|
||||
While Custom prices are supported in backtesting (starting with 2021.12), prices will be moved to within the candle's high/low prices.
|
||||
This behavior is currently being tested, and might be changed at a later point.
|
||||
Custom prices are supported in backtesting (starting with 2021.12), and orders will fill if the price falls within the candle's low/high range.
|
||||
Orders that don't fill immediately are subject to regular timeout handling, which happens once per (detail) candle.
|
||||
`custom_exit_price()` is only called for sells of type Sell_signal and Custom sell. All other sell-types will use regular backtesting prices.
|
||||
|
||||
## Custom order timeout rules
|
||||
@@ -399,7 +400,8 @@ Simple, time-based order-timeouts can be configured either via strategy or in th
|
||||
However, freqtrade also offers a custom callback for both order types, which allows you to decide based on custom criteria if an order did time out or not.
|
||||
|
||||
!!! Note
|
||||
Unfilled order timeouts are not relevant during backtesting or hyperopt, and are only relevant during real (live) trading. Therefore these methods are only called in these circumstances.
|
||||
Backtesting fills orders if their price falls within the candle's low/high range.
|
||||
The below callbacks will be called once per (detail) candle for orders that don't fill immediately (which use custom pricing).
|
||||
|
||||
### Custom order timeout example
|
||||
|
||||
@@ -412,7 +414,7 @@ It applies a tight timeout for higher priced assets, while allowing more time to
|
||||
The function must return either `True` (cancel order) or `False` (keep order alive).
|
||||
|
||||
``` python
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import datetime, timedelta
|
||||
from freqtrade.persistence import Trade
|
||||
|
||||
class AwesomeStrategy(IStrategy):
|
||||
@@ -425,22 +427,24 @@ class AwesomeStrategy(IStrategy):
|
||||
'sell': 60 * 25
|
||||
}
|
||||
|
||||
def check_buy_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool:
|
||||
if trade.open_rate > 100 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=5):
|
||||
def check_buy_timeout(self, pair: str, trade: 'Trade', order: dict,
|
||||
current_time: datetime, **kwargs) -> bool:
|
||||
if trade.open_rate > 100 and trade.open_date_utc < current_time - timedelta(minutes=5):
|
||||
return True
|
||||
elif trade.open_rate > 10 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=3):
|
||||
elif trade.open_rate > 10 and trade.open_date_utc < current_time - timedelta(minutes=3):
|
||||
return True
|
||||
elif trade.open_rate < 1 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(hours=24):
|
||||
elif trade.open_rate < 1 and trade.open_date_utc < current_time - timedelta(hours=24):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def check_sell_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool:
|
||||
if trade.open_rate > 100 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=5):
|
||||
def check_sell_timeout(self, pair: str, trade: Trade, order: dict,
|
||||
current_time: datetime, **kwargs) -> bool:
|
||||
if trade.open_rate > 100 and trade.open_date_utc < current_time - timedelta(minutes=5):
|
||||
return True
|
||||
elif trade.open_rate > 10 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=3):
|
||||
elif trade.open_rate > 10 and trade.open_date_utc < current_time - timedelta(minutes=3):
|
||||
return True
|
||||
elif trade.open_rate < 1 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(hours=24):
|
||||
elif trade.open_rate < 1 and trade.open_date_utc < current_time - timedelta(hours=24):
|
||||
return True
|
||||
return False
|
||||
```
|
||||
@@ -464,7 +468,8 @@ class AwesomeStrategy(IStrategy):
|
||||
'sell': 60 * 25
|
||||
}
|
||||
|
||||
def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
|
||||
def check_buy_timeout(self, pair: str, trade: Trade, order: dict,
|
||||
current_time: datetime, **kwargs) -> bool:
|
||||
ob = self.dp.orderbook(pair, 1)
|
||||
current_price = ob['bids'][0][0]
|
||||
# Cancel buy order if price is more than 2% above the order.
|
||||
@@ -473,7 +478,8 @@ class AwesomeStrategy(IStrategy):
|
||||
return False
|
||||
|
||||
|
||||
def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
|
||||
def check_sell_timeout(self, pair: str, trade: Trade, order: dict,
|
||||
current_time: datetime, **kwargs) -> bool:
|
||||
ob = self.dp.orderbook(pair, 1)
|
||||
current_price = ob['asks'][0][0]
|
||||
# Cancel sell order if price is more than 2% below the order.
|
||||
@@ -499,7 +505,8 @@ class AwesomeStrategy(IStrategy):
|
||||
# ... populate_* methods
|
||||
|
||||
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
|
||||
time_in_force: str, current_time: datetime, **kwargs) -> bool:
|
||||
time_in_force: str, current_time: datetime, entry_tag: Optional[str],
|
||||
**kwargs) -> bool:
|
||||
"""
|
||||
Called right before placing a buy order.
|
||||
Timing for this function is critical, so avoid doing heavy computations or
|
||||
@@ -568,3 +575,110 @@ class AwesomeStrategy(IStrategy):
|
||||
return True
|
||||
|
||||
```
|
||||
|
||||
## Adjust trade position
|
||||
|
||||
The `position_adjustment_enable` strategy property enables the usage of `adjust_trade_position()` callback in the strategy.
|
||||
For performance reasons, it's disabled by default and freqtrade will show a warning message on startup if enabled.
|
||||
`adjust_trade_position()` can be used to perform additional orders, for example to manage risk with DCA (Dollar Cost Averaging).
|
||||
|
||||
`max_entry_position_adjustment` property is used to limit the number of additional buys per trade (on top of the first buy) that the bot can execute. By default, the value is -1 which means the bot have no limit on number of adjustment buys.
|
||||
|
||||
The strategy is expected to return a stake_amount (in stake currency) between `min_stake` and `max_stake` if and when an additional buy order should be made (position is increased).
|
||||
If there are not enough funds in the wallet (the return value is above `max_stake`) then the signal will be ignored.
|
||||
Additional orders also result in additional fees and those orders don't count towards `max_open_trades`.
|
||||
|
||||
This callback is **not** called when there is an open order (either buy or sell) waiting for execution, or when you have reached the maximum amount of extra buys that you have set on `max_entry_position_adjustment`.
|
||||
`adjust_trade_position()` is called very frequently for the duration of a trade, so you must keep your implementation as performant as possible.
|
||||
|
||||
!!! Note "About stake size"
|
||||
Using fixed stake size means it will be the amount used for the first order, just like without position adjustment.
|
||||
If you wish to buy additional orders with DCA, then make sure to leave enough funds in the wallet for that.
|
||||
Using 'unlimited' stake amount with DCA orders requires you to also implement the `custom_stake_amount()` callback to avoid allocating all funds to the initial order.
|
||||
|
||||
!!! Warning
|
||||
Stoploss is still calculated from the initial opening price, not averaged price.
|
||||
|
||||
!!! Warning "/stopbuy"
|
||||
While `/stopbuy` command stops the bot from entering new trades, the position adjustment feature will continue buying new orders on existing trades.
|
||||
|
||||
!!! Warning "Backtesting"
|
||||
During backtesting this callback is called for each candle in `timeframe` or `timeframe_detail`, so performance will be affected.
|
||||
|
||||
``` python
|
||||
from freqtrade.persistence import Trade
|
||||
|
||||
|
||||
class DigDeeperStrategy(IStrategy):
|
||||
|
||||
position_adjustment_enable = True
|
||||
|
||||
# Attempts to handle large drops with DCA. High stoploss is required.
|
||||
stoploss = -0.30
|
||||
|
||||
# ... populate_* methods
|
||||
|
||||
# Example specific variables
|
||||
max_entry_position_adjustment = 3
|
||||
# This number is explained a bit further down
|
||||
max_dca_multiplier = 5.5
|
||||
|
||||
# This is called when placing the initial order (opening trade)
|
||||
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_stake: float, min_stake: float, max_stake: float,
|
||||
entry_tag: Optional[str], **kwargs) -> float:
|
||||
|
||||
# We need to leave most of the funds for possible further DCA orders
|
||||
# This also applies to fixed stakes
|
||||
return proposed_stake / self.max_dca_multiplier
|
||||
|
||||
def adjust_trade_position(self, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, min_stake: float,
|
||||
max_stake: float, **kwargs):
|
||||
"""
|
||||
Custom trade adjustment logic, returning the stake amount that a trade should be increased.
|
||||
This means extra buy orders with additional fees.
|
||||
|
||||
:param trade: trade object.
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param current_rate: Current buy rate.
|
||||
:param current_profit: Current profit (as ratio), calculated based on current_rate.
|
||||
:param min_stake: Minimal stake size allowed by exchange.
|
||||
:param max_stake: Balance available for trading.
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return float: Stake amount to adjust your trade
|
||||
"""
|
||||
|
||||
if current_profit > -0.05:
|
||||
return None
|
||||
|
||||
# Obtain pair dataframe (just to show how to access it)
|
||||
dataframe, _ = self.dp.get_analyzed_dataframe(trade.pair, self.timeframe)
|
||||
# Only buy when not actively falling price.
|
||||
last_candle = dataframe.iloc[-1].squeeze()
|
||||
previous_candle = dataframe.iloc[-2].squeeze()
|
||||
if last_candle['close'] < previous_candle['close']:
|
||||
return None
|
||||
|
||||
filled_buys = trade.select_filled_orders('buy')
|
||||
count_of_buys = trade.nr_of_successful_buys
|
||||
# Allow up to 3 additional increasingly larger buys (4 in total)
|
||||
# Initial buy is 1x
|
||||
# If that falls to -5% profit, we buy 1.25x more, average profit should increase to roughly -2.2%
|
||||
# If that falls down to -5% again, we buy 1.5x more
|
||||
# If that falls once again down to -5%, we buy 1.75x more
|
||||
# Total stake for this trade would be 1 + 1.25 + 1.5 + 1.75 = 5.5x of the initial allowed stake.
|
||||
# That is why max_dca_multiplier is 5.5
|
||||
# Hope you have a deep wallet!
|
||||
try:
|
||||
# This returns first order stake size
|
||||
stake_amount = filled_buys[0].cost
|
||||
# This then calculates current safety order size
|
||||
stake_amount = stake_amount * (1 + (count_of_buys * 0.25))
|
||||
return stake_amount
|
||||
except Exception as exception:
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
```
|
||||
|
@@ -838,7 +838,7 @@ In some situations it may be confusing to deal with stops relative to current ra
|
||||
|
||||
from datetime import datetime
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.strategy import IStrategy, stoploss_from_open
|
||||
from freqtrade.strategy import IStrategy, stoploss_from_absolute
|
||||
|
||||
class AwesomeStrategy(IStrategy):
|
||||
|
||||
|
@@ -59,7 +59,7 @@ $ freqtrade new-config --config config_binance.json
|
||||
? Do you want to enable Dry-run (simulated trades)? Yes
|
||||
? Please insert your stake currency: BTC
|
||||
? Please insert your stake amount: 0.05
|
||||
? Please insert max_open_trades (Integer or 'unlimited'): 3
|
||||
? Please insert max_open_trades (Integer or -1 for unlimited open trades): 3
|
||||
? Please insert your desired timeframe (e.g. 5m): 5m
|
||||
? Please insert your display Currency (for reporting): USD
|
||||
? Select exchange binance
|
||||
|
@@ -23,9 +23,9 @@ git clone https://github.com/freqtrade/freqtrade.git
|
||||
|
||||
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 unofficial pre-compiled windows Wheels [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib), which need to be downloaded and installed using `pip install TA_Lib‑0.4.22‑cp38‑cp38‑win_amd64.whl` (make sure to use the version matching your python version).
|
||||
As compiling from source on windows has heavy dependencies (requires a partial visual studio installation), there is also a repository of unofficial pre-compiled windows Wheels [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib), which need to be downloaded and installed using `pip install TA_Lib-0.4.24-cp38-cp38-win_amd64.whl` (make sure to use the version matching your python version).
|
||||
|
||||
Freqtrade provides these dependencies for the latest 3 Python versions (3.7, 3.8 and 3.9) and for 64bit Windows.
|
||||
Freqtrade provides these dependencies for the latest 3 Python versions (3.8, 3.9 and 3.10) and for 64bit Windows.
|
||||
Other versions must be downloaded from the above link.
|
||||
|
||||
``` powershell
|
||||
@@ -54,6 +54,8 @@ error: Microsoft Visual C++ 14.0 is required. Get it with "Microsoft Visual C++
|
||||
|
||||
Unfortunately, many packages requiring compilation don't provide a pre-built wheel. It is therefore mandatory to have a C/C++ compiler installed and available for your python environment to use.
|
||||
|
||||
The easiest way is to download install Microsoft Visual Studio Community [here](https://visualstudio.microsoft.com/downloads/) and make sure to install "Common Tools for Visual C++" to enable building C code on Windows. Unfortunately, this is a heavy download / dependency (~4Gb) so you might want to consider WSL or [docker compose](docker_quickstart.md) first.
|
||||
You can download the Visual C++ build tools from [here](https://visualstudio.microsoft.com/visual-cpp-build-tools/) and install "Desktop development with C++" in it's default configuration. Unfortunately, this is a heavy download / dependency so you might want to consider WSL2 or [docker compose](docker_quickstart.md) first.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
@@ -4,7 +4,7 @@ channels:
|
||||
# - defaults
|
||||
dependencies:
|
||||
# 1/4 req main
|
||||
- python>=3.7,<3.9
|
||||
- python>=3.8,<=3.10
|
||||
- numpy
|
||||
- pandas
|
||||
- pip
|
||||
@@ -25,9 +25,12 @@ dependencies:
|
||||
- fastapi
|
||||
- uvicorn
|
||||
- pyjwt
|
||||
- aiofiles
|
||||
- psutil
|
||||
- colorama
|
||||
- questionary
|
||||
- prompt-toolkit
|
||||
- python-dateutil
|
||||
|
||||
|
||||
# ============================
|
||||
|
@@ -1,5 +1,5 @@
|
||||
""" Freqtrade bot """
|
||||
__version__ = '2021.12'
|
||||
__version__ = '2022.2.1'
|
||||
|
||||
if __version__ == 'develop':
|
||||
|
||||
|
@@ -3,7 +3,7 @@
|
||||
__main__.py for Freqtrade
|
||||
To launch Freqtrade as a module
|
||||
|
||||
> python -m freqtrade (with Python >= 3.7)
|
||||
> python -m freqtrade (with Python >= 3.8)
|
||||
"""
|
||||
|
||||
from freqtrade import main
|
||||
|
@@ -24,7 +24,7 @@ ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "dataformat_ohlcv",
|
||||
ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions",
|
||||
"enable_protections", "dry_run_wallet", "timeframe_detail",
|
||||
"strategy_list", "export", "exportfilename",
|
||||
"backtest_breakdown"]
|
||||
"backtest_breakdown", "backtest_cache"]
|
||||
|
||||
ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path",
|
||||
"position_stacking", "use_max_market_positions",
|
||||
@@ -75,7 +75,7 @@ ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit",
|
||||
"timerange", "timeframe", "no_trades"]
|
||||
|
||||
ARGS_PLOT_PROFIT = ["pairs", "timerange", "export", "exportfilename", "db_url",
|
||||
"trade_source", "timeframe", "plot_auto_open"]
|
||||
"trade_source", "timeframe", "plot_auto_open", ]
|
||||
|
||||
ARGS_INSTALL_UI = ["erase_ui_only", 'ui_version']
|
||||
|
||||
|
@@ -76,17 +76,14 @@ def ask_user_config() -> Dict[str, Any]:
|
||||
{
|
||||
"type": "text",
|
||||
"name": "max_open_trades",
|
||||
"message": f"Please insert max_open_trades (Integer or '{UNLIMITED_STAKE_AMOUNT}'):",
|
||||
"message": "Please insert max_open_trades (Integer or -1 for unlimited open trades):",
|
||||
"default": "3",
|
||||
"validate": lambda val: val == UNLIMITED_STAKE_AMOUNT or validate_is_int(val),
|
||||
"filter": lambda val: '"' + UNLIMITED_STAKE_AMOUNT + '"'
|
||||
if val == UNLIMITED_STAKE_AMOUNT
|
||||
else val
|
||||
"validate": lambda val: validate_is_int(val)
|
||||
},
|
||||
{
|
||||
"type": "select",
|
||||
"name": "timeframe_in_config",
|
||||
"message": "Tim",
|
||||
"message": "Time",
|
||||
"choices": ["Have the strategy define timeframe.", "Override in configuration."]
|
||||
},
|
||||
{
|
||||
@@ -115,7 +112,7 @@ def ask_user_config() -> Dict[str, Any]:
|
||||
"ftx",
|
||||
"kucoin",
|
||||
"gateio",
|
||||
"okex",
|
||||
"okx",
|
||||
Separator(),
|
||||
"other",
|
||||
],
|
||||
@@ -143,7 +140,7 @@ def ask_user_config() -> Dict[str, Any]:
|
||||
"type": "password",
|
||||
"name": "exchange_key_password",
|
||||
"message": "Insert Exchange API Key password",
|
||||
"when": lambda x: not x['dry_run'] and x['exchange_name'] in ('kucoin', 'okex')
|
||||
"when": lambda x: not x['dry_run'] and x['exchange_name'] in ('kucoin', 'okx')
|
||||
},
|
||||
{
|
||||
"type": "confirm",
|
||||
|
@@ -182,11 +182,12 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
|
||||
),
|
||||
"exportfilename": Arg(
|
||||
'--export-filename',
|
||||
help='Save backtest results to the file with this filename. '
|
||||
'Requires `--export` to be set as well. '
|
||||
'Example: `--export-filename=user_data/backtest_results/backtest_today.json`',
|
||||
metavar='PATH',
|
||||
"--export-filename",
|
||||
"--backtest-filename",
|
||||
help="Use this filename for backtest results."
|
||||
"Requires `--export` to be set as well. "
|
||||
"Example: `--export-filename=user_data/backtest_results/backtest_today.json`",
|
||||
metavar="PATH",
|
||||
),
|
||||
"disableparamexport": Arg(
|
||||
'--disable-param-export',
|
||||
@@ -205,6 +206,12 @@ AVAILABLE_CLI_OPTIONS = {
|
||||
nargs='+',
|
||||
choices=constants.BACKTEST_BREAKDOWNS
|
||||
),
|
||||
"backtest_cache": Arg(
|
||||
'--cache',
|
||||
help='Load a cached backtest result no older than specified age (default: %(default)s).',
|
||||
default=constants.BACKTEST_CACHE_DEFAULT,
|
||||
choices=constants.BACKTEST_CACHE_AGE,
|
||||
),
|
||||
# Edge
|
||||
"stoploss_range": Arg(
|
||||
'--stoplosses',
|
||||
|
@@ -276,6 +276,9 @@ class Configuration:
|
||||
self._args_to_config(config, argname='backtest_breakdown',
|
||||
logstring='Parameter --breakdown detected ...')
|
||||
|
||||
self._args_to_config(config, argname='backtest_cache',
|
||||
logstring='Parameter --cache={} detected ...')
|
||||
|
||||
self._args_to_config(config, argname='disableparamexport',
|
||||
logstring='Parameter --disableparamexport detected: {} ...')
|
||||
|
||||
@@ -428,7 +431,6 @@ class Configuration:
|
||||
logstring='Using "{}" to store trades data.')
|
||||
|
||||
def _process_data_options(self, config: Dict[str, Any]) -> None:
|
||||
|
||||
self._args_to_config(config, argname='new_pairs_days',
|
||||
logstring='Detected --new-pairs-days: {}')
|
||||
|
||||
|
@@ -26,7 +26,7 @@ HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss',
|
||||
'SharpeHyperOptLoss', 'SharpeHyperOptLossDaily',
|
||||
'SortinoHyperOptLoss', 'SortinoHyperOptLossDaily',
|
||||
'CalmarHyperOptLoss',
|
||||
'MaxDrawDownHyperOptLoss']
|
||||
'MaxDrawDownHyperOptLoss', 'ProfitDrawDownHyperOptLoss']
|
||||
AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList',
|
||||
'AgeFilter', 'OffsetFilter', 'PerformanceFilter',
|
||||
'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter',
|
||||
@@ -34,6 +34,8 @@ AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList',
|
||||
AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard']
|
||||
AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5']
|
||||
BACKTEST_BREAKDOWNS = ['day', 'week', 'month']
|
||||
BACKTEST_CACHE_AGE = ['none', 'day', 'week', 'month']
|
||||
BACKTEST_CACHE_DEFAULT = 'day'
|
||||
DRY_RUN_WALLET = 1000
|
||||
DATETIME_PRINT_FORMAT = '%Y-%m-%d %H:%M:%S'
|
||||
MATH_CLOSE_PREC = 1e-14 # Precision used for float comparisons
|
||||
@@ -369,7 +371,9 @@ CONF_SCHEMA = {
|
||||
'type': 'string',
|
||||
'enum': AVAILABLE_DATAHANDLERS,
|
||||
'default': 'jsongz'
|
||||
}
|
||||
},
|
||||
'position_adjustment_enable': {'type': 'boolean'},
|
||||
'max_entry_position_adjustment': {'type': ['integer', 'number'], 'minimum': -1},
|
||||
},
|
||||
'definitions': {
|
||||
'exchange': {
|
||||
@@ -452,6 +456,7 @@ SCHEMA_BACKTEST_REQUIRED = [
|
||||
'dry_run_wallet',
|
||||
'dataformat_ohlcv',
|
||||
'dataformat_trades',
|
||||
'unfilledtimeout',
|
||||
]
|
||||
|
||||
SCHEMA_MINIMAL_REQUIRED = [
|
||||
|
@@ -2,6 +2,8 @@
|
||||
Helpers when analyzing backtest data
|
||||
"""
|
||||
import logging
|
||||
from copy import copy
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
@@ -9,21 +11,13 @@ import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
from freqtrade.constants import LAST_BT_RESULT_FN
|
||||
from freqtrade.misc import json_load
|
||||
from freqtrade.exceptions import OperationalException
|
||||
from freqtrade.misc import get_backtest_metadata_filename, json_load
|
||||
from freqtrade.persistence import LocalTrade, Trade, init_db
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Old format - maybe remove?
|
||||
BT_DATA_COLUMNS_OLD = ["pair", "profit_percent", "open_date", "close_date", "index",
|
||||
"trade_duration", "open_rate", "close_rate", "open_at_end", "sell_reason"]
|
||||
|
||||
# Mid-term format, created by BacktestResult Named Tuple
|
||||
BT_DATA_COLUMNS_MID = ['pair', 'profit_percent', 'open_date', 'close_date', 'trade_duration',
|
||||
'open_rate', 'close_rate', 'open_at_end', 'sell_reason', 'fee_open',
|
||||
'fee_close', 'amount', 'profit_abs', 'profit_ratio']
|
||||
|
||||
# Newest format
|
||||
BT_DATA_COLUMNS = ['pair', 'stake_amount', 'amount', 'open_date', 'close_date',
|
||||
'open_rate', 'close_rate',
|
||||
@@ -106,10 +100,30 @@ def get_latest_hyperopt_file(directory: Union[Path, str], predef_filename: str =
|
||||
if isinstance(directory, str):
|
||||
directory = Path(directory)
|
||||
if predef_filename:
|
||||
if Path(predef_filename).is_absolute():
|
||||
raise OperationalException(
|
||||
"--hyperopt-filename expects only the filename, not an absolute path.")
|
||||
return directory / predef_filename
|
||||
return directory / get_latest_hyperopt_filename(directory)
|
||||
|
||||
|
||||
def load_backtest_metadata(filename: Union[Path, str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Read metadata dictionary from backtest results file without reading and deserializing entire
|
||||
file.
|
||||
:param filename: path to backtest results file.
|
||||
:return: metadata dict or None if metadata is not present.
|
||||
"""
|
||||
filename = get_backtest_metadata_filename(filename)
|
||||
try:
|
||||
with filename.open() as fp:
|
||||
return json_load(fp)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except Exception as e:
|
||||
raise OperationalException('Unexpected error while loading backtest metadata.') from e
|
||||
|
||||
|
||||
def load_backtest_stats(filename: Union[Path, str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Load backtest statistics file.
|
||||
@@ -126,9 +140,80 @@ def load_backtest_stats(filename: Union[Path, str]) -> Dict[str, Any]:
|
||||
with filename.open() as file:
|
||||
data = json_load(file)
|
||||
|
||||
# Legacy list format does not contain metadata.
|
||||
if isinstance(data, dict):
|
||||
data['metadata'] = load_backtest_metadata(filename)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def _load_and_merge_backtest_result(strategy_name: str, filename: Path, results: Dict[str, Any]):
|
||||
bt_data = load_backtest_stats(filename)
|
||||
for k in ('metadata', 'strategy'):
|
||||
results[k][strategy_name] = bt_data[k][strategy_name]
|
||||
comparison = bt_data['strategy_comparison']
|
||||
for i in range(len(comparison)):
|
||||
if comparison[i]['key'] == strategy_name:
|
||||
results['strategy_comparison'].append(comparison[i])
|
||||
break
|
||||
|
||||
|
||||
def find_existing_backtest_stats(dirname: Union[Path, str], run_ids: Dict[str, str],
|
||||
min_backtest_date: datetime = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Find existing backtest stats that match specified run IDs and load them.
|
||||
:param dirname: pathlib.Path object, or string pointing to the file.
|
||||
:param run_ids: {strategy_name: id_string} dictionary.
|
||||
:param min_backtest_date: do not load a backtest older than specified date.
|
||||
:return: results dict.
|
||||
"""
|
||||
# Copy so we can modify this dict without affecting parent scope.
|
||||
run_ids = copy(run_ids)
|
||||
dirname = Path(dirname)
|
||||
results: Dict[str, Any] = {
|
||||
'metadata': {},
|
||||
'strategy': {},
|
||||
'strategy_comparison': [],
|
||||
}
|
||||
|
||||
# Weird glob expression here avoids including .meta.json files.
|
||||
for filename in reversed(sorted(dirname.glob('backtest-result-*-[0-9][0-9].json'))):
|
||||
metadata = load_backtest_metadata(filename)
|
||||
if not metadata:
|
||||
# Files are sorted from newest to oldest. When file without metadata is encountered it
|
||||
# is safe to assume older files will also not have any metadata.
|
||||
break
|
||||
|
||||
for strategy_name, run_id in list(run_ids.items()):
|
||||
strategy_metadata = metadata.get(strategy_name, None)
|
||||
if not strategy_metadata:
|
||||
# This strategy is not present in analyzed backtest.
|
||||
continue
|
||||
|
||||
if min_backtest_date is not None:
|
||||
try:
|
||||
backtest_date = strategy_metadata['backtest_start_time']
|
||||
except KeyError:
|
||||
# TODO: this can be removed starting from feb 2022
|
||||
# The metadata-file without start_time was only available in develop
|
||||
# and was never included in an official release.
|
||||
# Older metadata format without backtest time, too old to consider.
|
||||
return results
|
||||
backtest_date = datetime.fromtimestamp(backtest_date, tz=timezone.utc)
|
||||
if backtest_date < min_backtest_date:
|
||||
# Do not use a cached result for this strategy as first result is too old.
|
||||
del run_ids[strategy_name]
|
||||
continue
|
||||
|
||||
if strategy_metadata['run_id'] == run_id:
|
||||
del run_ids[strategy_name]
|
||||
_load_and_merge_backtest_result(strategy_name, filename, results)
|
||||
|
||||
if len(run_ids) == 0:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = None) -> pd.DataFrame:
|
||||
"""
|
||||
Load backtest data file.
|
||||
@@ -167,23 +252,9 @@ def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = Non
|
||||
)
|
||||
else:
|
||||
# old format - only with lists.
|
||||
df = pd.DataFrame(data, columns=BT_DATA_COLUMNS_OLD)
|
||||
if not df.empty:
|
||||
df['open_date'] = pd.to_datetime(df['open_date'],
|
||||
unit='s',
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
df['close_date'] = pd.to_datetime(df['close_date'],
|
||||
unit='s',
|
||||
utc=True,
|
||||
infer_datetime_format=True
|
||||
)
|
||||
# Create compatibility with new format
|
||||
df['profit_abs'] = df['close_rate'] - df['open_rate']
|
||||
raise OperationalException(
|
||||
"Backtest-results with only trades data are no longer supported.")
|
||||
if not df.empty:
|
||||
if 'profit_ratio' not in df.columns:
|
||||
df['profit_ratio'] = df['profit_percent']
|
||||
df = df.sort_values("open_date").reset_index(drop=True)
|
||||
return df
|
||||
|
||||
@@ -325,6 +396,7 @@ def combine_dataframes_with_mean(data: Dict[str, pd.DataFrame],
|
||||
:param column: Column in the original dataframes to use
|
||||
:return: DataFrame with the column renamed to the dict key, and a column
|
||||
named mean, containing the mean of all pairs.
|
||||
:raise: ValueError if no data is provided.
|
||||
"""
|
||||
df_comb = pd.concat([data[pair].set_index('date').rename(
|
||||
{column: pair}, axis=1)[pair] for pair in data], axis=1)
|
||||
@@ -360,9 +432,19 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str,
|
||||
return df
|
||||
|
||||
|
||||
def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date',
|
||||
value_col: str = 'profit_ratio'
|
||||
) -> Tuple[float, pd.Timestamp, pd.Timestamp, float, float]:
|
||||
def _calc_drawdown_series(profit_results: pd.DataFrame, *, date_col: str, value_col: str
|
||||
) -> pd.DataFrame:
|
||||
max_drawdown_df = pd.DataFrame()
|
||||
max_drawdown_df['cumulative'] = profit_results[value_col].cumsum()
|
||||
max_drawdown_df['high_value'] = max_drawdown_df['cumulative'].cummax()
|
||||
max_drawdown_df['drawdown'] = max_drawdown_df['cumulative'] - max_drawdown_df['high_value']
|
||||
max_drawdown_df['date'] = profit_results.loc[:, date_col]
|
||||
return max_drawdown_df
|
||||
|
||||
|
||||
def calculate_underwater(trades: pd.DataFrame, *, date_col: str = 'close_date',
|
||||
value_col: str = 'profit_ratio'
|
||||
):
|
||||
"""
|
||||
Calculate max drawdown and the corresponding close dates
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_ratio)
|
||||
@@ -375,10 +457,29 @@ def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date'
|
||||
if len(trades) == 0:
|
||||
raise ValueError("Trade dataframe empty.")
|
||||
profit_results = trades.sort_values(date_col).reset_index(drop=True)
|
||||
max_drawdown_df = pd.DataFrame()
|
||||
max_drawdown_df['cumulative'] = profit_results[value_col].cumsum()
|
||||
max_drawdown_df['high_value'] = max_drawdown_df['cumulative'].cummax()
|
||||
max_drawdown_df['drawdown'] = max_drawdown_df['cumulative'] - max_drawdown_df['high_value']
|
||||
max_drawdown_df = _calc_drawdown_series(profit_results, date_col=date_col, value_col=value_col)
|
||||
|
||||
return max_drawdown_df
|
||||
|
||||
|
||||
def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date',
|
||||
value_col: str = 'profit_abs', starting_balance: float = 0
|
||||
) -> Tuple[float, pd.Timestamp, pd.Timestamp, float, float, float]:
|
||||
"""
|
||||
Calculate max drawdown and the corresponding close dates
|
||||
:param trades: DataFrame containing trades (requires columns close_date and profit_ratio)
|
||||
:param date_col: Column in DataFrame to use for dates (defaults to 'close_date')
|
||||
:param value_col: Column in DataFrame to use for values (defaults to 'profit_abs')
|
||||
:param starting_balance: Portfolio starting balance - properly calculate relative drawdown.
|
||||
:return: Tuple (float, highdate, lowdate, highvalue, lowvalue, relative_drawdown)
|
||||
with absolute max drawdown, high and low time and high and low value,
|
||||
and the relative account drawdown
|
||||
:raise: ValueError if trade-dataframe was found empty.
|
||||
"""
|
||||
if len(trades) == 0:
|
||||
raise ValueError("Trade dataframe empty.")
|
||||
profit_results = trades.sort_values(date_col).reset_index(drop=True)
|
||||
max_drawdown_df = _calc_drawdown_series(profit_results, date_col=date_col, value_col=value_col)
|
||||
|
||||
idxmin = max_drawdown_df['drawdown'].idxmin()
|
||||
if idxmin == 0:
|
||||
@@ -388,7 +489,18 @@ def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_date'
|
||||
high_val = max_drawdown_df.loc[max_drawdown_df.iloc[:idxmin]
|
||||
['high_value'].idxmax(), 'cumulative']
|
||||
low_val = max_drawdown_df.loc[idxmin, 'cumulative']
|
||||
return abs(min(max_drawdown_df['drawdown'])), high_date, low_date, high_val, low_val
|
||||
max_drawdown_rel = 0.0
|
||||
if high_val + starting_balance != 0:
|
||||
max_drawdown_rel = (high_val - low_val) / (high_val + starting_balance)
|
||||
|
||||
return (
|
||||
abs(min(max_drawdown_df['drawdown'])),
|
||||
high_date,
|
||||
low_date,
|
||||
high_val,
|
||||
low_val,
|
||||
max_drawdown_rel
|
||||
)
|
||||
|
||||
|
||||
def calculate_csum(trades: pd.DataFrame, starting_balance: float = 0) -> Tuple[float, float]:
|
||||
|
@@ -5,7 +5,7 @@ from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import arrow
|
||||
from pandas import DataFrame
|
||||
from pandas import DataFrame, concat
|
||||
|
||||
from freqtrade.configuration import TimeRange
|
||||
from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS
|
||||
@@ -208,7 +208,7 @@ def _download_pair_history(pair: str, *,
|
||||
else:
|
||||
# Run cleaning again to ensure there were no duplicate candles
|
||||
# Especially between existing and new data.
|
||||
data = clean_ohlcv_dataframe(data.append(new_dataframe), timeframe, pair,
|
||||
data = clean_ohlcv_dataframe(concat([data, new_dataframe], axis=0), timeframe, pair,
|
||||
fill_missing=False, drop_incomplete=False)
|
||||
|
||||
logger.debug("New Start: %s",
|
||||
|
@@ -201,7 +201,7 @@ class IDataHandler(ABC):
|
||||
enddate = pairdf.iloc[-1]['date']
|
||||
|
||||
if timerange_startup:
|
||||
self._validate_pairdata(pair, pairdf, timerange_startup)
|
||||
self._validate_pairdata(pair, pairdf, timeframe, timerange_startup)
|
||||
pairdf = trim_dataframe(pairdf, timerange_startup)
|
||||
if self._check_empty_df(pairdf, pair, timeframe, warn_no_data):
|
||||
return pairdf
|
||||
@@ -228,7 +228,7 @@ class IDataHandler(ABC):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _validate_pairdata(self, pair, pairdata: DataFrame, timerange: TimeRange):
|
||||
def _validate_pairdata(self, pair, pairdata: DataFrame, timeframe: str, timerange: TimeRange):
|
||||
"""
|
||||
Validates pairdata for missing data at start end end and logs warnings.
|
||||
:param pairdata: Dataframe to validate
|
||||
@@ -238,12 +238,12 @@ class IDataHandler(ABC):
|
||||
if timerange.starttype == 'date':
|
||||
start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc)
|
||||
if pairdata.iloc[0]['date'] > start:
|
||||
logger.warning(f"Missing data at start for pair {pair}, "
|
||||
logger.warning(f"Missing data at start for pair {pair} at {timeframe}, "
|
||||
f"data starts at {pairdata.iloc[0]['date']:%Y-%m-%d %H:%M:%S}")
|
||||
if timerange.stoptype == 'date':
|
||||
stop = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc)
|
||||
if pairdata.iloc[-1]['date'] < stop:
|
||||
logger.warning(f"Missing data at end for pair {pair}, "
|
||||
logger.warning(f"Missing data at end for pair {pair} at {timeframe}, "
|
||||
f"data ends at {pairdata.iloc[-1]['date']:%Y-%m-%d %H:%M:%S}")
|
||||
|
||||
|
||||
|
@@ -20,4 +20,4 @@ from freqtrade.exchange.gateio import Gateio
|
||||
from freqtrade.exchange.hitbtc import Hitbtc
|
||||
from freqtrade.exchange.kraken import Kraken
|
||||
from freqtrade.exchange.kucoin import Kucoin
|
||||
from freqtrade.exchange.okex import Okex
|
||||
from freqtrade.exchange.okx import Okx
|
||||
|
@@ -4,9 +4,20 @@ import time
|
||||
from functools import wraps
|
||||
|
||||
from freqtrade.exceptions import DDosProtection, RetryableOrderError, TemporaryError
|
||||
from freqtrade.mixins import LoggingMixin
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
__logging_mixin = None
|
||||
|
||||
|
||||
def _get_logging_mixin():
|
||||
# Logging-mixin to cache kucoin responses
|
||||
# Only to be used in retrier
|
||||
global __logging_mixin
|
||||
if not __logging_mixin:
|
||||
__logging_mixin = LoggingMixin(logger)
|
||||
return __logging_mixin
|
||||
|
||||
|
||||
# Maximum default retry count.
|
||||
@@ -16,13 +27,15 @@ API_FETCH_ORDER_RETRY_COUNT = 5
|
||||
|
||||
BAD_EXCHANGES = {
|
||||
"bitmex": "Various reasons.",
|
||||
"phemex": "Does not provide history. ",
|
||||
"phemex": "Does not provide history.",
|
||||
"probit": "Requires additional, regular calls to `signIn()`.",
|
||||
"poloniex": "Does not provide fetch_order endpoint to fetch both open and closed orders.",
|
||||
}
|
||||
|
||||
MAP_EXCHANGE_CHILDCLASS = {
|
||||
'binanceus': 'binance',
|
||||
'binanceje': 'binance',
|
||||
'okex': 'okx',
|
||||
}
|
||||
|
||||
|
||||
@@ -72,28 +85,33 @@ def calculate_backoff(retrycount, max_retries):
|
||||
def retrier_async(f):
|
||||
async def wrapper(*args, **kwargs):
|
||||
count = kwargs.pop('count', API_RETRY_COUNT)
|
||||
kucoin = args[0].name == "Kucoin" # Check if the exchange is KuCoin.
|
||||
try:
|
||||
return await f(*args, **kwargs)
|
||||
except TemporaryError as ex:
|
||||
logger.warning('%s() returned exception: "%s"', f.__name__, ex)
|
||||
msg = f'{f.__name__}() returned exception: "{ex}". '
|
||||
if count > 0:
|
||||
logger.warning('retrying %s() still for %s times', f.__name__, count)
|
||||
msg += f'Retrying still for {count} times.'
|
||||
count -= 1
|
||||
kwargs.update({'count': count})
|
||||
kwargs['count'] = count
|
||||
if isinstance(ex, DDosProtection):
|
||||
if "kucoin" in str(ex) and "429000" in str(ex):
|
||||
if kucoin and "429000" in str(ex):
|
||||
# Temporary fix for 429000 error on kucoin
|
||||
# see https://github.com/freqtrade/freqtrade/issues/5700 for details.
|
||||
logger.warning(
|
||||
_get_logging_mixin().log_once(
|
||||
f"Kucoin 429 error, avoid triggering DDosProtection backoff delay. "
|
||||
f"{count} tries left before giving up")
|
||||
f"{count} tries left before giving up", logmethod=logger.warning)
|
||||
# Reset msg to avoid logging too many times.
|
||||
msg = ''
|
||||
else:
|
||||
backoff_delay = calculate_backoff(count + 1, API_RETRY_COUNT)
|
||||
logger.info(f"Applying DDosProtection backoff delay: {backoff_delay}")
|
||||
await asyncio.sleep(backoff_delay)
|
||||
if msg:
|
||||
logger.warning(msg)
|
||||
return await wrapper(*args, **kwargs)
|
||||
else:
|
||||
logger.warning('Giving up retrying: %s()', f.__name__)
|
||||
logger.warning(msg + 'Giving up.')
|
||||
raise ex
|
||||
return wrapper
|
||||
|
||||
@@ -106,9 +124,9 @@ def retrier(_func=None, retries=API_RETRY_COUNT):
|
||||
try:
|
||||
return f(*args, **kwargs)
|
||||
except (TemporaryError, RetryableOrderError) as ex:
|
||||
logger.warning('%s() returned exception: "%s"', f.__name__, ex)
|
||||
msg = f'{f.__name__}() returned exception: "{ex}". '
|
||||
if count > 0:
|
||||
logger.warning('retrying %s() still for %s times', f.__name__, count)
|
||||
logger.warning(msg + f'Retrying still for {count} times.')
|
||||
count -= 1
|
||||
kwargs.update({'count': count})
|
||||
if isinstance(ex, (DDosProtection, RetryableOrderError)):
|
||||
@@ -118,7 +136,7 @@ def retrier(_func=None, retries=API_RETRY_COUNT):
|
||||
time.sleep(backoff_delay)
|
||||
return wrapper(*args, **kwargs)
|
||||
else:
|
||||
logger.warning('Giving up retrying: %s()', f.__name__)
|
||||
logger.warning(msg + 'Giving up.')
|
||||
raise ex
|
||||
return wrapper
|
||||
# Support both @retrier and @retrier(retries=2) syntax
|
||||
|
@@ -67,6 +67,8 @@ class Exchange:
|
||||
"ohlcv_params": {},
|
||||
"ohlcv_candle_limit": 500,
|
||||
"ohlcv_partial_candle": True,
|
||||
# Check https://github.com/ccxt/ccxt/issues/10767 for removal of ohlcv_volume_currency
|
||||
"ohlcv_volume_currency": "base", # "base" or "quote"
|
||||
"trades_pagination": "time", # Possible are "time" or "id"
|
||||
"trades_pagination_arg": "since",
|
||||
"l2_limit_range": None,
|
||||
@@ -83,6 +85,8 @@ class Exchange:
|
||||
self._api: ccxt.Exchange = None
|
||||
self._api_async: ccxt_async.Exchange = None
|
||||
self._markets: Dict = {}
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
|
||||
self._config.update(config)
|
||||
|
||||
@@ -170,8 +174,10 @@ class Exchange:
|
||||
|
||||
def close(self):
|
||||
logger.debug("Exchange object destroyed, closing async loop")
|
||||
if self._api_async and inspect.iscoroutinefunction(self._api_async.close):
|
||||
asyncio.get_event_loop().run_until_complete(self._api_async.close())
|
||||
if (self._api_async and inspect.iscoroutinefunction(self._api_async.close)
|
||||
and self._api_async.session):
|
||||
logger.info("Closing async ccxt session.")
|
||||
self.loop.run_until_complete(self._api_async.close())
|
||||
|
||||
def _init_ccxt(self, exchange_config: Dict[str, Any], ccxt_module: CcxtModuleType = ccxt,
|
||||
ccxt_kwargs: Dict = {}) -> ccxt.Exchange:
|
||||
@@ -326,7 +332,7 @@ class Exchange:
|
||||
def _load_async_markets(self, reload: bool = False) -> None:
|
||||
try:
|
||||
if self._api_async:
|
||||
asyncio.get_event_loop().run_until_complete(
|
||||
self.loop.run_until_complete(
|
||||
self._api_async.load_markets(reload=reload))
|
||||
|
||||
except (asyncio.TimeoutError, ccxt.BaseError) as e:
|
||||
@@ -606,8 +612,9 @@ class Exchange:
|
||||
'cost': _amount * rate,
|
||||
'type': ordertype,
|
||||
'side': side,
|
||||
'filled': 0,
|
||||
'remaining': _amount,
|
||||
'datetime': arrow.utcnow().isoformat(),
|
||||
'datetime': arrow.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
|
||||
'timestamp': arrow.utcnow().int_timestamp * 1000,
|
||||
'status': "closed" if ordertype == "market" else "open",
|
||||
'fee': None,
|
||||
@@ -621,6 +628,7 @@ class Exchange:
|
||||
average = self.get_dry_market_fill_price(pair, side, amount, rate)
|
||||
dry_order.update({
|
||||
'average': average,
|
||||
'filled': _amount,
|
||||
'cost': dry_order['amount'] * average,
|
||||
})
|
||||
dry_order = self.add_dry_order_fee(pair, dry_order)
|
||||
@@ -652,7 +660,8 @@ class Exchange:
|
||||
max_slippage_val = rate * ((1 + slippage) if side == 'buy' else (1 - slippage))
|
||||
|
||||
remaining_amount = amount
|
||||
filled_amount = 0
|
||||
filled_amount = 0.0
|
||||
book_entry_price = 0.0
|
||||
for book_entry in ob[ob_type]:
|
||||
book_entry_price = book_entry[0]
|
||||
book_entry_coin_volume = book_entry[1]
|
||||
@@ -944,7 +953,7 @@ class Exchange:
|
||||
raise OperationalException(e) from e
|
||||
|
||||
@retrier
|
||||
def get_tickers(self, cached: bool = False) -> Dict:
|
||||
def get_tickers(self, symbols: List[str] = None, cached: bool = False) -> Dict:
|
||||
"""
|
||||
:param cached: Allow cached result
|
||||
:return: fetch_tickers result
|
||||
@@ -954,7 +963,7 @@ class Exchange:
|
||||
if tickers:
|
||||
return tickers
|
||||
try:
|
||||
tickers = self._api.fetch_tickers()
|
||||
tickers = self._api.fetch_tickers(symbols)
|
||||
self._fetch_tickers_cache['fetch_tickers'] = tickers
|
||||
return tickers
|
||||
except ccxt.NotSupported as e:
|
||||
@@ -1227,7 +1236,7 @@ class Exchange:
|
||||
:param since_ms: Timestamp in milliseconds to get history from
|
||||
:return: List with candle (OHLCV) data
|
||||
"""
|
||||
pair, timeframe, data = asyncio.get_event_loop().run_until_complete(
|
||||
pair, timeframe, data = self.loop.run_until_complete(
|
||||
self._async_get_historic_ohlcv(pair=pair, timeframe=timeframe,
|
||||
since_ms=since_ms, is_new_pair=is_new_pair))
|
||||
logger.info(f"Downloaded data for {pair} with length {len(data)}.")
|
||||
@@ -1329,8 +1338,10 @@ class Exchange:
|
||||
results_df = {}
|
||||
# Chunk requests into batches of 100 to avoid overwelming ccxt Throttling
|
||||
for input_coro in chunks(input_coroutines, 100):
|
||||
results = asyncio.get_event_loop().run_until_complete(
|
||||
asyncio.gather(*input_coro, return_exceptions=True))
|
||||
async def gather_stuff():
|
||||
return await asyncio.gather(*input_coro, return_exceptions=True)
|
||||
|
||||
results = self.loop.run_until_complete(gather_stuff())
|
||||
|
||||
# handle caching
|
||||
for res in results:
|
||||
@@ -1566,7 +1577,7 @@ class Exchange:
|
||||
if not self.exchange_has("fetchTrades"):
|
||||
raise OperationalException("This exchange does not support downloading Trades.")
|
||||
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
return self.loop.run_until_complete(
|
||||
self._async_get_trade_history(pair=pair, since=since,
|
||||
until=until, from_id=from_id))
|
||||
|
||||
@@ -1576,7 +1587,7 @@ def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = Non
|
||||
|
||||
|
||||
def is_exchange_officially_supported(exchange_name: str) -> bool:
|
||||
return exchange_name in ['bittrex', 'binance', 'kraken', 'ftx', 'gateio', 'okex']
|
||||
return exchange_name in ['bittrex', 'binance', 'kraken', 'ftx', 'gateio', 'okx']
|
||||
|
||||
|
||||
def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]:
|
||||
|
@@ -19,6 +19,7 @@ class Ftx(Exchange):
|
||||
_ft_has: Dict = {
|
||||
"stoploss_on_exchange": True,
|
||||
"ohlcv_candle_limit": 1500,
|
||||
"ohlcv_volume_currency": "quote",
|
||||
}
|
||||
|
||||
def market_is_tradable(self, market: Dict[str, Any]) -> bool:
|
||||
@@ -105,15 +106,18 @@ class Ftx(Exchange):
|
||||
if order[0].get('status') == 'closed':
|
||||
# Trigger order was triggered ...
|
||||
real_order_id = order[0].get('info', {}).get('orderId')
|
||||
# OrderId may be None for stoploss-market orders
|
||||
# But contains "average" in these cases.
|
||||
if real_order_id:
|
||||
order1 = self._api.fetch_order(real_order_id, pair)
|
||||
self._log_exchange_response('fetch_stoploss_order1', order1)
|
||||
# Fake type to stop - as this was really a stop order.
|
||||
order1['id_stop'] = order1['id']
|
||||
order1['id'] = order_id
|
||||
order1['type'] = 'stop'
|
||||
order1['status_stop'] = 'triggered'
|
||||
return order1
|
||||
|
||||
order1 = self._api.fetch_order(real_order_id, pair)
|
||||
self._log_exchange_response('fetch_stoploss_order1', order1)
|
||||
# Fake type to stop - as this was really a stop order.
|
||||
order1['id_stop'] = order1['id']
|
||||
order1['id'] = order_id
|
||||
order1['type'] = 'stop'
|
||||
order1['status_stop'] = 'triggered'
|
||||
return order1
|
||||
return order[0]
|
||||
else:
|
||||
raise InvalidOrderException(f"Could not get stoploss order for id {order_id}")
|
||||
|
@@ -21,6 +21,7 @@ class Gateio(Exchange):
|
||||
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit": 1000,
|
||||
"ohlcv_volume_currency": "quote",
|
||||
}
|
||||
|
||||
_headers = {'X-Gate-Channel-Id': 'freqtrade'}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
""" Kraken exchange subclass """
|
||||
import logging
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import ccxt
|
||||
|
||||
@@ -33,6 +33,12 @@ class Kraken(Exchange):
|
||||
return (parent_check and
|
||||
market.get('darkpool', False) is False)
|
||||
|
||||
def get_tickers(self, symbols: List[str] = None, cached: bool = False) -> Dict:
|
||||
# Only fetch tickers for current stake currency
|
||||
# Otherwise the request for kraken becomes too large.
|
||||
symbols = list(self.get_markets(quote_currencies=[self._config['stake_currency']]))
|
||||
return super().get_tickers(symbols=symbols, cached=cached)
|
||||
|
||||
@retrier
|
||||
def get_balances(self) -> dict:
|
||||
if self._config['dry_run']:
|
||||
|
@@ -7,12 +7,12 @@ from freqtrade.exchange import Exchange
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Okex(Exchange):
|
||||
"""Okex exchange class.
|
||||
class Okx(Exchange):
|
||||
"""Okx exchange class.
|
||||
|
||||
Contains adjustments needed for Freqtrade to work with this exchange.
|
||||
"""
|
||||
|
||||
_ft_has: Dict = {
|
||||
"ohlcv_candle_limit": 100,
|
||||
"ohlcv_candle_limit": 300,
|
||||
}
|
@@ -7,16 +7,14 @@ import traceback
|
||||
from datetime import datetime, timezone
|
||||
from math import isclose
|
||||
from threading import Lock
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import arrow
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from freqtrade import __version__, constants
|
||||
from freqtrade.configuration import validate_config_consistency
|
||||
from freqtrade.data.converter import order_book_to_dataframe
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.edge import Edge
|
||||
from freqtrade.enums import RPCMessageType, SellType, State
|
||||
from freqtrade.enums import RPCMessageType, RunMode, SellType, State
|
||||
from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError,
|
||||
InvalidOrderException, PricingError)
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
|
||||
@@ -102,6 +100,8 @@ class FreqtradeBot(LoggingMixin):
|
||||
self._exit_lock = Lock()
|
||||
LoggingMixin.__init__(self, logger, timeframe_to_seconds(self.strategy.timeframe))
|
||||
|
||||
self.last_process = datetime(1970, 1, 1, tzinfo=timezone.utc)
|
||||
|
||||
def notify_status(self, msg: str) -> None:
|
||||
"""
|
||||
Public method for users of this class (worker, etc.) to send notifications
|
||||
@@ -126,6 +126,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
self.rpc.cleanup()
|
||||
cleanup_db()
|
||||
self.exchange.close()
|
||||
|
||||
def startup(self) -> None:
|
||||
"""
|
||||
@@ -178,11 +179,17 @@ class FreqtradeBot(LoggingMixin):
|
||||
# First process current opened trades (positions)
|
||||
self.exit_positions(trades)
|
||||
|
||||
# Check if we need to adjust our current positions before attempting to buy new trades.
|
||||
if self.strategy.position_adjustment_enable:
|
||||
with self._exit_lock:
|
||||
self.process_open_trade_positions()
|
||||
|
||||
# Then looking for buy opportunities
|
||||
if self.get_free_open_trades():
|
||||
self.enter_positions()
|
||||
|
||||
Trade.commit()
|
||||
self.last_process = datetime.now(timezone.utc)
|
||||
|
||||
def process_stopped(self) -> None:
|
||||
"""
|
||||
@@ -285,33 +292,12 @@ class FreqtradeBot(LoggingMixin):
|
||||
for trade in trades:
|
||||
if trade.is_open and not trade.fee_updated('buy'):
|
||||
order = trade.select_order('buy', False)
|
||||
if order:
|
||||
open_order = trade.select_order('buy', True)
|
||||
if order and open_order is None:
|
||||
logger.info(f"Updating buy-fee on trade {trade} for order {order.order_id}.")
|
||||
self.update_trade_state(trade, order.order_id, send_msg=False)
|
||||
|
||||
def handle_insufficient_funds(self, trade: Trade):
|
||||
"""
|
||||
Determine if we ever opened a sell order for this trade.
|
||||
If not, try update buy fees - otherwise "refind" the open order we obviously lost.
|
||||
"""
|
||||
sell_order = trade.select_order('sell', None)
|
||||
if sell_order:
|
||||
self.refind_lost_order(trade)
|
||||
else:
|
||||
self.reupdate_enter_order_fees(trade)
|
||||
|
||||
def reupdate_enter_order_fees(self, trade: Trade):
|
||||
"""
|
||||
Get buy order from database, and try to reupdate.
|
||||
Handles trades where the initial fee-update did not work.
|
||||
"""
|
||||
logger.info(f"Trying to reupdate buy fees for {trade}")
|
||||
order = trade.select_order('buy', False)
|
||||
if order:
|
||||
logger.info(f"Updating buy-fee on trade {trade} for order {order.order_id}.")
|
||||
self.update_trade_state(trade, order.order_id, send_msg=False)
|
||||
|
||||
def refind_lost_order(self, trade):
|
||||
"""
|
||||
Try refinding a lost trade.
|
||||
Only used when InsufficientFunds appears on sell orders (stoploss or sell).
|
||||
@@ -324,9 +310,6 @@ class FreqtradeBot(LoggingMixin):
|
||||
if not order.ft_is_open:
|
||||
logger.debug(f"Order {order} is no longer open.")
|
||||
continue
|
||||
if order.ft_order_side == 'buy':
|
||||
# Skip buy side - this is handled by reupdate_buy_order_fees
|
||||
continue
|
||||
try:
|
||||
fo = self.exchange.fetch_order_or_stoploss_order(order.order_id, order.ft_pair,
|
||||
order.ft_order_side == 'stoploss')
|
||||
@@ -338,6 +321,9 @@ class FreqtradeBot(LoggingMixin):
|
||||
if fo and fo['status'] == 'open':
|
||||
# Assume this as the open order
|
||||
trade.open_order_id = order.order_id
|
||||
elif order.ft_order_side == 'buy':
|
||||
if fo and fo['status'] == 'open':
|
||||
trade.open_order_id = order.order_id
|
||||
if fo:
|
||||
logger.info(f"Found {order} for trade {trade}.")
|
||||
self.update_trade_state(trade, order.order_id, fo,
|
||||
@@ -443,6 +429,59 @@ class FreqtradeBot(LoggingMixin):
|
||||
else:
|
||||
return False
|
||||
|
||||
#
|
||||
# BUY / increase positions / DCA logic and methods
|
||||
#
|
||||
def process_open_trade_positions(self):
|
||||
"""
|
||||
Tries to execute additional buy or sell orders for open trades (positions)
|
||||
"""
|
||||
# Walk through each pair and check if it needs changes
|
||||
for trade in Trade.get_open_trades():
|
||||
# If there is any open orders, wait for them to finish.
|
||||
if trade.open_order_id is None:
|
||||
try:
|
||||
self.check_and_call_adjust_trade_position(trade)
|
||||
except DependencyException as exception:
|
||||
logger.warning(
|
||||
f"Unable to adjust position of trade for {trade.pair}: {exception}")
|
||||
|
||||
def check_and_call_adjust_trade_position(self, trade: Trade):
|
||||
"""
|
||||
Check the implemented trading strategy for adjustment command.
|
||||
If the strategy triggers the adjustment, a new order gets issued.
|
||||
Once that completes, the existing trade is modified to match new data.
|
||||
"""
|
||||
if self.strategy.max_entry_position_adjustment > -1:
|
||||
count_of_buys = trade.nr_of_successful_buys
|
||||
if count_of_buys > self.strategy.max_entry_position_adjustment:
|
||||
logger.debug(f"Max adjustment entries for {trade.pair} has been reached.")
|
||||
return
|
||||
else:
|
||||
logger.debug("Max adjustment entries is set to unlimited.")
|
||||
current_rate = self.exchange.get_rate(trade.pair, refresh=True, side="buy")
|
||||
current_profit = trade.calc_profit_ratio(current_rate)
|
||||
|
||||
min_stake_amount = self.exchange.get_min_pair_stake_amount(trade.pair,
|
||||
current_rate,
|
||||
self.strategy.stoploss)
|
||||
max_stake_amount = self.wallets.get_available_stake_amount()
|
||||
logger.debug(f"Calling adjust_trade_position for pair {trade.pair}")
|
||||
stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position,
|
||||
default_retval=None)(
|
||||
trade=trade, current_time=datetime.now(timezone.utc), current_rate=current_rate,
|
||||
current_profit=current_profit, min_stake=min_stake_amount, max_stake=max_stake_amount)
|
||||
|
||||
if stake_amount is not None and stake_amount > 0.0:
|
||||
# We should increase our position
|
||||
self.execute_entry(trade.pair, stake_amount, trade=trade)
|
||||
|
||||
if stake_amount is not None and stake_amount < 0.0:
|
||||
# We should decrease our position
|
||||
# TODO: Selling part of the trade not implemented yet.
|
||||
logger.error(f"Unable to decrease trade position / sell partially"
|
||||
f" for pair {trade.pair}, feature not implemented.")
|
||||
|
||||
def _check_depth_of_market_buy(self, pair: str, conf: Dict) -> bool:
|
||||
"""
|
||||
Checks depth of market before executing a buy
|
||||
@@ -468,54 +507,39 @@ class FreqtradeBot(LoggingMixin):
|
||||
return False
|
||||
|
||||
def execute_entry(self, pair: str, stake_amount: float, price: Optional[float] = None, *,
|
||||
ordertype: Optional[str] = None, buy_tag: Optional[str] = None) -> bool:
|
||||
ordertype: Optional[str] = None, buy_tag: Optional[str] = None,
|
||||
trade: Optional[Trade] = None) -> bool:
|
||||
"""
|
||||
Executes a limit buy for the given pair
|
||||
:param pair: pair for which we want to create a LIMIT_BUY
|
||||
:param stake_amount: amount of stake-currency for the pair
|
||||
:return: True if a buy order is created, false if it fails.
|
||||
"""
|
||||
time_in_force = self.strategy.order_time_in_force['buy']
|
||||
|
||||
if price:
|
||||
enter_limit_requested = price
|
||||
else:
|
||||
# Calculate price
|
||||
proposed_enter_rate = self.exchange.get_rate(pair, refresh=True, side="buy")
|
||||
custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price,
|
||||
default_retval=proposed_enter_rate)(
|
||||
pair=pair, current_time=datetime.now(timezone.utc),
|
||||
proposed_rate=proposed_enter_rate)
|
||||
pos_adjust = trade is not None
|
||||
|
||||
enter_limit_requested = self.get_valid_price(custom_entry_price, proposed_enter_rate)
|
||||
|
||||
if not enter_limit_requested:
|
||||
raise PricingError('Could not determine buy price.')
|
||||
|
||||
min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, enter_limit_requested,
|
||||
self.strategy.stoploss)
|
||||
|
||||
if not self.edge:
|
||||
max_stake_amount = self.wallets.get_available_stake_amount()
|
||||
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
|
||||
default_retval=stake_amount)(
|
||||
pair=pair, current_time=datetime.now(timezone.utc),
|
||||
current_rate=enter_limit_requested, proposed_stake=stake_amount,
|
||||
min_stake=min_stake_amount, max_stake=max_stake_amount)
|
||||
stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount)
|
||||
enter_limit_requested, stake_amount = self.get_valid_enter_price_and_stake(
|
||||
pair, price, stake_amount, buy_tag, trade)
|
||||
|
||||
if not stake_amount:
|
||||
return False
|
||||
|
||||
logger.info(f"Buy signal found: about create a new trade for {pair} with stake_amount: "
|
||||
f"{stake_amount} ...")
|
||||
if pos_adjust:
|
||||
logger.info(f"Position adjust: about to create a new order for {pair} with stake: "
|
||||
f"{stake_amount} for {trade}")
|
||||
else:
|
||||
logger.info(f"Buy signal found: about create a new trade for {pair} with stake_amount: "
|
||||
f"{stake_amount} ...")
|
||||
|
||||
amount = stake_amount / enter_limit_requested
|
||||
order_type = ordertype or self.strategy.order_types['buy']
|
||||
time_in_force = self.strategy.order_time_in_force['buy']
|
||||
|
||||
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
|
||||
if not pos_adjust and not strategy_safe_wrapper(
|
||||
self.strategy.confirm_trade_entry, default_retval=True)(
|
||||
pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested,
|
||||
time_in_force=time_in_force, current_time=datetime.now(timezone.utc)):
|
||||
time_in_force=time_in_force, current_time=datetime.now(timezone.utc),
|
||||
entry_tag=buy_tag):
|
||||
logger.info(f"User requested abortion of buying {pair}")
|
||||
return False
|
||||
amount = self.exchange.amount_to_precision(pair, amount)
|
||||
@@ -525,6 +549,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
order_obj = Order.parse_from_ccxt_object(order, pair, 'buy')
|
||||
order_id = order['id']
|
||||
order_status = order.get('status', None)
|
||||
logger.info(f"Order #{order_id} was created for {pair} and status is {order_status}.")
|
||||
|
||||
# we assume the order is executed at the price requested
|
||||
enter_limit_filled_price = enter_limit_requested
|
||||
@@ -560,32 +585,49 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
# 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,
|
||||
is_open=True,
|
||||
amount_requested=amount_requested,
|
||||
fee_open=fee,
|
||||
fee_close=fee,
|
||||
open_rate=enter_limit_filled_price,
|
||||
open_rate_requested=enter_limit_requested,
|
||||
open_date=datetime.utcnow(),
|
||||
exchange=self.exchange.id,
|
||||
open_order_id=order_id,
|
||||
strategy=self.strategy.get_strategy_name(),
|
||||
buy_tag=buy_tag,
|
||||
timeframe=timeframe_to_minutes(self.config['timeframe'])
|
||||
)
|
||||
trade.orders.append(order_obj)
|
||||
# This is a new trade
|
||||
if trade is None:
|
||||
trade = Trade(
|
||||
pair=pair,
|
||||
stake_amount=stake_amount,
|
||||
amount=amount,
|
||||
is_open=True,
|
||||
amount_requested=amount_requested,
|
||||
fee_open=fee,
|
||||
fee_close=fee,
|
||||
open_rate=enter_limit_filled_price,
|
||||
open_rate_requested=enter_limit_requested,
|
||||
open_date=datetime.utcnow(),
|
||||
exchange=self.exchange.id,
|
||||
open_order_id=order_id,
|
||||
fee_open_currency=None,
|
||||
strategy=self.strategy.get_strategy_name(),
|
||||
buy_tag=buy_tag,
|
||||
timeframe=timeframe_to_minutes(self.config['timeframe'])
|
||||
)
|
||||
else:
|
||||
# This is additional buy, we reset fee_open_currency so timeout checking can work
|
||||
trade.is_open = True
|
||||
trade.fee_open_currency = None
|
||||
trade.open_rate_requested = enter_limit_requested
|
||||
trade.open_order_id = order_id
|
||||
|
||||
trade.orders.append(order_obj)
|
||||
trade.recalc_trade_from_orders()
|
||||
Trade.query.session.add(trade)
|
||||
Trade.commit()
|
||||
|
||||
# Updating wallets
|
||||
self.wallets.update()
|
||||
|
||||
self._notify_enter(trade, order_type)
|
||||
self._notify_enter(trade, order, order_type)
|
||||
|
||||
if pos_adjust:
|
||||
if order_status == 'closed':
|
||||
logger.info(f"DCA order closed, trade should be up to date: {trade}")
|
||||
trade = self.cancel_stoploss_on_exchange(trade)
|
||||
else:
|
||||
logger.info(f"DCA order {order_status}, will wait for resolution: {trade}")
|
||||
|
||||
# Update fees if order is closed
|
||||
if order_status == 'closed':
|
||||
@@ -593,26 +635,75 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
return True
|
||||
|
||||
def _notify_enter(self, trade: Trade, order_type: Optional[str] = None,
|
||||
def cancel_stoploss_on_exchange(self, trade: Trade) -> Trade:
|
||||
# First cancelling stoploss on exchange ...
|
||||
if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id:
|
||||
try:
|
||||
logger.info(f"Canceling stoploss on exchange for {trade}")
|
||||
co = self.exchange.cancel_stoploss_order_with_result(
|
||||
trade.stoploss_order_id, trade.pair, trade.amount)
|
||||
trade.update_order(co)
|
||||
except InvalidOrderException:
|
||||
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
|
||||
return trade
|
||||
|
||||
def get_valid_enter_price_and_stake(
|
||||
self, pair: str, price: Optional[float], stake_amount: float,
|
||||
entry_tag: Optional[str],
|
||||
trade: Optional[Trade]) -> Tuple[float, float]:
|
||||
if price:
|
||||
enter_limit_requested = price
|
||||
else:
|
||||
# Calculate price
|
||||
proposed_enter_rate = self.exchange.get_rate(pair, refresh=True, side="buy")
|
||||
custom_entry_price = strategy_safe_wrapper(self.strategy.custom_entry_price,
|
||||
default_retval=proposed_enter_rate)(
|
||||
pair=pair, current_time=datetime.now(timezone.utc),
|
||||
proposed_rate=proposed_enter_rate, entry_tag=entry_tag)
|
||||
|
||||
enter_limit_requested = self.get_valid_price(custom_entry_price, proposed_enter_rate)
|
||||
if not enter_limit_requested:
|
||||
raise PricingError('Could not determine buy price.')
|
||||
min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, enter_limit_requested,
|
||||
self.strategy.stoploss)
|
||||
if not self.edge and trade is None:
|
||||
max_stake_amount = self.wallets.get_available_stake_amount()
|
||||
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
|
||||
default_retval=stake_amount)(
|
||||
pair=pair, current_time=datetime.now(timezone.utc),
|
||||
current_rate=enter_limit_requested, proposed_stake=stake_amount,
|
||||
min_stake=min_stake_amount, max_stake=max_stake_amount, entry_tag=entry_tag)
|
||||
stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount)
|
||||
return enter_limit_requested, stake_amount
|
||||
|
||||
def _notify_enter(self, trade: Trade, order: Dict, order_type: Optional[str] = None,
|
||||
fill: bool = False) -> None:
|
||||
"""
|
||||
Sends rpc notification when a buy occurred.
|
||||
"""
|
||||
open_rate = safe_value_fallback(order, 'average', 'price')
|
||||
if open_rate is None:
|
||||
open_rate = trade.open_rate
|
||||
|
||||
current_rate = trade.open_rate_requested
|
||||
if self.dataprovider.runmode in (RunMode.DRY_RUN, RunMode.LIVE):
|
||||
current_rate = self.exchange.get_rate(trade.pair, refresh=False, side="buy")
|
||||
|
||||
msg = {
|
||||
'trade_id': trade.id,
|
||||
'type': RPCMessageType.BUY_FILL if fill else RPCMessageType.BUY,
|
||||
'buy_tag': trade.buy_tag,
|
||||
'exchange': self.exchange.name.capitalize(),
|
||||
'pair': trade.pair,
|
||||
'limit': trade.open_rate, # Deprecated (?)
|
||||
'open_rate': trade.open_rate,
|
||||
'limit': open_rate, # Deprecated (?)
|
||||
'open_rate': open_rate,
|
||||
'order_type': order_type,
|
||||
'stake_amount': trade.stake_amount,
|
||||
'stake_currency': self.config['stake_currency'],
|
||||
'fiat_currency': self.config.get('fiat_display_currency', None),
|
||||
'amount': trade.amount,
|
||||
'amount': safe_value_fallback(order, 'filled', 'amount') or trade.amount,
|
||||
'open_date': trade.open_date or datetime.utcnow(),
|
||||
'current_rate': trade.open_rate_requested,
|
||||
'current_rate': current_rate,
|
||||
}
|
||||
|
||||
# Send the message
|
||||
@@ -856,20 +947,6 @@ class FreqtradeBot(LoggingMixin):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _check_timed_out(self, side: str, order: dict) -> bool:
|
||||
"""
|
||||
Check if timeout is active, and if the order is still open and timed out
|
||||
"""
|
||||
timeout = self.config.get('unfilledtimeout', {}).get(side)
|
||||
ordertime = arrow.get(order['datetime']).datetime
|
||||
if timeout is not None:
|
||||
timeout_unit = self.config.get('unfilledtimeout', {}).get('unit', 'minutes')
|
||||
timeout_kwargs = {timeout_unit: -timeout}
|
||||
timeout_threshold = arrow.utcnow().shift(**timeout_kwargs).datetime
|
||||
return (order['status'] == 'open' and order['side'] == side
|
||||
and ordertime < timeout_threshold)
|
||||
return False
|
||||
|
||||
def check_handle_timedout(self) -> None:
|
||||
"""
|
||||
Check if any orders are timed out and cancel if necessary
|
||||
@@ -888,26 +965,24 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
fully_cancelled = self.update_trade_state(trade, trade.open_order_id, order)
|
||||
|
||||
order_obj = trade.select_order_by_order_id(trade.open_order_id)
|
||||
|
||||
if (order['side'] == 'buy' and (order['status'] == 'open' or fully_cancelled) and (
|
||||
fully_cancelled
|
||||
or self._check_timed_out('buy', order)
|
||||
or strategy_safe_wrapper(self.strategy.check_buy_timeout,
|
||||
default_retval=False)(pair=trade.pair,
|
||||
trade=trade,
|
||||
order=order))):
|
||||
or (order_obj and self.strategy.ft_check_timed_out(
|
||||
'buy', trade, order_obj, datetime.now(timezone.utc))
|
||||
))):
|
||||
self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT'])
|
||||
|
||||
elif (order['side'] == 'sell' and (order['status'] == 'open' or fully_cancelled) and (
|
||||
fully_cancelled
|
||||
or self._check_timed_out('sell', order)
|
||||
or strategy_safe_wrapper(self.strategy.check_sell_timeout,
|
||||
default_retval=False)(pair=trade.pair,
|
||||
trade=trade,
|
||||
order=order))):
|
||||
self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT'])
|
||||
or (order_obj and self.strategy.ft_check_timed_out(
|
||||
'sell', trade, order_obj, datetime.now(timezone.utc))
|
||||
))):
|
||||
canceled = self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT'])
|
||||
canceled_count = trade.get_exit_order_count()
|
||||
max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0)
|
||||
if max_timeouts > 0 and canceled_count >= max_timeouts:
|
||||
if canceled and max_timeouts > 0 and canceled_count >= max_timeouts:
|
||||
logger.warning(f'Emergencyselling trade {trade}, as the sell order '
|
||||
f'timed out {max_timeouts} times.')
|
||||
try:
|
||||
@@ -946,12 +1021,12 @@ class FreqtradeBot(LoggingMixin):
|
||||
|
||||
# Cancelled orders may have the status of 'canceled' or 'closed'
|
||||
if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES:
|
||||
filled_val = order.get('filled', 0.0) or 0.0
|
||||
filled_val: float = order.get('filled', 0.0) or 0.0
|
||||
filled_stake = filled_val * trade.open_rate
|
||||
minstake = self.exchange.get_min_pair_stake_amount(
|
||||
trade.pair, trade.open_rate, self.strategy.stoploss)
|
||||
|
||||
if filled_val > 0 and filled_stake < minstake:
|
||||
if filled_val > 0 and minstake and filled_stake < minstake:
|
||||
logger.warning(
|
||||
f"Order {trade.open_order_id} for {trade.pair} not cancelled, "
|
||||
f"as the filled amount of {filled_val} would result in an unsellable trade.")
|
||||
@@ -975,10 +1050,16 @@ class FreqtradeBot(LoggingMixin):
|
||||
filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled')
|
||||
if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC):
|
||||
logger.info('Buy order fully cancelled. Removing %s from database.', trade)
|
||||
# if trade is not partially completed, just delete the trade
|
||||
trade.delete()
|
||||
was_trade_fully_canceled = True
|
||||
reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}"
|
||||
# if trade is not partially completed and it's the only order, just delete the trade
|
||||
if len(trade.orders) <= 1:
|
||||
trade.delete()
|
||||
was_trade_fully_canceled = True
|
||||
reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}"
|
||||
else:
|
||||
# FIXME TODO: This could possibly reworked to not duplicate the code 15 lines below.
|
||||
self.update_trade_state(trade, trade.open_order_id, corder)
|
||||
trade.open_order_id = None
|
||||
logger.info('Partial buy order timeout for %s.', trade)
|
||||
else:
|
||||
# if trade is partially complete, edit the stake details for the trade
|
||||
# and close the order
|
||||
@@ -998,11 +1079,12 @@ class FreqtradeBot(LoggingMixin):
|
||||
reason=reason)
|
||||
return was_trade_fully_canceled
|
||||
|
||||
def handle_cancel_exit(self, trade: Trade, order: Dict, reason: str) -> str:
|
||||
def handle_cancel_exit(self, trade: Trade, order: Dict, reason: str) -> bool:
|
||||
"""
|
||||
Sell cancel - cancel order and update trade
|
||||
:return: Reason for cancel
|
||||
:return: True if exit order was cancelled, false otherwise
|
||||
"""
|
||||
cancelled = False
|
||||
# if trade is not partially completed, just cancel the order
|
||||
if order['remaining'] == order['amount'] or order.get('filled') == 0.0:
|
||||
if not self.exchange.check_order_canceled_empty(order):
|
||||
@@ -1013,7 +1095,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
trade.update_order(co)
|
||||
except InvalidOrderException:
|
||||
logger.exception(f"Could not cancel sell order {trade.open_order_id}")
|
||||
return 'error cancelling order'
|
||||
return False
|
||||
logger.info('Sell order %s for %s.', reason, trade)
|
||||
else:
|
||||
reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE']
|
||||
@@ -1027,9 +1109,11 @@ class FreqtradeBot(LoggingMixin):
|
||||
trade.close_date = None
|
||||
trade.is_open = True
|
||||
trade.open_order_id = None
|
||||
cancelled = True
|
||||
else:
|
||||
# TODO: figure out how to handle partially complete sell orders
|
||||
reason = constants.CANCEL_REASON['PARTIALLY_FILLED_KEEP_OPEN']
|
||||
cancelled = False
|
||||
|
||||
self.wallets.update()
|
||||
self._notify_exit_cancel(
|
||||
@@ -1037,7 +1121,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
order_type=self.strategy.order_types['sell'],
|
||||
reason=reason
|
||||
)
|
||||
return reason
|
||||
return cancelled
|
||||
|
||||
def _safe_exit_amount(self, pair: str, amount: float) -> float:
|
||||
"""
|
||||
@@ -1102,13 +1186,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
limit = self.get_valid_price(custom_exit_price, proposed_limit_rate)
|
||||
|
||||
# First cancelling stoploss on exchange ...
|
||||
if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id:
|
||||
try:
|
||||
co = self.exchange.cancel_stoploss_order_with_result(trade.stoploss_order_id,
|
||||
trade.pair, trade.amount)
|
||||
trade.update_order(co)
|
||||
except InvalidOrderException:
|
||||
logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}")
|
||||
trade = self.cancel_stoploss_on_exchange(trade)
|
||||
|
||||
order_type = ordertype or self.strategy.order_types[sell_type]
|
||||
if sell_reason.sell_type == SellType.EMERGENCY_SELL:
|
||||
@@ -1266,7 +1344,7 @@ class FreqtradeBot(LoggingMixin):
|
||||
return False
|
||||
|
||||
# Update trade with order values
|
||||
logger.info('Found open order for %s', trade)
|
||||
logger.info(f'Found open order for {trade}')
|
||||
try:
|
||||
order = action_order or self.exchange.fetch_order_or_stoploss_order(order_id,
|
||||
trade.pair,
|
||||
@@ -1282,29 +1360,31 @@ class FreqtradeBot(LoggingMixin):
|
||||
# Handling of this will happen in check_handle_timedout.
|
||||
return True
|
||||
|
||||
# Try update amount (binance-fix)
|
||||
try:
|
||||
new_amount = self.get_real_amount(trade, order)
|
||||
if not isclose(safe_value_fallback(order, 'filled', 'amount'), new_amount,
|
||||
abs_tol=constants.MATH_CLOSE_PREC):
|
||||
order['amount'] = new_amount
|
||||
order.pop('filled', None)
|
||||
trade.recalc_open_trade_value()
|
||||
except DependencyException as exception:
|
||||
logger.warning("Could not update trade amount: %s", exception)
|
||||
order_obj = trade.select_order_by_order_id(order_id)
|
||||
if not order_obj:
|
||||
raise DependencyException(
|
||||
f"Order_obj not found for {order_id}. This should not have happened.")
|
||||
self.handle_order_fee(trade, order_obj, order)
|
||||
|
||||
trade.update(order)
|
||||
trade.update_trade(order_obj)
|
||||
# TODO: is the below necessary? it's already done in update_trade for filled buys
|
||||
trade.recalc_trade_from_orders()
|
||||
Trade.commit()
|
||||
|
||||
# Updating wallets when order is closed
|
||||
if order['status'] in constants.NON_OPEN_EXCHANGE_STATES:
|
||||
# If a buy order was closed, force update on stoploss on exchange
|
||||
if order.get('side', None) == 'buy':
|
||||
trade = self.cancel_stoploss_on_exchange(trade)
|
||||
# Updating wallets when order is closed
|
||||
self.wallets.update()
|
||||
|
||||
if not trade.is_open:
|
||||
if send_msg and not stoploss_order and not trade.open_order_id:
|
||||
self._notify_exit(trade, '', True)
|
||||
self.handle_protections(trade.pair)
|
||||
self.wallets.update()
|
||||
elif send_msg and not trade.open_order_id:
|
||||
# Buy fill
|
||||
self._notify_enter(trade, fill=True)
|
||||
self._notify_enter(trade, order, fill=True)
|
||||
|
||||
return False
|
||||
|
||||
@@ -1339,6 +1419,16 @@ class FreqtradeBot(LoggingMixin):
|
||||
return real_amount
|
||||
return amount
|
||||
|
||||
def handle_order_fee(self, trade: Trade, order_obj: Order, order: Dict[str, Any]) -> None:
|
||||
# Try update amount (binance-fix)
|
||||
try:
|
||||
new_amount = self.get_real_amount(trade, order)
|
||||
if not isclose(safe_value_fallback(order, 'filled', 'amount'), new_amount,
|
||||
abs_tol=constants.MATH_CLOSE_PREC):
|
||||
order_obj.ft_fee_base = trade.amount - new_amount
|
||||
except DependencyException as exception:
|
||||
logger.warning("Could not update trade amount: %s", exception)
|
||||
|
||||
def get_real_amount(self, trade: Trade, order: Dict) -> float:
|
||||
"""
|
||||
Detect and update trade fee.
|
||||
|
@@ -7,11 +7,25 @@ from typing import Any, Dict
|
||||
from freqtrade.exceptions import OperationalException
|
||||
|
||||
|
||||
class FTBufferingHandler(BufferingHandler):
|
||||
def flush(self):
|
||||
"""
|
||||
Override Flush behaviour - we keep half of the configured capacity
|
||||
otherwise, we have moments with "empty" logs.
|
||||
"""
|
||||
self.acquire()
|
||||
try:
|
||||
# Keep half of the records in buffer.
|
||||
self.buffer = self.buffer[-int(self.capacity / 2):]
|
||||
finally:
|
||||
self.release()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
LOGFORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
|
||||
# Initialize bufferhandler - will be used for /log endpoints
|
||||
bufferHandler = BufferingHandler(1000)
|
||||
bufferHandler = FTBufferingHandler(1000)
|
||||
bufferHandler.setFormatter(Formatter(LOGFORMAT))
|
||||
|
||||
|
||||
|
@@ -9,8 +9,8 @@ from typing import Any, List
|
||||
|
||||
|
||||
# check min. python version
|
||||
if sys.version_info < (3, 7): # pragma: no cover
|
||||
sys.exit("Freqtrade requires Python version >= 3.7")
|
||||
if sys.version_info < (3, 8): # pragma: no cover
|
||||
sys.exit("Freqtrade requires Python version >= 3.8")
|
||||
|
||||
from freqtrade.commands import Arguments
|
||||
from freqtrade.exceptions import FreqtradeException, OperationalException
|
||||
|
@@ -2,11 +2,13 @@
|
||||
Various tool function for Freqtrade and scripts
|
||||
"""
|
||||
import gzip
|
||||
import hashlib
|
||||
import logging
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, List
|
||||
from typing import Any, Iterator, List, Union
|
||||
from typing.io import IO
|
||||
from urllib.parse import urlparse
|
||||
|
||||
@@ -27,18 +29,23 @@ def decimals_per_coin(coin: str):
|
||||
return DECIMALS_PER_COIN.get(coin, DECIMAL_PER_COIN_FALLBACK)
|
||||
|
||||
|
||||
def round_coin_value(value: float, coin: str, show_coin_name=True) -> str:
|
||||
def round_coin_value(
|
||||
value: float, coin: str, show_coin_name=True, keep_trailing_zeros=False) -> str:
|
||||
"""
|
||||
Get price value for this coin
|
||||
:param value: Value to be printed
|
||||
:param coin: Which coin are we printing the price / value for
|
||||
:param show_coin_name: Return string in format: "222.22 USDT" or "222.22"
|
||||
:param keep_trailing_zeros: Keep trailing zeros "222.200" vs. "222.2"
|
||||
:return: Formatted / rounded value (with or without coin name)
|
||||
"""
|
||||
val = f"{value:.{decimals_per_coin(coin)}f}"
|
||||
if not keep_trailing_zeros:
|
||||
val = val.rstrip('0').rstrip('.')
|
||||
if show_coin_name:
|
||||
return f"{value:.{decimals_per_coin(coin)}f} {coin}"
|
||||
else:
|
||||
return f"{value:.{decimals_per_coin(coin)}f}"
|
||||
val = f"{val} {coin}"
|
||||
|
||||
return val
|
||||
|
||||
|
||||
def shorten_date(_date: str) -> str:
|
||||
@@ -228,3 +235,34 @@ def parse_db_uri_for_logging(uri: str):
|
||||
return uri
|
||||
pwd = parsed_db_uri.netloc.split(':')[1].split('@')[0]
|
||||
return parsed_db_uri.geturl().replace(f':{pwd}@', ':*****@')
|
||||
|
||||
|
||||
def get_strategy_run_id(strategy) -> str:
|
||||
"""
|
||||
Generate unique identification hash for a backtest run. Identical config and strategy file will
|
||||
always return an identical hash.
|
||||
:param strategy: strategy object.
|
||||
:return: hex string id.
|
||||
"""
|
||||
digest = hashlib.sha1()
|
||||
config = deepcopy(strategy.config)
|
||||
|
||||
# Options that have no impact on results of individual backtest.
|
||||
not_important_keys = ('strategy_list', 'original_config', 'telegram', 'api_server')
|
||||
for k in not_important_keys:
|
||||
if k in config:
|
||||
del config[k]
|
||||
|
||||
# Explicitly allow NaN values (e.g. max_open_trades).
|
||||
# as it does not matter for getting the hash.
|
||||
digest.update(rapidjson.dumps(config, default=str,
|
||||
number_mode=rapidjson.NM_NAN).encode('utf-8'))
|
||||
with open(strategy.__file__, 'rb') as fp:
|
||||
digest.update(fp.read())
|
||||
return digest.hexdigest().lower()
|
||||
|
||||
|
||||
def get_backtest_metadata_filename(filename: Union[Path, str]) -> Path:
|
||||
"""Return metadata filename for specified backtest results file."""
|
||||
filename = Path(filename)
|
||||
return filename.parent / Path(f'{filename.stem}.meta{filename.suffix}')
|
||||
|
@@ -11,20 +11,22 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade import constants
|
||||
from freqtrade.configuration import TimeRange, validate_config_consistency
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT
|
||||
from freqtrade.data import history
|
||||
from freqtrade.data.btanalysis import trade_list_to_dataframe
|
||||
from freqtrade.data.btanalysis import find_existing_backtest_stats, trade_list_to_dataframe
|
||||
from freqtrade.data.converter import trim_dataframe, trim_dataframes
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.enums import BacktestState, SellType
|
||||
from freqtrade.exceptions import DependencyException, OperationalException
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
|
||||
from freqtrade.misc import get_strategy_run_id
|
||||
from freqtrade.mixins import LoggingMixin
|
||||
from freqtrade.optimize.bt_progress import BTProgress
|
||||
from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results,
|
||||
store_backtest_stats)
|
||||
from freqtrade.persistence import LocalTrade, PairLocks, Trade
|
||||
from freqtrade.persistence import LocalTrade, Order, PairLocks, Trade
|
||||
from freqtrade.plugins.pairlistmanager import PairListManager
|
||||
from freqtrade.plugins.protectionmanager import ProtectionManager
|
||||
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
|
||||
@@ -60,9 +62,12 @@ class Backtesting:
|
||||
|
||||
LoggingMixin.show_output = False
|
||||
self.config = config
|
||||
self.results: Optional[Dict[str, Any]] = None
|
||||
self.results: Dict[str, Any] = {}
|
||||
self.trade_id_counter: int = 0
|
||||
self.order_id_counter: int = 0
|
||||
|
||||
config['dry_run'] = True
|
||||
self.run_ids: Dict[str, str] = {}
|
||||
self.strategylist: List[IStrategy] = []
|
||||
self.all_results: Dict[str, Dict] = {}
|
||||
|
||||
@@ -123,7 +128,8 @@ class Backtesting:
|
||||
def __del__(self):
|
||||
self.cleanup()
|
||||
|
||||
def cleanup(self):
|
||||
@staticmethod
|
||||
def cleanup():
|
||||
LoggingMixin.show_output = True
|
||||
PairLocks.use_db = True
|
||||
Trade.use_db = True
|
||||
@@ -228,6 +234,8 @@ class Backtesting:
|
||||
PairLocks.reset_locks()
|
||||
Trade.reset_trades()
|
||||
self.rejected_trades = 0
|
||||
self.timedout_entry_orders = 0
|
||||
self.timedout_exit_orders = 0
|
||||
self.dataprovider.clear_cache()
|
||||
if enable_protections:
|
||||
self._load_protections(self.strategy)
|
||||
@@ -246,6 +254,9 @@ class Backtesting:
|
||||
Helper function to convert a processed dataframes into lists for performance reasons.
|
||||
|
||||
Used by backtest() - so keep this optimized for performance.
|
||||
|
||||
:param processed: a processed dictionary with format {pair, data}, which gets cleared to
|
||||
optimize memory usage!
|
||||
"""
|
||||
# Every change to this headers list must evaluate further usages of the resulting tuple
|
||||
# and eventually change the constants for indexes at the top
|
||||
@@ -254,7 +265,8 @@ class Backtesting:
|
||||
self.progress.init_step(BacktestState.CONVERT, len(processed))
|
||||
|
||||
# Create dict with data
|
||||
for pair, pair_data in processed.items():
|
||||
for pair in processed.keys():
|
||||
pair_data = processed[pair]
|
||||
self.check_abort()
|
||||
self.progress.increment()
|
||||
if not pair_data.empty:
|
||||
@@ -266,8 +278,15 @@ class Backtesting:
|
||||
df_analyzed = self.strategy.advise_sell(
|
||||
self.strategy.advise_buy(pair_data, {'pair': pair}), {'pair': pair}).copy()
|
||||
# Trim startup period from analyzed dataframe
|
||||
df_analyzed = trim_dataframe(df_analyzed, self.timerange,
|
||||
startup_candles=self.required_startup)
|
||||
df_analyzed = processed[pair] = pair_data = trim_dataframe(
|
||||
df_analyzed, self.timerange, startup_candles=self.required_startup)
|
||||
# Update dataprovider cache
|
||||
self.dataprovider._set_cached_df(pair, self.timeframe, df_analyzed)
|
||||
|
||||
# Create a copy of the dataframe before shifting, that way the buy signal/tag
|
||||
# remains on the correct candle for callbacks.
|
||||
df_analyzed = df_analyzed.copy()
|
||||
|
||||
# To avoid using data from future, we use buy/sell signals shifted
|
||||
# from the previous candle
|
||||
df_analyzed.loc[:, 'buy'] = df_analyzed.loc[:, 'buy'].shift(1)
|
||||
@@ -275,9 +294,6 @@ class Backtesting:
|
||||
df_analyzed.loc[:, 'buy_tag'] = df_analyzed.loc[:, 'buy_tag'].shift(1)
|
||||
df_analyzed.loc[:, 'exit_tag'] = df_analyzed.loc[:, 'exit_tag'].shift(1)
|
||||
|
||||
# Update dataprovider cache
|
||||
self.dataprovider._set_cached_df(pair, self.timeframe, df_analyzed)
|
||||
|
||||
df_analyzed = df_analyzed.drop(df_analyzed.head(1).index)
|
||||
|
||||
# Convert from Pandas to list for performance reasons
|
||||
@@ -342,7 +358,22 @@ class Backtesting:
|
||||
# use Open rate if open_rate > calculated sell rate
|
||||
return sell_row[OPEN_IDX]
|
||||
|
||||
return close_rate
|
||||
if (
|
||||
trade_dur == 0
|
||||
# Red candle (for longs), TODO: green candle (for shorts)
|
||||
and sell_row[OPEN_IDX] > sell_row[CLOSE_IDX] # Red candle
|
||||
and trade.open_rate < sell_row[OPEN_IDX] # trade-open below open_rate
|
||||
and close_rate > sell_row[CLOSE_IDX]
|
||||
):
|
||||
# ROI on opening candles with custom pricing can only
|
||||
# trigger if the entry was at Open or lower.
|
||||
# details: https: // github.com/freqtrade/freqtrade/issues/6261
|
||||
# If open_rate is < open, only allow sells below the close on red candles.
|
||||
raise ValueError("Opening candle ROI on red candles.")
|
||||
# Use the maximum between close_rate and low as we
|
||||
# cannot sell outside of a candle.
|
||||
# Applies when a new ROI setting comes in place and the whole candle is above that.
|
||||
return min(max(close_rate, sell_row[LOW_IDX]), sell_row[HIGH_IDX])
|
||||
|
||||
else:
|
||||
# This should not be reached...
|
||||
@@ -350,8 +381,42 @@ class Backtesting:
|
||||
else:
|
||||
return sell_row[OPEN_IDX]
|
||||
|
||||
def _get_adjust_trade_entry_for_candle(self, trade: LocalTrade, row: Tuple
|
||||
) -> LocalTrade:
|
||||
|
||||
current_profit = trade.calc_profit_ratio(row[OPEN_IDX])
|
||||
min_stake = self.exchange.get_min_pair_stake_amount(trade.pair, row[OPEN_IDX], -0.1)
|
||||
max_stake = self.wallets.get_available_stake_amount()
|
||||
stake_amount = strategy_safe_wrapper(self.strategy.adjust_trade_position,
|
||||
default_retval=None)(
|
||||
trade=trade, current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX],
|
||||
current_profit=current_profit, min_stake=min_stake, max_stake=max_stake)
|
||||
|
||||
# Check if we should increase our position
|
||||
if stake_amount is not None and stake_amount > 0.0:
|
||||
pos_trade = self._enter_trade(trade.pair, row, stake_amount, trade)
|
||||
if pos_trade is not None:
|
||||
self.wallets.update()
|
||||
return pos_trade
|
||||
|
||||
return trade
|
||||
|
||||
def _get_order_filled(self, rate: float, row: Tuple) -> bool:
|
||||
""" Rate is within candle, therefore filled"""
|
||||
return row[LOW_IDX] <= rate <= row[HIGH_IDX]
|
||||
|
||||
def _get_sell_trade_entry_for_candle(self, trade: LocalTrade,
|
||||
sell_row: Tuple) -> Optional[LocalTrade]:
|
||||
|
||||
# Check if we need to adjust our current positions
|
||||
if self.strategy.position_adjustment_enable:
|
||||
check_adjust_buy = True
|
||||
if self.strategy.max_entry_position_adjustment > -1:
|
||||
count_of_buys = trade.nr_of_successful_buys
|
||||
check_adjust_buy = (count_of_buys <= self.strategy.max_entry_position_adjustment)
|
||||
if check_adjust_buy:
|
||||
trade = self._get_adjust_trade_entry_for_candle(trade, sell_row)
|
||||
|
||||
sell_candle_time = sell_row[DATE_IDX].to_pydatetime()
|
||||
sell = self.strategy.should_sell(trade, sell_row[OPEN_IDX], # type: ignore
|
||||
sell_candle_time, sell_row[BUY_IDX],
|
||||
@@ -362,21 +427,27 @@ class Backtesting:
|
||||
trade.close_date = sell_candle_time
|
||||
|
||||
trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60)
|
||||
closerate = self._get_close_rate(sell_row, trade, sell, trade_dur)
|
||||
try:
|
||||
closerate = self._get_close_rate(sell_row, trade, sell, trade_dur)
|
||||
except ValueError:
|
||||
return None
|
||||
# call the custom exit price,with default value as previous closerate
|
||||
current_profit = trade.calc_profit_ratio(closerate)
|
||||
order_type = self.strategy.order_types['sell']
|
||||
if sell.sell_type in (SellType.SELL_SIGNAL, SellType.CUSTOM_SELL):
|
||||
# Custom exit pricing only for sell-signals
|
||||
closerate = strategy_safe_wrapper(self.strategy.custom_exit_price,
|
||||
default_retval=closerate)(
|
||||
pair=trade.pair, trade=trade,
|
||||
current_time=sell_row[DATE_IDX],
|
||||
proposed_rate=closerate, current_profit=current_profit)
|
||||
# Use the maximum between close_rate and low as we cannot sell outside of a candle.
|
||||
closerate = min(max(closerate, sell_row[LOW_IDX]), sell_row[HIGH_IDX])
|
||||
|
||||
if order_type == 'limit':
|
||||
closerate = strategy_safe_wrapper(self.strategy.custom_exit_price,
|
||||
default_retval=closerate)(
|
||||
pair=trade.pair, trade=trade,
|
||||
current_time=sell_candle_time,
|
||||
proposed_rate=closerate, current_profit=current_profit)
|
||||
# We can't place orders lower than current low.
|
||||
# freqtrade does not support this in live, and the order would fill immediately
|
||||
closerate = max(closerate, sell_row[LOW_IDX])
|
||||
# Confirm trade exit:
|
||||
time_in_force = self.strategy.order_time_in_force['sell']
|
||||
|
||||
if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)(
|
||||
pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount,
|
||||
rate=closerate,
|
||||
@@ -396,7 +467,28 @@ class Backtesting:
|
||||
):
|
||||
trade.sell_reason = sell_row[EXIT_TAG_IDX]
|
||||
|
||||
trade.close(closerate, show_msg=False)
|
||||
self.order_id_counter += 1
|
||||
order = Order(
|
||||
id=self.order_id_counter,
|
||||
ft_trade_id=trade.id,
|
||||
order_date=sell_candle_time,
|
||||
order_update_date=sell_candle_time,
|
||||
ft_is_open=True,
|
||||
ft_pair=trade.pair,
|
||||
order_id=str(self.order_id_counter),
|
||||
symbol=trade.pair,
|
||||
ft_order_side="sell",
|
||||
side="sell",
|
||||
order_type=order_type,
|
||||
status="open",
|
||||
price=closerate,
|
||||
average=closerate,
|
||||
amount=trade.amount,
|
||||
filled=0,
|
||||
remaining=trade.amount,
|
||||
cost=trade.amount * closerate,
|
||||
)
|
||||
trade.orders.append(order)
|
||||
return trade
|
||||
|
||||
return None
|
||||
@@ -416,7 +508,9 @@ class Backtesting:
|
||||
return self._get_sell_trade_entry_for_candle(trade, sell_row)
|
||||
detail_data.loc[:, 'buy'] = sell_row[BUY_IDX]
|
||||
detail_data.loc[:, 'sell'] = sell_row[SELL_IDX]
|
||||
headers = ['date', 'buy', 'open', 'close', 'sell', 'low', 'high']
|
||||
detail_data.loc[:, 'buy_tag'] = sell_row[BUY_TAG_IDX]
|
||||
detail_data.loc[:, 'exit_tag'] = sell_row[EXIT_TAG_IDX]
|
||||
headers = ['date', 'buy', 'open', 'close', 'sell', 'low', 'high', 'buy_tag', 'exit_tag']
|
||||
for det_row in detail_data[headers].values.tolist():
|
||||
res = self._get_sell_trade_entry_for_candle(trade, det_row)
|
||||
if res:
|
||||
@@ -427,57 +521,110 @@ class Backtesting:
|
||||
else:
|
||||
return self._get_sell_trade_entry_for_candle(trade, sell_row)
|
||||
|
||||
def _enter_trade(self, pair: str, row: List) -> Optional[LocalTrade]:
|
||||
try:
|
||||
stake_amount = self.wallets.get_trade_stake_amount(pair, None)
|
||||
except DependencyException:
|
||||
return None
|
||||
# let's call the custom entry price, using the open price as default price
|
||||
propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price,
|
||||
default_retval=row[OPEN_IDX])(
|
||||
pair=pair, current_time=row[DATE_IDX].to_pydatetime(),
|
||||
proposed_rate=row[OPEN_IDX]) # default value is the open rate
|
||||
def _enter_trade(self, pair: str, row: Tuple, stake_amount: Optional[float] = None,
|
||||
trade: Optional[LocalTrade] = None) -> Optional[LocalTrade]:
|
||||
|
||||
# Move rate to within the candle's low/high rate
|
||||
propose_rate = min(max(propose_rate, row[LOW_IDX]), row[HIGH_IDX])
|
||||
current_time = row[DATE_IDX].to_pydatetime()
|
||||
entry_tag = row[BUY_TAG_IDX] if len(row) >= BUY_TAG_IDX + 1 else None
|
||||
# let's call the custom entry price, using the open price as default price
|
||||
order_type = self.strategy.order_types['buy']
|
||||
propose_rate = row[OPEN_IDX]
|
||||
if order_type == 'limit':
|
||||
propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price,
|
||||
default_retval=row[OPEN_IDX])(
|
||||
pair=pair, current_time=current_time,
|
||||
proposed_rate=propose_rate, entry_tag=entry_tag) # default value is the open rate
|
||||
# We can't place orders higher than current high (otherwise it'd be a stop limit buy)
|
||||
# which freqtrade does not support in live.
|
||||
propose_rate = min(propose_rate, row[HIGH_IDX])
|
||||
|
||||
min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, propose_rate, -0.05) or 0
|
||||
max_stake_amount = self.wallets.get_available_stake_amount()
|
||||
|
||||
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
|
||||
default_retval=stake_amount)(
|
||||
pair=pair, current_time=row[DATE_IDX].to_pydatetime(), current_rate=propose_rate,
|
||||
proposed_stake=stake_amount, min_stake=min_stake_amount, max_stake=max_stake_amount)
|
||||
pos_adjust = trade is not None
|
||||
if not pos_adjust:
|
||||
try:
|
||||
stake_amount = self.wallets.get_trade_stake_amount(pair, None, update=False)
|
||||
except DependencyException:
|
||||
return None
|
||||
|
||||
stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount,
|
||||
default_retval=stake_amount)(
|
||||
pair=pair, current_time=current_time, current_rate=propose_rate,
|
||||
proposed_stake=stake_amount, min_stake=min_stake_amount, max_stake=max_stake_amount,
|
||||
entry_tag=entry_tag)
|
||||
|
||||
stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount)
|
||||
|
||||
if not stake_amount:
|
||||
return None
|
||||
# In case of pos adjust, still return the original trade
|
||||
# If not pos adjust, trade is None
|
||||
return trade
|
||||
|
||||
order_type = self.strategy.order_types['buy']
|
||||
time_in_force = self.strategy.order_time_in_force['sell']
|
||||
time_in_force = self.strategy.order_time_in_force['buy']
|
||||
# Confirm trade entry:
|
||||
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
|
||||
pair=pair, order_type=order_type, amount=stake_amount, rate=propose_rate,
|
||||
time_in_force=time_in_force, current_time=row[DATE_IDX].to_pydatetime()):
|
||||
return None
|
||||
if not pos_adjust:
|
||||
if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)(
|
||||
pair=pair, order_type=order_type, amount=stake_amount, rate=propose_rate,
|
||||
time_in_force=time_in_force, current_time=current_time,
|
||||
entry_tag=entry_tag):
|
||||
return None
|
||||
|
||||
if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount):
|
||||
# Enter trade
|
||||
has_buy_tag = len(row) >= BUY_TAG_IDX + 1
|
||||
trade = LocalTrade(
|
||||
pair=pair,
|
||||
open_rate=propose_rate,
|
||||
open_date=row[DATE_IDX].to_pydatetime(),
|
||||
stake_amount=stake_amount,
|
||||
amount=round(stake_amount / propose_rate, 8),
|
||||
fee_open=self.fee,
|
||||
fee_close=self.fee,
|
||||
is_open=True,
|
||||
buy_tag=row[BUY_TAG_IDX] if has_buy_tag else None,
|
||||
exchange='backtesting',
|
||||
self.order_id_counter += 1
|
||||
amount = round(stake_amount / propose_rate, 8)
|
||||
if trade is None:
|
||||
# Enter trade
|
||||
self.trade_id_counter += 1
|
||||
trade = LocalTrade(
|
||||
id=self.trade_id_counter,
|
||||
open_order_id=self.order_id_counter,
|
||||
pair=pair,
|
||||
open_rate=propose_rate,
|
||||
open_rate_requested=propose_rate,
|
||||
open_date=current_time,
|
||||
stake_amount=stake_amount,
|
||||
amount=amount,
|
||||
amount_requested=amount,
|
||||
fee_open=self.fee,
|
||||
fee_close=self.fee,
|
||||
is_open=True,
|
||||
buy_tag=entry_tag,
|
||||
exchange='backtesting',
|
||||
orders=[]
|
||||
)
|
||||
|
||||
trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True)
|
||||
|
||||
order = Order(
|
||||
id=self.order_id_counter,
|
||||
ft_trade_id=trade.id,
|
||||
ft_is_open=True,
|
||||
ft_pair=trade.pair,
|
||||
order_id=str(self.order_id_counter),
|
||||
symbol=trade.pair,
|
||||
ft_order_side="buy",
|
||||
side="buy",
|
||||
order_type=order_type,
|
||||
status="open",
|
||||
order_date=current_time,
|
||||
order_filled_date=current_time,
|
||||
order_update_date=current_time,
|
||||
price=propose_rate,
|
||||
average=propose_rate,
|
||||
amount=amount,
|
||||
filled=0,
|
||||
remaining=amount,
|
||||
cost=stake_amount + trade.fee_open,
|
||||
)
|
||||
return trade
|
||||
return None
|
||||
if pos_adjust and self._get_order_filled(order.price, row):
|
||||
order.close_bt_order(current_time)
|
||||
else:
|
||||
trade.open_order_id = str(self.order_id_counter)
|
||||
trade.orders.append(order)
|
||||
trade.recalc_trade_from_orders()
|
||||
|
||||
return trade
|
||||
|
||||
def handle_left_open(self, open_trades: Dict[str, List[LocalTrade]],
|
||||
data: Dict[str, List[Tuple]]) -> List[LocalTrade]:
|
||||
@@ -488,6 +635,9 @@ class Backtesting:
|
||||
for pair in open_trades.keys():
|
||||
if len(open_trades[pair]) > 0:
|
||||
for trade in open_trades[pair]:
|
||||
if trade.open_order_id and trade.nr_of_successful_buys == 0:
|
||||
# Ignore trade if buy-order did not fill yet
|
||||
continue
|
||||
sell_row = data[pair][-1]
|
||||
|
||||
trade.close_date = sell_row[DATE_IDX].to_pydatetime()
|
||||
@@ -508,6 +658,51 @@ class Backtesting:
|
||||
self.rejected_trades += 1
|
||||
return False
|
||||
|
||||
def run_protections(self, enable_protections, pair: str, current_time: datetime):
|
||||
if enable_protections:
|
||||
self.protections.stop_per_pair(pair, current_time)
|
||||
self.protections.global_stop(current_time)
|
||||
|
||||
def check_order_cancel(self, trade: LocalTrade, current_time) -> bool:
|
||||
"""
|
||||
Check if an order has been canceled.
|
||||
Returns True if the trade should be Deleted (initial order was canceled).
|
||||
"""
|
||||
for order in [o for o in trade.orders if o.ft_is_open]:
|
||||
|
||||
timedout = self.strategy.ft_check_timed_out(order.side, trade, order, current_time)
|
||||
if timedout:
|
||||
if order.side == 'buy':
|
||||
self.timedout_entry_orders += 1
|
||||
if trade.nr_of_successful_buys == 0:
|
||||
# Remove trade due to buy timeout expiration.
|
||||
return True
|
||||
else:
|
||||
# Close additional buy order
|
||||
del trade.orders[trade.orders.index(order)]
|
||||
if order.side == 'sell':
|
||||
self.timedout_exit_orders += 1
|
||||
# Close sell order and retry selling on next signal.
|
||||
del trade.orders[trade.orders.index(order)]
|
||||
|
||||
return False
|
||||
|
||||
def validate_row(
|
||||
self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]:
|
||||
try:
|
||||
# Row is treated as "current incomplete candle".
|
||||
# Buy / sell signals are shifted by 1 to compensate for this.
|
||||
row = data[pair][row_index]
|
||||
except IndexError:
|
||||
# missing Data for one pair at the end.
|
||||
# Warnings for this are shown during data loading
|
||||
return None
|
||||
|
||||
# Waits until the time-counter reaches the start of the data for this pair.
|
||||
if row[DATE_IDX] > current_time:
|
||||
return None
|
||||
return row
|
||||
|
||||
def backtest(self, processed: Dict,
|
||||
start_date: datetime, end_date: datetime,
|
||||
max_open_trades: int = 0, position_stacking: bool = False,
|
||||
@@ -519,7 +714,8 @@ class Backtesting:
|
||||
Of course try to not have ugly code. By some accessor are sometime slower than functions.
|
||||
Avoid extensive logging in this method and functions it calls.
|
||||
|
||||
:param processed: a processed dictionary with format {pair, data}
|
||||
:param processed: a processed dictionary with format {pair, data}, which gets cleared to
|
||||
optimize memory usage!
|
||||
:param start_date: backtesting timerange start datetime
|
||||
:param end_date: backtesting timerange end datetime
|
||||
:param max_open_trades: maximum number of concurrent trades, <= 0 means unlimited
|
||||
@@ -529,14 +725,15 @@ class Backtesting:
|
||||
"""
|
||||
trades: List[LocalTrade] = []
|
||||
self.prepare_backtest(enable_protections)
|
||||
|
||||
# Ensure wallets are uptodate (important for --strategy-list)
|
||||
self.wallets.update()
|
||||
# Use dict of lists with data for performance
|
||||
# (looping lists is a lot faster than pandas DataFrames)
|
||||
data: Dict = self._get_ohlcv_as_lists(processed)
|
||||
|
||||
# Indexes per pair, so some pairs are allowed to have a missing start.
|
||||
indexes: Dict = defaultdict(int)
|
||||
tmp = start_date + timedelta(minutes=self.timeframe_min)
|
||||
current_time = start_date + timedelta(minutes=self.timeframe_min)
|
||||
|
||||
open_trades: Dict[str, List[LocalTrade]] = defaultdict(list)
|
||||
open_trade_count = 0
|
||||
@@ -545,35 +742,27 @@ class Backtesting:
|
||||
(end_date - start_date) / timedelta(minutes=self.timeframe_min)))
|
||||
|
||||
# Loop timerange and get candle for each pair at that point in time
|
||||
while tmp <= end_date:
|
||||
while current_time <= end_date:
|
||||
open_trade_count_start = open_trade_count
|
||||
self.check_abort()
|
||||
for i, pair in enumerate(data):
|
||||
row_index = indexes[pair]
|
||||
try:
|
||||
# Row is treated as "current incomplete candle".
|
||||
# Buy / sell signals are shifted by 1 to compensate for this.
|
||||
row = data[pair][row_index]
|
||||
except IndexError:
|
||||
# missing Data for one pair at the end.
|
||||
# Warnings for this are shown during data loading
|
||||
continue
|
||||
|
||||
# Waits until the time-counter reaches the start of the data for this pair.
|
||||
if row[DATE_IDX] > tmp:
|
||||
row = self.validate_row(data, pair, row_index, current_time)
|
||||
if not row:
|
||||
continue
|
||||
|
||||
row_index += 1
|
||||
indexes[pair] = row_index
|
||||
self.dataprovider._set_dataframe_max_index(row_index)
|
||||
|
||||
# 1. Process buys.
|
||||
# without positionstacking, we can only have one open trade per pair.
|
||||
# max_open_trades must be respected
|
||||
# don't open on the last row
|
||||
if (
|
||||
(position_stacking or len(open_trades[pair]) == 0)
|
||||
and self.trade_slot_available(max_open_trades, open_trade_count_start)
|
||||
and tmp != end_date
|
||||
and current_time != end_date
|
||||
and row[BUY_IDX] == 1
|
||||
and row[SELL_IDX] != 1
|
||||
and not PairLocks.is_pair_locked(pair, row[DATE_IDX])
|
||||
@@ -581,32 +770,51 @@ class Backtesting:
|
||||
trade = self._enter_trade(pair, row)
|
||||
if trade:
|
||||
# TODO: hacky workaround to avoid opening > max_open_trades
|
||||
# This emulates previous behaviour - not sure if this is correct
|
||||
# This emulates previous behavior - not sure if this is correct
|
||||
# Prevents buying if the trade-slot was freed in this candle
|
||||
open_trade_count_start += 1
|
||||
open_trade_count += 1
|
||||
# logger.debug(f"{pair} - Emulate creation of new trade: {trade}.")
|
||||
open_trades[pair].append(trade)
|
||||
LocalTrade.add_bt_trade(trade)
|
||||
|
||||
for trade in list(open_trades[pair]):
|
||||
# also check the buying candle for sell conditions.
|
||||
trade_entry = self._get_sell_trade_entry(trade, row)
|
||||
# Sell occurred
|
||||
if trade_entry:
|
||||
# 2. Process buy orders.
|
||||
order = trade.select_order('buy', is_open=True)
|
||||
if order and self._get_order_filled(order.price, row):
|
||||
order.close_bt_order(current_time)
|
||||
trade.open_order_id = None
|
||||
LocalTrade.add_bt_trade(trade)
|
||||
self.wallets.update()
|
||||
|
||||
# 3. Create sell orders (if any)
|
||||
if not trade.open_order_id:
|
||||
self._get_sell_trade_entry(trade, row) # Place sell order if necessary
|
||||
|
||||
# 4. Process sell orders.
|
||||
order = trade.select_order('sell', is_open=True)
|
||||
if order and self._get_order_filled(order.price, row):
|
||||
trade.open_order_id = None
|
||||
trade.close_date = current_time
|
||||
trade.close(order.price, show_msg=False)
|
||||
|
||||
# logger.debug(f"{pair} - Backtesting sell {trade}")
|
||||
open_trade_count -= 1
|
||||
open_trades[pair].remove(trade)
|
||||
|
||||
LocalTrade.close_bt_trade(trade)
|
||||
trades.append(trade_entry)
|
||||
if enable_protections:
|
||||
self.protections.stop_per_pair(pair, row[DATE_IDX])
|
||||
self.protections.global_stop(tmp)
|
||||
trades.append(trade)
|
||||
self.wallets.update()
|
||||
self.run_protections(enable_protections, pair, current_time)
|
||||
|
||||
# 5. Cancel expired buy/sell orders.
|
||||
if self.check_order_cancel(trade, current_time):
|
||||
# Close trade due to buy timeout expiration.
|
||||
open_trade_count -= 1
|
||||
open_trades[pair].remove(trade)
|
||||
self.wallets.update()
|
||||
|
||||
# Move time one configured time_interval ahead.
|
||||
self.progress.increment()
|
||||
tmp += timedelta(minutes=self.timeframe_min)
|
||||
current_time += timedelta(minutes=self.timeframe_min)
|
||||
|
||||
trades += self.handle_left_open(open_trades, data=data)
|
||||
self.wallets.update()
|
||||
@@ -617,6 +825,8 @@ class Backtesting:
|
||||
'config': self.strategy.config,
|
||||
'locks': PairLocks.get_all_locks(),
|
||||
'rejected_signals': self.rejected_trades,
|
||||
'timedout_entry_orders': self.timedout_entry_orders,
|
||||
'timedout_exit_orders': self.timedout_exit_orders,
|
||||
'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']),
|
||||
}
|
||||
|
||||
@@ -666,6 +876,7 @@ class Backtesting:
|
||||
)
|
||||
backtest_end_time = datetime.now(timezone.utc)
|
||||
results.update({
|
||||
'run_id': self.run_ids.get(strat.get_strategy_name(), ''),
|
||||
'backtest_start_time': int(backtest_start_time.timestamp()),
|
||||
'backtest_end_time': int(backtest_end_time.timestamp()),
|
||||
})
|
||||
@@ -673,6 +884,33 @@ class Backtesting:
|
||||
|
||||
return min_date, max_date
|
||||
|
||||
def _get_min_cached_backtest_date(self):
|
||||
min_backtest_date = None
|
||||
backtest_cache_age = self.config.get('backtest_cache', constants.BACKTEST_CACHE_DEFAULT)
|
||||
if self.timerange.stopts == 0 or datetime.fromtimestamp(
|
||||
self.timerange.stopts, tz=timezone.utc) > datetime.now(tz=timezone.utc):
|
||||
logger.warning('Backtest result caching disabled due to use of open-ended timerange.')
|
||||
elif backtest_cache_age == 'day':
|
||||
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(days=1)
|
||||
elif backtest_cache_age == 'week':
|
||||
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=1)
|
||||
elif backtest_cache_age == 'month':
|
||||
min_backtest_date = datetime.now(tz=timezone.utc) - timedelta(weeks=4)
|
||||
return min_backtest_date
|
||||
|
||||
def load_prior_backtest(self):
|
||||
self.run_ids = {
|
||||
strategy.get_strategy_name(): get_strategy_run_id(strategy)
|
||||
for strategy in self.strategylist
|
||||
}
|
||||
|
||||
# Load previous result that will be updated incrementally.
|
||||
# This can be circumvented in certain instances in combination with downloading more data
|
||||
min_backtest_date = self._get_min_cached_backtest_date()
|
||||
if min_backtest_date is not None:
|
||||
self.results = find_existing_backtest_stats(
|
||||
self.config['user_data_dir'] / 'backtest_results', self.run_ids, min_backtest_date)
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Run backtesting end-to-end
|
||||
@@ -684,15 +922,38 @@ class Backtesting:
|
||||
self.load_bt_data_detail()
|
||||
logger.info("Dataload complete. Calculating indicators")
|
||||
|
||||
for strat in self.strategylist:
|
||||
min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
|
||||
if len(self.strategylist) > 0:
|
||||
self.load_prior_backtest()
|
||||
|
||||
self.results = generate_backtest_stats(data, self.all_results,
|
||||
min_date=min_date, max_date=max_date)
|
||||
for strat in self.strategylist:
|
||||
if self.results and strat.get_strategy_name() in self.results['strategy']:
|
||||
# When previous result hash matches - reuse that result and skip backtesting.
|
||||
logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}')
|
||||
continue
|
||||
min_date, max_date = self.backtest_one_strategy(strat, data, timerange)
|
||||
|
||||
# Update old results with new ones.
|
||||
if len(self.all_results) > 0:
|
||||
results = generate_backtest_stats(
|
||||
data, self.all_results, min_date=min_date, max_date=max_date)
|
||||
if self.results:
|
||||
self.results['metadata'].update(results['metadata'])
|
||||
self.results['strategy'].update(results['strategy'])
|
||||
self.results['strategy_comparison'].extend(results['strategy_comparison'])
|
||||
else:
|
||||
self.results = results
|
||||
|
||||
if self.config.get('export', 'none') == 'trades':
|
||||
store_backtest_stats(self.config['exportfilename'], self.results)
|
||||
|
||||
# Results may be mixed up now. Sort them so they follow --strategy-list order.
|
||||
if 'strategy_list' in self.config and len(self.results) > 0:
|
||||
self.results['strategy_comparison'] = sorted(
|
||||
self.results['strategy_comparison'],
|
||||
key=lambda c: self.config['strategy_list'].index(c['key']))
|
||||
self.results['strategy'] = dict(
|
||||
sorted(self.results['strategy'].items(),
|
||||
key=lambda kv: self.config['strategy_list'].index(kv[0])))
|
||||
|
||||
if len(self.strategylist) > 0:
|
||||
# Show backtest results
|
||||
show_backtest_results(self.config, self.results)
|
||||
|
@@ -12,7 +12,7 @@ class BTProgress:
|
||||
def init_step(self, action: BacktestState, max_steps: float):
|
||||
self._action = action
|
||||
self._max_steps = max_steps
|
||||
self._proress = 0
|
||||
self._progress = 0
|
||||
|
||||
def set_new_value(self, new_value: float):
|
||||
self._progress = new_value
|
||||
|
@@ -34,7 +34,7 @@ class EdgeCli:
|
||||
self.config['stake_amount'] = constants.UNLIMITED_STAKE_AMOUNT
|
||||
self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config)
|
||||
self.strategy = StrategyResolver.load_strategy(self.config)
|
||||
self.strategy.dp = DataProvider(config, None)
|
||||
self.strategy.dp = DataProvider(config, self.exchange)
|
||||
|
||||
validate_config_consistency(self.config)
|
||||
|
||||
|
@@ -76,6 +76,7 @@ class Hyperopt:
|
||||
self.config = config
|
||||
|
||||
self.backtesting = Backtesting(self.config)
|
||||
self.pairlist = self.backtesting.pairlists.whitelist
|
||||
|
||||
if not self.config.get('hyperopt'):
|
||||
self.custom_hyperopt = HyperOptAuto(self.config)
|
||||
@@ -332,7 +333,7 @@ class Hyperopt:
|
||||
params_details = self._get_params_details(params_dict)
|
||||
|
||||
strat_stats = generate_strategy_stats(
|
||||
processed, self.backtesting.strategy.get_strategy_name(),
|
||||
self.pairlist, self.backtesting.strategy.get_strategy_name(),
|
||||
backtesting_results, min_date, max_date, market_change=0
|
||||
)
|
||||
results_explanation = HyperoptTools.format_results_explanation_string(
|
||||
@@ -366,7 +367,7 @@ class Hyperopt:
|
||||
}
|
||||
|
||||
def get_optimizer(self, dimensions: List[Dimension], cpu_count) -> Optimizer:
|
||||
estimator = self.custom_hyperopt.generate_estimator()
|
||||
estimator = self.custom_hyperopt.generate_estimator(dimensions=dimensions)
|
||||
|
||||
acq_optimizer = "sampling"
|
||||
if isinstance(estimator, str):
|
||||
@@ -422,6 +423,7 @@ class Hyperopt:
|
||||
self.backtesting.exchange.close()
|
||||
self.backtesting.exchange._api = None # type: ignore
|
||||
self.backtesting.exchange._api_async = None # type: ignore
|
||||
self.backtesting.exchange.loop = None # type: ignore
|
||||
# self.backtesting.exchange = None # type: ignore
|
||||
self.backtesting.pairlists = None # type: ignore
|
||||
|
||||
|
@@ -91,5 +91,5 @@ class HyperOptAuto(IHyperOpt):
|
||||
def trailing_space(self) -> List['Dimension']:
|
||||
return self._get_func('trailing_space')()
|
||||
|
||||
def generate_estimator(self) -> EstimatorType:
|
||||
return self._get_func('generate_estimator')()
|
||||
def generate_estimator(self, dimensions: List['Dimension'], **kwargs) -> EstimatorType:
|
||||
return self._get_func('generate_estimator')(dimensions=dimensions, **kwargs)
|
||||
|
@@ -40,7 +40,7 @@ class IHyperOpt(ABC):
|
||||
IHyperOpt.ticker_interval = str(config['timeframe']) # DEPRECATED
|
||||
IHyperOpt.timeframe = str(config['timeframe'])
|
||||
|
||||
def generate_estimator(self) -> EstimatorType:
|
||||
def generate_estimator(self, dimensions: List[Dimension], **kwargs) -> EstimatorType:
|
||||
"""
|
||||
Return base_estimator.
|
||||
Can be any of "GP", "RF", "ET", "GBRT" or an instance of a class
|
||||
|
@@ -47,10 +47,9 @@ class CalmarHyperOptLoss(IHyperOptLoss):
|
||||
|
||||
# calculate max drawdown
|
||||
try:
|
||||
_, _, _, high_val, low_val = calculate_max_drawdown(
|
||||
_, _, _, _, _, max_drawdown = calculate_max_drawdown(
|
||||
results, value_col="profit_abs"
|
||||
)
|
||||
max_drawdown = (high_val - low_val) / high_val
|
||||
except ValueError:
|
||||
max_drawdown = 0
|
||||
|
||||
|
30
freqtrade/optimize/hyperopt_loss_profit_drawdown.py
Normal file
30
freqtrade/optimize/hyperopt_loss_profit_drawdown.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
ProfitDrawDownHyperOptLoss
|
||||
|
||||
This module defines the alternative HyperOptLoss class based on Profit &
|
||||
Drawdown objective which can be used for Hyperoptimization.
|
||||
|
||||
Possible to change `DRAWDOWN_MULT` to penalize drawdown objective for
|
||||
individual needs.
|
||||
"""
|
||||
from pandas import DataFrame
|
||||
|
||||
from freqtrade.data.btanalysis import calculate_max_drawdown
|
||||
from freqtrade.optimize.hyperopt import IHyperOptLoss
|
||||
|
||||
|
||||
# higher numbers penalize drawdowns more severely
|
||||
DRAWDOWN_MULT = 0.075
|
||||
|
||||
|
||||
class ProfitDrawDownHyperOptLoss(IHyperOptLoss):
|
||||
@staticmethod
|
||||
def hyperopt_loss_function(results: DataFrame, trade_count: int, *args, **kwargs) -> float:
|
||||
total_profit = results["profit_abs"].sum()
|
||||
|
||||
try:
|
||||
max_drawdown_abs = calculate_max_drawdown(results, value_col="profit_abs")[5]
|
||||
except ValueError:
|
||||
max_drawdown_abs = 0
|
||||
|
||||
return -1 * (total_profit * (1 - max_drawdown_abs * DRAWDOWN_MULT))
|
@@ -137,6 +137,7 @@ class HyperoptTools():
|
||||
}
|
||||
if not HyperoptTools._test_hyperopt_results_exist(results_file):
|
||||
# No file found.
|
||||
logger.warning(f"Hyperopt file {results_file} not found.")
|
||||
return [], 0
|
||||
|
||||
epochs = []
|
||||
@@ -299,8 +300,7 @@ class HyperoptTools():
|
||||
f"Objective: {results['loss']:.5f}")
|
||||
|
||||
@staticmethod
|
||||
def prepare_trials_columns(trials: pd.DataFrame, legacy_mode: bool,
|
||||
has_drawdown: bool) -> pd.DataFrame:
|
||||
def prepare_trials_columns(trials: pd.DataFrame, has_drawdown: bool) -> pd.DataFrame:
|
||||
trials['Best'] = ''
|
||||
|
||||
if 'results_metrics.winsdrawslosses' not in trials.columns:
|
||||
@@ -309,33 +309,26 @@ class HyperoptTools():
|
||||
|
||||
if not has_drawdown:
|
||||
# Ensure compatibility with older versions of hyperopt results
|
||||
trials['results_metrics.max_drawdown_abs'] = None
|
||||
trials['results_metrics.max_drawdown'] = None
|
||||
trials['results_metrics.max_drawdown_account'] = None
|
||||
|
||||
if not legacy_mode:
|
||||
# New mode, using backtest result for metrics
|
||||
trials['results_metrics.winsdrawslosses'] = trials.apply(
|
||||
lambda x: f"{x['results_metrics.wins']} {x['results_metrics.draws']:>4} "
|
||||
f"{x['results_metrics.losses']:>4}", axis=1)
|
||||
trials = trials[['Best', 'current_epoch', 'results_metrics.total_trades',
|
||||
'results_metrics.winsdrawslosses',
|
||||
'results_metrics.profit_mean', 'results_metrics.profit_total_abs',
|
||||
'results_metrics.profit_total', 'results_metrics.holding_avg',
|
||||
'results_metrics.max_drawdown', 'results_metrics.max_drawdown_abs',
|
||||
'loss', 'is_initial_point', 'is_best']]
|
||||
# New mode, using backtest result for metrics
|
||||
trials['results_metrics.winsdrawslosses'] = trials.apply(
|
||||
lambda x: f"{x['results_metrics.wins']} {x['results_metrics.draws']:>4} "
|
||||
f"{x['results_metrics.losses']:>4}", axis=1)
|
||||
|
||||
else:
|
||||
# Legacy mode
|
||||
trials = trials[['Best', 'current_epoch', 'results_metrics.trade_count',
|
||||
'results_metrics.winsdrawslosses', 'results_metrics.avg_profit',
|
||||
'results_metrics.total_profit', 'results_metrics.profit',
|
||||
'results_metrics.duration', 'results_metrics.max_drawdown',
|
||||
'results_metrics.max_drawdown_abs', 'loss', 'is_initial_point',
|
||||
'is_best']]
|
||||
trials = trials[['Best', 'current_epoch', 'results_metrics.total_trades',
|
||||
'results_metrics.winsdrawslosses',
|
||||
'results_metrics.profit_mean', 'results_metrics.profit_total_abs',
|
||||
'results_metrics.profit_total', 'results_metrics.holding_avg',
|
||||
'results_metrics.max_drawdown',
|
||||
'results_metrics.max_drawdown_account', 'results_metrics.max_drawdown_abs',
|
||||
'loss', 'is_initial_point', 'is_best']]
|
||||
|
||||
trials.columns = ['Best', 'Epoch', 'Trades', ' Win Draw Loss', 'Avg profit',
|
||||
'Total profit', 'Profit', 'Avg duration', 'Max Drawdown',
|
||||
'max_drawdown_abs', 'Objective', 'is_initial_point', 'is_best']
|
||||
trials.columns = [
|
||||
'Best', 'Epoch', 'Trades', ' Win Draw Loss', 'Avg profit',
|
||||
'Total profit', 'Profit', 'Avg duration', 'max_drawdown', 'max_drawdown_account',
|
||||
'max_drawdown_abs', 'Objective', 'is_initial_point', 'is_best'
|
||||
]
|
||||
|
||||
return trials
|
||||
|
||||
@@ -351,10 +344,9 @@ class HyperoptTools():
|
||||
tabulate.PRESERVE_WHITESPACE = True
|
||||
trials = json_normalize(results, max_level=1)
|
||||
|
||||
legacy_mode = 'results_metrics.total_trades' not in trials
|
||||
has_drawdown = 'results_metrics.max_drawdown_abs' in trials.columns
|
||||
has_account_drawdown = 'results_metrics.max_drawdown_account' in trials.columns
|
||||
|
||||
trials = HyperoptTools.prepare_trials_columns(trials, legacy_mode, has_drawdown)
|
||||
trials = HyperoptTools.prepare_trials_columns(trials, has_account_drawdown)
|
||||
|
||||
trials['is_profit'] = False
|
||||
trials.loc[trials['is_initial_point'], 'Best'] = '* '
|
||||
@@ -362,12 +354,12 @@ class HyperoptTools():
|
||||
trials.loc[trials['is_initial_point'] & trials['is_best'], 'Best'] = '* Best'
|
||||
trials.loc[trials['Total profit'] > 0, 'is_profit'] = True
|
||||
trials['Trades'] = trials['Trades'].astype(str)
|
||||
perc_multi = 1 if legacy_mode else 100
|
||||
# perc_multi = 1 if legacy_mode else 100
|
||||
trials['Epoch'] = trials['Epoch'].apply(
|
||||
lambda x: '{}/{}'.format(str(x).rjust(len(str(total_epochs)), ' '), total_epochs)
|
||||
)
|
||||
trials['Avg profit'] = trials['Avg profit'].apply(
|
||||
lambda x: f'{x * perc_multi:,.2f}%'.rjust(7, ' ') if not isna(x) else "--".rjust(7, ' ')
|
||||
lambda x: f'{x:,.2%}'.rjust(7, ' ') if not isna(x) else "--".rjust(7, ' ')
|
||||
)
|
||||
trials['Avg duration'] = trials['Avg duration'].apply(
|
||||
lambda x: f'{x:,.1f} m'.rjust(7, ' ') if isinstance(x, float) else f"{x}"
|
||||
@@ -379,24 +371,25 @@ class HyperoptTools():
|
||||
|
||||
stake_currency = config['stake_currency']
|
||||
|
||||
if has_drawdown:
|
||||
trials['Max Drawdown'] = trials.apply(
|
||||
lambda x: '{} {}'.format(
|
||||
round_coin_value(x['max_drawdown_abs'], stake_currency),
|
||||
'({:,.2f}%)'.format(x['Max Drawdown'] * perc_multi).rjust(10, ' ')
|
||||
).rjust(25 + len(stake_currency))
|
||||
if x['Max Drawdown'] != 0.0 else '--'.rjust(25 + len(stake_currency)),
|
||||
axis=1
|
||||
)
|
||||
else:
|
||||
trials = trials.drop(columns=['Max Drawdown'])
|
||||
trials[f"Max Drawdown{' (Acct)' if has_account_drawdown else ''}"] = trials.apply(
|
||||
lambda x: "{} {}".format(
|
||||
round_coin_value(x['max_drawdown_abs'], stake_currency, keep_trailing_zeros=True),
|
||||
(f"({x['max_drawdown_account']:,.2%})"
|
||||
if has_account_drawdown
|
||||
else f"({x['max_drawdown']:,.2%})"
|
||||
).rjust(10, ' ')
|
||||
).rjust(25 + len(stake_currency))
|
||||
if x['max_drawdown'] != 0.0 or x['max_drawdown_account'] != 0.0
|
||||
else '--'.rjust(25 + len(stake_currency)),
|
||||
axis=1
|
||||
)
|
||||
|
||||
trials = trials.drop(columns=['max_drawdown_abs'])
|
||||
trials = trials.drop(columns=['max_drawdown_abs', 'max_drawdown', 'max_drawdown_account'])
|
||||
|
||||
trials['Profit'] = trials.apply(
|
||||
lambda x: '{} {}'.format(
|
||||
round_coin_value(x['Total profit'], stake_currency),
|
||||
'({:,.2f}%)'.format(x['Profit'] * perc_multi).rjust(10, ' ')
|
||||
round_coin_value(x['Total profit'], stake_currency, keep_trailing_zeros=True),
|
||||
f"({x['Profit']:,.2%})".rjust(10, ' ')
|
||||
).rjust(25+len(stake_currency))
|
||||
if x['Total profit'] != 0.0 else '--'.rjust(25+len(stake_currency)),
|
||||
axis=1
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Union
|
||||
@@ -10,7 +11,8 @@ from tabulate import tabulate
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT
|
||||
from freqtrade.data.btanalysis import (calculate_csum, calculate_market_change,
|
||||
calculate_max_drawdown)
|
||||
from freqtrade.misc import decimals_per_coin, file_dump_json, round_coin_value
|
||||
from freqtrade.misc import (decimals_per_coin, file_dump_json, get_backtest_metadata_filename,
|
||||
round_coin_value)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -32,6 +34,11 @@ def store_backtest_stats(recordfilename: Path, stats: Dict[str, DataFrame]) -> N
|
||||
recordfilename.parent,
|
||||
f'{recordfilename.stem}-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}'
|
||||
).with_suffix(recordfilename.suffix)
|
||||
|
||||
# Store metadata separately.
|
||||
file_dump_json(get_backtest_metadata_filename(filename), stats['metadata'])
|
||||
del stats['metadata']
|
||||
|
||||
file_dump_json(filename, stats)
|
||||
|
||||
latest_filename = Path.joinpath(filename.parent, LAST_BT_RESULT_FN)
|
||||
@@ -98,11 +105,11 @@ def _generate_result_line(result: DataFrame, starting_balance: int, first_column
|
||||
}
|
||||
|
||||
|
||||
def generate_pair_metrics(data: Dict[str, Dict], stake_currency: str, starting_balance: int,
|
||||
def generate_pair_metrics(pairlist: List[str], stake_currency: str, starting_balance: int,
|
||||
results: DataFrame, skip_nan: bool = False) -> List[Dict]:
|
||||
"""
|
||||
Generates and returns a list for the given backtest data and the results dataframe
|
||||
:param data: Dict of <pair: dataframe> containing data that was used during backtesting.
|
||||
:param pairlist: Pairlist used
|
||||
:param stake_currency: stake-currency - used to correctly name headers
|
||||
:param starting_balance: Starting balance
|
||||
:param results: Dataframe containing the backtest results
|
||||
@@ -112,7 +119,7 @@ def generate_pair_metrics(data: Dict[str, Dict], stake_currency: str, starting_b
|
||||
|
||||
tabular_data = []
|
||||
|
||||
for pair in data:
|
||||
for pair in pairlist:
|
||||
result = results[results['pair'] == pair]
|
||||
if skip_nan and result['profit_abs'].isnull().all():
|
||||
continue
|
||||
@@ -194,29 +201,21 @@ def generate_sell_reason_stats(max_open_trades: int, results: DataFrame) -> List
|
||||
return tabular_data
|
||||
|
||||
|
||||
def generate_strategy_comparison(all_results: Dict) -> List[Dict]:
|
||||
def generate_strategy_comparison(bt_stats: Dict) -> List[Dict]:
|
||||
"""
|
||||
Generate summary per strategy
|
||||
:param all_results: Dict of <Strategyname: DataFrame> containing results for all strategies
|
||||
:param bt_stats: Dict of <Strategyname: DataFrame> containing results for all strategies
|
||||
:return: List of Dicts containing the metrics per Strategy
|
||||
"""
|
||||
|
||||
tabular_data = []
|
||||
for strategy, results in all_results.items():
|
||||
tabular_data.append(_generate_result_line(
|
||||
results['results'], results['config']['dry_run_wallet'], strategy)
|
||||
)
|
||||
try:
|
||||
max_drawdown_per, _, _, _, _ = calculate_max_drawdown(results['results'],
|
||||
value_col='profit_ratio')
|
||||
max_drawdown_abs, _, _, _, _ = calculate_max_drawdown(results['results'],
|
||||
value_col='profit_abs')
|
||||
except ValueError:
|
||||
max_drawdown_per = 0
|
||||
max_drawdown_abs = 0
|
||||
tabular_data[-1]['max_drawdown_per'] = round(max_drawdown_per * 100, 2)
|
||||
tabular_data[-1]['max_drawdown_abs'] = \
|
||||
round_coin_value(max_drawdown_abs, results['config']['stake_currency'], False)
|
||||
for strategy, result in bt_stats.items():
|
||||
tabular_data.append(deepcopy(result['results_per_pair'][-1]))
|
||||
# Update "key" to strategy (results_per_pair has it as "Total").
|
||||
tabular_data[-1]['key'] = strategy
|
||||
tabular_data[-1]['max_drawdown_account'] = result['max_drawdown_account']
|
||||
tabular_data[-1]['max_drawdown_abs'] = round_coin_value(
|
||||
result['max_drawdown_abs'], result['stake_currency'], False)
|
||||
return tabular_data
|
||||
|
||||
|
||||
@@ -352,14 +351,14 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def generate_strategy_stats(btdata: Dict[str, DataFrame],
|
||||
def generate_strategy_stats(pairlist: List[str],
|
||||
strategy: str,
|
||||
content: Dict[str, Any],
|
||||
min_date: datetime, max_date: datetime,
|
||||
market_change: float
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
:param btdata: Backtest data
|
||||
:param pairlist: List of pairs to backtest
|
||||
:param strategy: Strategy name
|
||||
:param content: Backtest result data in the format:
|
||||
{'results: results, 'config: config}}.
|
||||
@@ -372,11 +371,11 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame],
|
||||
if not isinstance(results, DataFrame):
|
||||
return {}
|
||||
config = content['config']
|
||||
max_open_trades = min(config['max_open_trades'], len(btdata.keys()))
|
||||
max_open_trades = min(config['max_open_trades'], len(pairlist))
|
||||
starting_balance = config['dry_run_wallet']
|
||||
stake_currency = config['stake_currency']
|
||||
|
||||
pair_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
|
||||
pair_results = generate_pair_metrics(pairlist, stake_currency=stake_currency,
|
||||
starting_balance=starting_balance,
|
||||
results=results, skip_nan=False)
|
||||
|
||||
@@ -385,7 +384,7 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame],
|
||||
|
||||
sell_reason_stats = generate_sell_reason_stats(max_open_trades=max_open_trades,
|
||||
results=results)
|
||||
left_open_results = generate_pair_metrics(btdata, stake_currency=stake_currency,
|
||||
left_open_results = generate_pair_metrics(pairlist, stake_currency=stake_currency,
|
||||
starting_balance=starting_balance,
|
||||
results=results.loc[results['is_open']],
|
||||
skip_nan=True)
|
||||
@@ -429,7 +428,7 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame],
|
||||
|
||||
'trades_per_day': round(len(results) / backtest_days, 2),
|
||||
'market_change': market_change,
|
||||
'pairlist': list(btdata.keys()),
|
||||
'pairlist': pairlist,
|
||||
'stake_amount': config['stake_amount'],
|
||||
'stake_currency': config['stake_currency'],
|
||||
'stake_currency_decimals': decimals_per_coin(config['stake_currency']),
|
||||
@@ -437,6 +436,8 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame],
|
||||
'dry_run_wallet': starting_balance,
|
||||
'final_balance': content['final_balance'],
|
||||
'rejected_signals': content['rejected_signals'],
|
||||
'timedout_entry_orders': content['timedout_entry_orders'],
|
||||
'timedout_exit_orders': content['timedout_exit_orders'],
|
||||
'max_open_trades': max_open_trades,
|
||||
'max_open_trades_setting': (config['max_open_trades']
|
||||
if config['max_open_trades'] != float('inf') else -1),
|
||||
@@ -462,12 +463,14 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame],
|
||||
}
|
||||
|
||||
try:
|
||||
max_drawdown, _, _, _, _ = calculate_max_drawdown(
|
||||
max_drawdown_legacy, _, _, _, _, _ = calculate_max_drawdown(
|
||||
results, value_col='profit_ratio')
|
||||
drawdown_abs, drawdown_start, drawdown_end, high_val, low_val = calculate_max_drawdown(
|
||||
results, value_col='profit_abs')
|
||||
(drawdown_abs, drawdown_start, drawdown_end, high_val, low_val,
|
||||
max_drawdown) = calculate_max_drawdown(
|
||||
results, value_col='profit_abs', starting_balance=starting_balance)
|
||||
strat_stats.update({
|
||||
'max_drawdown': max_drawdown,
|
||||
'max_drawdown': max_drawdown_legacy, # Deprecated - do not use
|
||||
'max_drawdown_account': max_drawdown,
|
||||
'max_drawdown_abs': drawdown_abs,
|
||||
'drawdown_start': drawdown_start.strftime(DATETIME_PRINT_FORMAT),
|
||||
'drawdown_start_ts': drawdown_start.timestamp() * 1000,
|
||||
@@ -487,6 +490,7 @@ def generate_strategy_stats(btdata: Dict[str, DataFrame],
|
||||
except ValueError:
|
||||
strat_stats.update({
|
||||
'max_drawdown': 0.0,
|
||||
'max_drawdown_account': 0.0,
|
||||
'max_drawdown_abs': 0.0,
|
||||
'max_drawdown_low': 0.0,
|
||||
'max_drawdown_high': 0.0,
|
||||
@@ -513,16 +517,26 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame],
|
||||
:param max_date: Backtest end date
|
||||
:return: Dictionary containing results per strategy and a strategy summary.
|
||||
"""
|
||||
result: Dict[str, Any] = {'strategy': {}}
|
||||
result: Dict[str, Any] = {
|
||||
'metadata': {},
|
||||
'strategy': {},
|
||||
'strategy_comparison': [],
|
||||
}
|
||||
market_change = calculate_market_change(btdata, 'close')
|
||||
|
||||
metadata = {}
|
||||
pairlist = list(btdata.keys())
|
||||
for strategy, content in all_results.items():
|
||||
strat_stats = generate_strategy_stats(btdata, strategy, content,
|
||||
strat_stats = generate_strategy_stats(pairlist, strategy, content,
|
||||
min_date, max_date, market_change=market_change)
|
||||
metadata[strategy] = {
|
||||
'run_id': content['run_id'],
|
||||
'backtest_start_time': content['backtest_start_time'],
|
||||
}
|
||||
result['strategy'][strategy] = strat_stats
|
||||
|
||||
strategy_results = generate_strategy_comparison(all_results=all_results)
|
||||
strategy_results = generate_strategy_comparison(bt_stats=result['strategy'])
|
||||
|
||||
result['metadata'] = metadata
|
||||
result['strategy_comparison'] = strategy_results
|
||||
|
||||
return result
|
||||
@@ -646,7 +660,12 @@ def text_table_strategy(strategy_results, stake_currency: str) -> str:
|
||||
headers.append('Drawdown')
|
||||
|
||||
# Align drawdown string on the center two space separator.
|
||||
drawdown = [f'{t["max_drawdown_per"]:.2f}' for t in strategy_results]
|
||||
if 'max_drawdown_account' in strategy_results[0]:
|
||||
drawdown = [f'{t["max_drawdown_account"] * 100:.2f}' for t in strategy_results]
|
||||
else:
|
||||
# Support for prior backtest results
|
||||
drawdown = [f'{t["max_drawdown_per"]:.2f}' for t in strategy_results]
|
||||
|
||||
dd_pad_abs = max([len(t['max_drawdown_abs']) for t in strategy_results])
|
||||
dd_pad_per = max([len(dd) for dd in drawdown])
|
||||
drawdown = [f'{t["max_drawdown_abs"]:>{dd_pad_abs}} {stake_currency} {dd:>{dd_pad_per}}%'
|
||||
@@ -709,6 +728,9 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"),
|
||||
('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"),
|
||||
('Rejected Buy signals', strat_results.get('rejected_signals', 'N/A')),
|
||||
('Entry/Exit Timeouts',
|
||||
f"{strat_results.get('timedout_entry_orders', 'N/A')} / "
|
||||
f"{strat_results.get('timedout_exit_orders', 'N/A')}"),
|
||||
('', ''), # Empty line to improve readability
|
||||
|
||||
('Min balance', round_coin_value(strat_results['csum_min'],
|
||||
@@ -716,7 +738,10 @@ def text_table_add_metrics(strat_results: Dict) -> str:
|
||||
('Max balance', round_coin_value(strat_results['csum_max'],
|
||||
strat_results['stake_currency'])),
|
||||
|
||||
('Drawdown', f"{strat_results['max_drawdown']:.2%}"),
|
||||
# Compatibility to show old hyperopt results
|
||||
('Drawdown (Account)', f"{strat_results['max_drawdown_account']:.2%}")
|
||||
if 'max_drawdown_account' in strat_results else (
|
||||
'Drawdown', f"{strat_results['max_drawdown']:.2%}"),
|
||||
('Drawdown', round_coin_value(strat_results['max_drawdown_abs'],
|
||||
strat_results['stake_currency'])),
|
||||
('Drawdown high', round_coin_value(strat_results['max_drawdown_high'],
|
||||
|
@@ -28,7 +28,36 @@ def get_backup_name(tabs, backup_prefix: str):
|
||||
return table_back_name
|
||||
|
||||
|
||||
def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, cols: List):
|
||||
def get_last_sequence_ids(engine, trade_back_name, order_back_name):
|
||||
order_id: int = None
|
||||
trade_id: int = None
|
||||
|
||||
if engine.name == 'postgresql':
|
||||
with engine.begin() as connection:
|
||||
trade_id = connection.execute(text("select nextval('trades_id_seq')")).fetchone()[0]
|
||||
order_id = connection.execute(text("select nextval('orders_id_seq')")).fetchone()[0]
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(
|
||||
f"ALTER SEQUENCE orders_id_seq rename to {order_back_name}_id_seq_bak"))
|
||||
connection.execute(text(
|
||||
f"ALTER SEQUENCE trades_id_seq rename to {trade_back_name}_id_seq_bak"))
|
||||
return order_id, trade_id
|
||||
|
||||
|
||||
def set_sequence_ids(engine, order_id, trade_id):
|
||||
|
||||
if engine.name == 'postgresql':
|
||||
with engine.begin() as connection:
|
||||
if order_id:
|
||||
connection.execute(text(f"ALTER SEQUENCE orders_id_seq RESTART WITH {order_id}"))
|
||||
if trade_id:
|
||||
connection.execute(text(f"ALTER SEQUENCE trades_id_seq RESTART WITH {trade_id}"))
|
||||
|
||||
|
||||
def migrate_trades_and_orders_table(
|
||||
decl_base, inspector, engine,
|
||||
trade_back_name: str, cols: List,
|
||||
order_back_name: str, cols_order: List):
|
||||
fee_open = get_column_def(cols, 'fee_open', 'fee')
|
||||
fee_open_cost = get_column_def(cols, 'fee_open_cost', 'null')
|
||||
fee_open_currency = get_column_def(cols, 'fee_open_currency', 'null')
|
||||
@@ -64,11 +93,20 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
|
||||
|
||||
# Schema migration necessary
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"alter table trades rename to {table_back_name}"))
|
||||
connection.execute(text(f"alter table trades rename to {trade_back_name}"))
|
||||
|
||||
with engine.begin() as connection:
|
||||
# drop indexes on backup table in new session
|
||||
for index in inspector.get_indexes(table_back_name):
|
||||
connection.execute(text(f"drop index {index['name']}"))
|
||||
for index in inspector.get_indexes(trade_back_name):
|
||||
if engine.name == 'mysql':
|
||||
connection.execute(text(f"drop index {index['name']} on {trade_back_name}"))
|
||||
else:
|
||||
connection.execute(text(f"drop index {index['name']}"))
|
||||
|
||||
order_id, trade_id = get_last_sequence_ids(engine, trade_back_name, order_back_name)
|
||||
|
||||
drop_orders_table(engine, order_back_name)
|
||||
|
||||
# let SQLAlchemy create the schema as required
|
||||
decl_base.metadata.create_all(engine)
|
||||
|
||||
@@ -100,9 +138,12 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col
|
||||
{sell_order_status} sell_order_status,
|
||||
{strategy} strategy, {buy_tag} buy_tag, {timeframe} timeframe,
|
||||
{open_trade_value} open_trade_value, {close_profit_abs} close_profit_abs
|
||||
from {table_back_name}
|
||||
from {trade_back_name}
|
||||
"""))
|
||||
|
||||
migrate_orders_table(engine, order_back_name, cols_order)
|
||||
set_sequence_ids(engine, order_id, trade_id)
|
||||
|
||||
|
||||
def migrate_open_orders_to_trades(engine):
|
||||
with engine.begin() as connection:
|
||||
@@ -121,31 +162,39 @@ def migrate_open_orders_to_trades(engine):
|
||||
"""))
|
||||
|
||||
|
||||
def migrate_orders_table(decl_base, inspector, engine, table_back_name: str, cols: List):
|
||||
# Schema migration necessary
|
||||
def drop_orders_table(engine, table_back_name: str):
|
||||
# Drop and recreate orders table as backup
|
||||
# This drops foreign keys, too.
|
||||
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"alter table orders rename to {table_back_name}"))
|
||||
connection.execute(text(f"create table {table_back_name} as select * from orders"))
|
||||
connection.execute(text("drop table orders"))
|
||||
|
||||
with engine.begin() as connection:
|
||||
# drop indexes on backup table in new session
|
||||
for index in inspector.get_indexes(table_back_name):
|
||||
connection.execute(text(f"drop index {index['name']}"))
|
||||
|
||||
def migrate_orders_table(engine, table_back_name: str, cols_order: List):
|
||||
|
||||
ft_fee_base = get_column_def(cols_order, 'ft_fee_base', 'null')
|
||||
|
||||
# let SQLAlchemy create the schema as required
|
||||
decl_base.metadata.create_all(engine)
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text(f"""
|
||||
insert into orders ( id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id,
|
||||
status, symbol, order_type, side, price, amount, filled, average, remaining, cost,
|
||||
order_date, order_filled_date, order_update_date)
|
||||
order_date, order_filled_date, order_update_date, ft_fee_base)
|
||||
select id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id,
|
||||
status, symbol, order_type, side, price, amount, filled, null average, remaining, cost,
|
||||
order_date, order_filled_date, order_update_date
|
||||
order_date, order_filled_date, order_update_date, {ft_fee_base}
|
||||
from {table_back_name}
|
||||
"""))
|
||||
|
||||
|
||||
def set_sqlite_to_wal(engine):
|
||||
if engine.name == 'sqlite' and str(engine.url) != 'sqlite://':
|
||||
# Set Mode to
|
||||
with engine.begin() as connection:
|
||||
connection.execute(text("PRAGMA journal_mode=wal"))
|
||||
|
||||
|
||||
def check_migrate(engine, decl_base, previous_tables) -> None:
|
||||
"""
|
||||
Checks if migration is necessary and migrates if necessary
|
||||
@@ -153,26 +202,22 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
|
||||
inspector = inspect(engine)
|
||||
|
||||
cols = inspector.get_columns('trades')
|
||||
cols_orders = inspector.get_columns('orders')
|
||||
tabs = get_table_names_for_table(inspector, 'trades')
|
||||
table_back_name = get_backup_name(tabs, 'trades_bak')
|
||||
order_tabs = get_table_names_for_table(inspector, 'orders')
|
||||
order_table_bak_name = get_backup_name(order_tabs, 'orders_bak')
|
||||
|
||||
# Check for latest column
|
||||
if not has_column(cols, 'buy_tag'):
|
||||
logger.info(f'Running database migration for trades - backup: {table_back_name}')
|
||||
migrate_trades_table(decl_base, inspector, engine, table_back_name, cols)
|
||||
# Reread columns - the above recreated the table!
|
||||
inspector = inspect(engine)
|
||||
cols = inspector.get_columns('trades')
|
||||
# Check if migration necessary
|
||||
# Migrates both trades and orders table!
|
||||
# if not has_column(cols, 'buy_tag'):
|
||||
if 'orders' not in previous_tables or not has_column(cols_orders, 'ft_fee_base'):
|
||||
logger.info(f"Running database migration for trades - "
|
||||
f"backup: {table_back_name}, {order_table_bak_name}")
|
||||
migrate_trades_and_orders_table(
|
||||
decl_base, inspector, engine, table_back_name, cols, order_table_bak_name, cols_orders)
|
||||
|
||||
if 'orders' not in previous_tables and 'trades' in previous_tables:
|
||||
logger.info('Moving open orders to Orders table.')
|
||||
migrate_open_orders_to_trades(engine)
|
||||
else:
|
||||
cols_order = inspector.get_columns('orders')
|
||||
|
||||
if not has_column(cols_order, 'average'):
|
||||
tabs = get_table_names_for_table(inspector, 'orders')
|
||||
# Empty for now - as there is only one iteration of the orders table so far.
|
||||
table_back_name = get_backup_name(tabs, 'orders_bak')
|
||||
|
||||
migrate_orders_table(decl_base, inspector, engine, table_back_name, cols)
|
||||
set_sqlite_to_wal(engine)
|
||||
|
@@ -16,7 +16,6 @@ from sqlalchemy.sql.schema import UniqueConstraint
|
||||
from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES
|
||||
from freqtrade.enums import SellType
|
||||
from freqtrade.exceptions import DependencyException, OperationalException
|
||||
from freqtrade.misc import safe_value_fallback
|
||||
from freqtrade.persistence.migrations import check_migrate
|
||||
|
||||
|
||||
@@ -39,6 +38,9 @@ def init_db(db_url: str, clean_open_orders: bool = False) -> None:
|
||||
"""
|
||||
kwargs = {}
|
||||
|
||||
if db_url == 'sqlite:///':
|
||||
raise OperationalException(
|
||||
f'Bad db-url {db_url}. For in-memory database, please use `sqlite://`.')
|
||||
if db_url == 'sqlite://':
|
||||
kwargs.update({
|
||||
'poolclass': StaticPool,
|
||||
@@ -113,14 +115,15 @@ class Order(_DECL_BASE):
|
||||
|
||||
trade = relationship("Trade", back_populates="orders")
|
||||
|
||||
ft_order_side = Column(String(25), nullable=False)
|
||||
ft_pair = Column(String(25), nullable=False)
|
||||
# order_side can only be 'buy', 'sell' or 'stoploss'
|
||||
ft_order_side: str = Column(String(25), nullable=False)
|
||||
ft_pair: str = Column(String(25), nullable=False)
|
||||
ft_is_open = Column(Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
order_id = Column(String(255), nullable=False, index=True)
|
||||
status = Column(String(255), nullable=True)
|
||||
symbol = Column(String(25), nullable=True)
|
||||
order_type = Column(String(50), nullable=True)
|
||||
order_type: str = Column(String(50), nullable=True)
|
||||
side = Column(String(25), nullable=True)
|
||||
price = Column(Float, nullable=True)
|
||||
average = Column(Float, nullable=True)
|
||||
@@ -132,6 +135,29 @@ class Order(_DECL_BASE):
|
||||
order_filled_date = Column(DateTime, nullable=True)
|
||||
order_update_date = Column(DateTime, nullable=True)
|
||||
|
||||
ft_fee_base = Column(Float, nullable=True)
|
||||
|
||||
@property
|
||||
def order_date_utc(self) -> datetime:
|
||||
""" Order-date with UTC timezoneinfo"""
|
||||
return self.order_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
@property
|
||||
def safe_price(self) -> float:
|
||||
return self.average or self.price
|
||||
|
||||
@property
|
||||
def safe_filled(self) -> float:
|
||||
return self.filled or self.amount or 0.0
|
||||
|
||||
@property
|
||||
def safe_fee_base(self) -> float:
|
||||
return self.ft_fee_base or 0.0
|
||||
|
||||
@property
|
||||
def safe_amount_after_fee(self) -> float:
|
||||
return self.safe_filled - self.safe_fee_base
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
return (f'Order(id={self.id}, order_id={self.order_id}, trade_id={self.ft_trade_id}, '
|
||||
@@ -165,6 +191,36 @@ class Order(_DECL_BASE):
|
||||
self.order_filled_date = datetime.now(timezone.utc)
|
||||
self.order_update_date = datetime.now(timezone.utc)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'amount': self.amount,
|
||||
'average': round(self.average, 8) if self.average else 0,
|
||||
'safe_price': self.safe_price,
|
||||
'cost': self.cost if self.cost else 0,
|
||||
'filled': self.filled,
|
||||
'ft_order_side': self.ft_order_side,
|
||||
'is_open': self.ft_is_open,
|
||||
'order_date': self.order_date.strftime(DATETIME_PRINT_FORMAT)
|
||||
if self.order_date else None,
|
||||
'order_timestamp': int(self.order_date.replace(
|
||||
tzinfo=timezone.utc).timestamp() * 1000) if self.order_date else None,
|
||||
'order_filled_date': self.order_filled_date.strftime(DATETIME_PRINT_FORMAT)
|
||||
if self.order_filled_date else None,
|
||||
'order_filled_timestamp': int(self.order_filled_date.replace(
|
||||
tzinfo=timezone.utc).timestamp() * 1000) if self.order_filled_date else None,
|
||||
'order_type': self.order_type,
|
||||
'pair': self.ft_pair,
|
||||
'price': self.price,
|
||||
'remaining': self.remaining,
|
||||
'status': self.status,
|
||||
}
|
||||
|
||||
def close_bt_order(self, close_date: datetime):
|
||||
self.order_filled_date = close_date
|
||||
self.filled = self.amount
|
||||
self.status = 'closed'
|
||||
self.ft_is_open = False
|
||||
|
||||
@staticmethod
|
||||
def update_orders(orders: List['Order'], order: Dict[str, Any]):
|
||||
"""
|
||||
@@ -282,6 +338,16 @@ class LocalTrade():
|
||||
return self.close_date.replace(tzinfo=timezone.utc)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
filled_orders = self.select_filled_orders()
|
||||
filled_entries = []
|
||||
filled_exits = []
|
||||
if len(filled_orders) > 0:
|
||||
for order in filled_orders:
|
||||
if order.ft_order_side == 'buy':
|
||||
filled_entries.append(order.to_json())
|
||||
if order.ft_order_side == 'sell':
|
||||
filled_exits.append(order.to_json())
|
||||
|
||||
return {
|
||||
'trade_id': self.id,
|
||||
'pair': self.pair,
|
||||
@@ -345,6 +411,8 @@ class LocalTrade():
|
||||
'max_rate': self.max_rate,
|
||||
|
||||
'open_order_id': self.open_order_id,
|
||||
'filled_entry_orders': filled_entries,
|
||||
'filled_exit_orders': filled_exits,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -407,40 +475,39 @@ class LocalTrade():
|
||||
f"Trailing stoploss saved us: "
|
||||
f"{float(self.stop_loss) - float(self.initial_stop_loss):.8f}.")
|
||||
|
||||
def update(self, order: Dict) -> None:
|
||||
def update_trade(self, order: Order) -> None:
|
||||
"""
|
||||
Updates this entity with amount and actual open/close rates.
|
||||
:param order: order retrieved by exchange.fetch_order()
|
||||
:return: None
|
||||
"""
|
||||
order_type = order['type']
|
||||
# Ignore open and cancelled orders
|
||||
if order['status'] == 'open' or safe_value_fallback(order, 'average', 'price') is None:
|
||||
if order.status == 'open' or order.safe_price is None:
|
||||
return
|
||||
|
||||
logger.info('Updating trade (id=%s) ...', self.id)
|
||||
logger.info(f'Updating trade (id={self.id}) ...')
|
||||
|
||||
if order_type in ('market', 'limit') and order['side'] == 'buy':
|
||||
if order.ft_order_side == 'buy':
|
||||
# Update open rate and actual amount
|
||||
self.open_rate = float(safe_value_fallback(order, 'average', 'price'))
|
||||
self.amount = float(safe_value_fallback(order, 'filled', 'amount'))
|
||||
self.recalc_open_trade_value()
|
||||
self.open_rate = order.safe_price
|
||||
self.amount = order.safe_amount_after_fee
|
||||
if self.is_open:
|
||||
logger.info(f'{order_type.upper()}_BUY has been fulfilled for {self}.')
|
||||
logger.info(f'{order.order_type.upper()}_BUY has been fulfilled for {self}.')
|
||||
self.open_order_id = None
|
||||
elif order_type in ('market', 'limit') and order['side'] == 'sell':
|
||||
self.recalc_trade_from_orders()
|
||||
elif order.ft_order_side == 'sell':
|
||||
if self.is_open:
|
||||
logger.info(f'{order_type.upper()}_SELL has been fulfilled for {self}.')
|
||||
self.close(safe_value_fallback(order, 'average', 'price'))
|
||||
elif order_type in ('stop_loss_limit', 'stop-loss', 'stop-loss-limit', 'stop'):
|
||||
logger.info(f'{order.order_type.upper()}_SELL has been fulfilled for {self}.')
|
||||
self.close(order.safe_price)
|
||||
elif order.ft_order_side == 'stoploss':
|
||||
self.stoploss_order_id = None
|
||||
self.close_rate_requested = self.stop_loss
|
||||
self.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value
|
||||
if self.is_open:
|
||||
logger.info(f'{order_type.upper()} is hit for {self}.')
|
||||
self.close(safe_value_fallback(order, 'average', 'price'))
|
||||
logger.info(f'{order.order_type.upper()} is hit for {self}.')
|
||||
self.close(order.safe_price)
|
||||
else:
|
||||
raise ValueError(f'Unknown order type: {order_type}')
|
||||
raise ValueError(f'Unknown order type: {order.order_type}')
|
||||
Trade.commit()
|
||||
|
||||
def close(self, rate: float, *, show_msg: bool = True) -> None:
|
||||
@@ -568,14 +635,59 @@ class LocalTrade():
|
||||
profit_ratio = (close_trade_value / self.open_trade_value) - 1
|
||||
return float(f"{profit_ratio:.8f}")
|
||||
|
||||
def select_order(self, order_side: str, is_open: Optional[bool]) -> Optional[Order]:
|
||||
def recalc_trade_from_orders(self):
|
||||
# We need at least 2 entry orders for averaging amounts and rates.
|
||||
if len(self.select_filled_orders('buy')) < 2:
|
||||
# Just in case, still recalc open trade value
|
||||
self.recalc_open_trade_value()
|
||||
return
|
||||
|
||||
total_amount = 0.0
|
||||
total_stake = 0.0
|
||||
for o in self.orders:
|
||||
if (o.ft_is_open or
|
||||
(o.ft_order_side != 'buy') or
|
||||
(o.status not in NON_OPEN_EXCHANGE_STATES)):
|
||||
continue
|
||||
|
||||
tmp_amount = o.safe_amount_after_fee
|
||||
tmp_price = o.average or o.price
|
||||
if o.filled is not None:
|
||||
tmp_amount = o.filled
|
||||
if tmp_amount > 0.0 and tmp_price is not None:
|
||||
total_amount += tmp_amount
|
||||
total_stake += tmp_price * tmp_amount
|
||||
|
||||
if total_amount > 0:
|
||||
self.open_rate = total_stake / total_amount
|
||||
self.stake_amount = total_stake
|
||||
self.amount = total_amount
|
||||
self.fee_open_cost = self.fee_open * self.stake_amount
|
||||
self.recalc_open_trade_value()
|
||||
if self.stop_loss_pct is not None and self.open_rate is not None:
|
||||
self.adjust_stop_loss(self.open_rate, self.stop_loss_pct)
|
||||
|
||||
def select_order_by_order_id(self, order_id: str) -> Optional[Order]:
|
||||
"""
|
||||
Finds order object by Order id.
|
||||
:param order_id: Exchange order id
|
||||
"""
|
||||
for o in self.orders:
|
||||
if o.order_id == order_id:
|
||||
return o
|
||||
return None
|
||||
|
||||
def select_order(
|
||||
self, order_side: str = None, is_open: Optional[bool] = None) -> Optional[Order]:
|
||||
"""
|
||||
Finds latest order for this orderside and status
|
||||
:param order_side: Side of the order (either 'buy' or 'sell')
|
||||
:param order_side: ft_order_side of the order (either 'buy', 'sell' or 'stoploss')
|
||||
:param is_open: Only search for open orders?
|
||||
:return: latest Order object if it exists, else None
|
||||
"""
|
||||
orders = [o for o in self.orders if o.side == order_side]
|
||||
orders = self.orders
|
||||
if order_side:
|
||||
orders = [o for o in self.orders if o.ft_order_side == order_side]
|
||||
if is_open is not None:
|
||||
orders = [o for o in orders if o.ft_is_open == is_open]
|
||||
if len(orders) > 0:
|
||||
@@ -583,6 +695,34 @@ class LocalTrade():
|
||||
else:
|
||||
return None
|
||||
|
||||
def select_filled_orders(self, order_side: Optional[str] = None) -> List['Order']:
|
||||
"""
|
||||
Finds filled orders for this orderside.
|
||||
:param order_side: Side of the order (either 'buy', 'sell', or None)
|
||||
:return: array of Order objects
|
||||
"""
|
||||
return [o for o in self.orders if ((o.ft_order_side == order_side) or (order_side is None))
|
||||
and o.ft_is_open is False and
|
||||
(o.filled or 0) > 0 and
|
||||
o.status in NON_OPEN_EXCHANGE_STATES]
|
||||
|
||||
@property
|
||||
def nr_of_successful_buys(self) -> int:
|
||||
"""
|
||||
Helper function to count the number of buy orders that have been filled.
|
||||
:return: int count of buy orders that have been filled for this trade.
|
||||
"""
|
||||
|
||||
return len(self.select_filled_orders('buy'))
|
||||
|
||||
@property
|
||||
def nr_of_successful_sells(self) -> int:
|
||||
"""
|
||||
Helper function to count the number of sell orders that have been filled.
|
||||
:return: int count of sell orders that have been filled for this trade.
|
||||
"""
|
||||
return len(self.select_filled_orders('sell'))
|
||||
|
||||
@staticmethod
|
||||
def get_trades_proxy(*, pair: str = None, is_open: bool = None,
|
||||
open_date: datetime = None, close_date: datetime = None,
|
||||
@@ -670,7 +810,7 @@ class Trade(_DECL_BASE, LocalTrade):
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan")
|
||||
orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan", lazy="joined")
|
||||
|
||||
exchange = Column(String(25), nullable=False)
|
||||
pair = Column(String(25), nullable=False, index=True)
|
||||
@@ -681,11 +821,11 @@ class Trade(_DECL_BASE, LocalTrade):
|
||||
fee_close = Column(Float, nullable=False, default=0.0)
|
||||
fee_close_cost = Column(Float, nullable=True)
|
||||
fee_close_currency = Column(String(25), nullable=True)
|
||||
open_rate = Column(Float)
|
||||
open_rate: float = Column(Float)
|
||||
open_rate_requested = Column(Float)
|
||||
# open_trade_value - calculated via _calc_open_trade_value
|
||||
open_trade_value = Column(Float)
|
||||
close_rate = Column(Float)
|
||||
close_rate: Optional[float] = Column(Float)
|
||||
close_rate_requested = Column(Float)
|
||||
close_profit = Column(Float)
|
||||
close_profit_abs = Column(Float)
|
||||
|
@@ -5,7 +5,8 @@ from typing import Any, Dict, List
|
||||
import pandas as pd
|
||||
|
||||
from freqtrade.configuration import TimeRange
|
||||
from freqtrade.data.btanalysis import (calculate_max_drawdown, combine_dataframes_with_mean,
|
||||
from freqtrade.data.btanalysis import (analyze_trade_parallelism, calculate_max_drawdown,
|
||||
calculate_underwater, combine_dataframes_with_mean,
|
||||
create_cum_profit, extract_trades_of_period, load_trades)
|
||||
from freqtrade.data.converter import trim_dataframe
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
@@ -60,8 +61,8 @@ def init_plotscript(config, markets: List, startup_candles: int = 0):
|
||||
startup_candles, min_date)
|
||||
|
||||
no_trades = False
|
||||
filename = config.get('exportfilename')
|
||||
if config.get('no_trades', False):
|
||||
filename = config.get("exportfilename")
|
||||
if config.get("no_trades", False):
|
||||
no_trades = True
|
||||
elif config['trade_source'] == 'file':
|
||||
if not filename.is_dir() and not filename.is_file():
|
||||
@@ -160,7 +161,7 @@ def add_max_drawdown(fig, row, trades: pd.DataFrame, df_comb: pd.DataFrame,
|
||||
Add scatter points indicating max drawdown
|
||||
"""
|
||||
try:
|
||||
max_drawdown, highdate, lowdate, _, _ = calculate_max_drawdown(trades)
|
||||
_, highdate, lowdate, _, _, max_drawdown = calculate_max_drawdown(trades)
|
||||
|
||||
drawdown = go.Scatter(
|
||||
x=[highdate, lowdate],
|
||||
@@ -185,6 +186,48 @@ def add_max_drawdown(fig, row, trades: pd.DataFrame, df_comb: pd.DataFrame,
|
||||
return fig
|
||||
|
||||
|
||||
def add_underwater(fig, row, trades: pd.DataFrame) -> make_subplots:
|
||||
"""
|
||||
Add underwater plot
|
||||
"""
|
||||
try:
|
||||
underwater = calculate_underwater(trades, value_col="profit_abs")
|
||||
|
||||
underwater = go.Scatter(
|
||||
x=underwater['date'],
|
||||
y=underwater['drawdown'],
|
||||
name="Underwater Plot",
|
||||
fill='tozeroy',
|
||||
fillcolor='#cc362b',
|
||||
line={'color': '#cc362b'},
|
||||
)
|
||||
fig.add_trace(underwater, row, 1)
|
||||
except ValueError:
|
||||
logger.warning("No trades found - not plotting underwater plot")
|
||||
return fig
|
||||
|
||||
|
||||
def add_parallelism(fig, row, trades: pd.DataFrame, timeframe: str) -> make_subplots:
|
||||
"""
|
||||
Add Chart showing trade parallelism
|
||||
"""
|
||||
try:
|
||||
result = analyze_trade_parallelism(trades, timeframe)
|
||||
|
||||
drawdown = go.Scatter(
|
||||
x=result.index,
|
||||
y=result['open_trades'],
|
||||
name="Parallel trades",
|
||||
fill='tozeroy',
|
||||
fillcolor='#242222',
|
||||
line={'color': '#242222'},
|
||||
)
|
||||
fig.add_trace(drawdown, row, 1)
|
||||
except ValueError:
|
||||
logger.warning("No trades found - not plotting Parallelism.")
|
||||
return fig
|
||||
|
||||
|
||||
def plot_trades(fig, trades: pd.DataFrame) -> make_subplots:
|
||||
"""
|
||||
Add trades to "fig"
|
||||
@@ -192,10 +235,12 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots:
|
||||
# Trades can be empty
|
||||
if trades is not None and len(trades) > 0:
|
||||
# Create description for sell summarizing the trade
|
||||
trades['desc'] = trades.apply(lambda row: f"{row['profit_ratio']:.2%}, "
|
||||
f"{row['sell_reason']}, "
|
||||
f"{row['trade_duration']} min",
|
||||
axis=1)
|
||||
trades['desc'] = trades.apply(
|
||||
lambda row: f"{row['profit_ratio']:.2%}, " +
|
||||
(f"{row['buy_tag']}, " if row['buy_tag'] is not None else "") +
|
||||
f"{row['sell_reason']}, " +
|
||||
f"{row['trade_duration']} min",
|
||||
axis=1)
|
||||
trade_buys = go.Scatter(
|
||||
x=trades["open_date"],
|
||||
y=trades["open_rate"],
|
||||
@@ -460,7 +505,12 @@ def generate_candlestick_graph(pair: str, data: pd.DataFrame, trades: pd.DataFra
|
||||
def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame],
|
||||
trades: pd.DataFrame, timeframe: str, stake_currency: str) -> go.Figure:
|
||||
# Combine close-values for all pairs, rename columns to "pair"
|
||||
df_comb = combine_dataframes_with_mean(data, "close")
|
||||
try:
|
||||
df_comb = combine_dataframes_with_mean(data, "close")
|
||||
except ValueError:
|
||||
raise OperationalException(
|
||||
"No data found. Please make sure that data is available for "
|
||||
"the timerange and pairs selected.")
|
||||
|
||||
# Trim trades to available OHLCV data
|
||||
trades = extract_trades_of_period(df_comb, trades, date_index=True)
|
||||
@@ -477,20 +527,30 @@ def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame],
|
||||
name='Avg close price',
|
||||
)
|
||||
|
||||
fig = make_subplots(rows=3, cols=1, shared_xaxes=True,
|
||||
row_width=[1, 1, 1],
|
||||
fig = make_subplots(rows=5, cols=1, shared_xaxes=True,
|
||||
row_heights=[1, 1, 1, 0.5, 1],
|
||||
vertical_spacing=0.05,
|
||||
subplot_titles=["AVG Close Price", "Combined Profit", "Profit per pair"])
|
||||
subplot_titles=[
|
||||
"AVG Close Price",
|
||||
"Combined Profit",
|
||||
"Profit per pair",
|
||||
"Parallelism",
|
||||
"Underwater",
|
||||
])
|
||||
fig['layout'].update(title="Freqtrade Profit plot")
|
||||
fig['layout']['yaxis1'].update(title='Price')
|
||||
fig['layout']['yaxis2'].update(title=f'Profit {stake_currency}')
|
||||
fig['layout']['yaxis3'].update(title=f'Profit {stake_currency}')
|
||||
fig['layout']['yaxis4'].update(title='Trade count')
|
||||
fig['layout']['yaxis5'].update(title='Underwater Plot')
|
||||
fig['layout']['xaxis']['rangeslider'].update(visible=False)
|
||||
fig.update_layout(modebar_add=["v1hovermode", "toggleSpikeLines"])
|
||||
|
||||
fig.add_trace(avgclose, 1, 1)
|
||||
fig = add_profit(fig, 2, df_comb, 'cum_profit', 'Profit')
|
||||
fig = add_max_drawdown(fig, 2, trades, df_comb, timeframe)
|
||||
fig = add_parallelism(fig, 4, trades, timeframe)
|
||||
fig = add_underwater(fig, 5, trades)
|
||||
|
||||
for pair in pairs:
|
||||
profit_col = f'cum_profit_{pair}'
|
||||
|
@@ -60,6 +60,7 @@ class PerformanceFilter(IPairList):
|
||||
|
||||
# Get pairlist from performance dataframe values
|
||||
list_df = pd.DataFrame({'pair': pairlist})
|
||||
list_df['prior_idx'] = list_df.index
|
||||
|
||||
# Set initial value for pairs with no trades to 0
|
||||
# Sort the list using:
|
||||
@@ -67,7 +68,7 @@ class PerformanceFilter(IPairList):
|
||||
# - then count (low to high, so as to favor same performance with fewer trades)
|
||||
# - then pair name alphametically
|
||||
sorted_df = list_df.merge(performance, on='pair', how='left')\
|
||||
.fillna(0).sort_values(by=['count', 'pair'], ascending=True)\
|
||||
.fillna(0).sort_values(by=['count', 'prior_idx'], ascending=True)\
|
||||
.sort_values(by=['profit_ratio'], ascending=False)
|
||||
if self._min_profit is not None:
|
||||
removed = sorted_df[sorted_df['profit_ratio'] < self._min_profit]
|
||||
|
@@ -47,7 +47,7 @@ class SpreadFilter(IPairList):
|
||||
spread = 1 - ticker['bid'] / ticker['ask']
|
||||
if spread > self._max_spread_ratio:
|
||||
self.log_once(f"Removed {pair} from whitelist, because spread "
|
||||
f"{spread * 100:.3%} > {self._max_spread_ratio:.3%}",
|
||||
f"{spread:.3%} > {self._max_spread_ratio:.3%}",
|
||||
logger.info)
|
||||
return False
|
||||
else:
|
||||
|
@@ -4,7 +4,6 @@ Volume PairList provider
|
||||
Provides dynamic pair list based on trade volumes
|
||||
"""
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import arrow
|
||||
@@ -120,10 +119,17 @@ class VolumePairList(IPairList):
|
||||
else:
|
||||
# Use fresh pairlist
|
||||
# Check if pair quote currency equals to the stake currency.
|
||||
_pairlist = [k for k in self._exchange.get_markets(
|
||||
quote_currencies=[self._stake_currency],
|
||||
pairs_only=True, active_only=True).keys()]
|
||||
# No point in testing for blacklisted pairs...
|
||||
_pairlist = self.verify_blacklist(_pairlist, logger.info)
|
||||
|
||||
filtered_tickers = [
|
||||
v for k, v in tickers.items()
|
||||
if (self._exchange.get_pair_quote_currency(k) == self._stake_currency
|
||||
and (self._use_range or v[self._sort_key] is not None))]
|
||||
and (self._use_range or v[self._sort_key] is not None)
|
||||
and v['symbol'] in _pairlist)]
|
||||
pairlist = [s['symbol'] for s in filtered_tickers]
|
||||
|
||||
pairlist = self.filter_pairlist(pairlist, tickers)
|
||||
@@ -178,12 +184,16 @@ class VolumePairList(IPairList):
|
||||
] if (p['symbol'], self._lookback_timeframe) in candles else None
|
||||
# in case of candle data calculate typical price and quoteVolume for candle
|
||||
if pair_candles is not None and not pair_candles.empty:
|
||||
pair_candles['typical_price'] = (pair_candles['high'] + pair_candles['low']
|
||||
+ pair_candles['close']) / 3
|
||||
pair_candles['quoteVolume'] = (
|
||||
pair_candles['volume'] * pair_candles['typical_price']
|
||||
)
|
||||
if self._exchange._ft_has["ohlcv_volume_currency"] == "base":
|
||||
pair_candles['typical_price'] = (pair_candles['high'] + pair_candles['low']
|
||||
+ pair_candles['close']) / 3
|
||||
|
||||
pair_candles['quoteVolume'] = (
|
||||
pair_candles['volume'] * pair_candles['typical_price']
|
||||
)
|
||||
else:
|
||||
# Exchange ohlcv data is in quote volume already.
|
||||
pair_candles['quoteVolume'] = pair_candles['volume']
|
||||
# ensure that a rolling sum over the lookback_period is built
|
||||
# if pair_candles contains more candles than lookback_period
|
||||
quoteVolume = (pair_candles['quoteVolume']
|
||||
@@ -204,7 +214,7 @@ class VolumePairList(IPairList):
|
||||
|
||||
# Validate whitelist to only have active market pairs
|
||||
pairs = self._whitelist_for_active_markets([s['symbol'] for s in sorted_tickers])
|
||||
pairs = self.verify_blacklist(pairs, partial(self.log_once, logmethod=logger.info))
|
||||
pairs = self.verify_blacklist(pairs, logmethod=logger.info)
|
||||
# Limit pairlist to the requested number of pairs
|
||||
pairs = pairs[:self._number_pairs]
|
||||
|
||||
|
@@ -55,7 +55,8 @@ class MaxDrawdown(IProtection):
|
||||
|
||||
# Drawdown is always positive
|
||||
try:
|
||||
drawdown, _, _, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit')
|
||||
# TODO: This should use absolute profit calculation, considering account balance.
|
||||
drawdown, _, _, _, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit')
|
||||
except ValueError:
|
||||
return False, None, None
|
||||
|
||||
|
@@ -96,7 +96,9 @@ class StrategyResolver(IResolver):
|
||||
("ignore_roi_if_buy_signal", False),
|
||||
("sell_profit_offset", 0.0),
|
||||
("disable_dataframe_checks", False),
|
||||
("ignore_buying_expired_candle_after", 0)
|
||||
("ignore_buying_expired_candle_after", 0),
|
||||
("position_adjustment_enable", False),
|
||||
("max_entry_position_adjustment", -1),
|
||||
]
|
||||
for attribute, default in attributes:
|
||||
StrategyResolver._override_attribute_helper(strategy, config,
|
||||
|
@@ -8,7 +8,7 @@ from freqtrade.configuration.config_validation import validate_config_consistenc
|
||||
from freqtrade.enums import BacktestState
|
||||
from freqtrade.exceptions import DependencyException
|
||||
from freqtrade.rpc.api_server.api_schemas import BacktestRequest, BacktestResponse
|
||||
from freqtrade.rpc.api_server.deps import get_config
|
||||
from freqtrade.rpc.api_server.deps import get_config, is_webserver_mode
|
||||
from freqtrade.rpc.api_server.webserver import ApiServer
|
||||
from freqtrade.rpc.rpc import RPCException
|
||||
|
||||
@@ -20,8 +20,9 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.post('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest'])
|
||||
# flake8: noqa: C901
|
||||
async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: BackgroundTasks,
|
||||
config=Depends(get_config)):
|
||||
config=Depends(get_config), ws_mode=Depends(is_webserver_mode)):
|
||||
"""Start backtesting if not done so already"""
|
||||
if ApiServer._bgtask_running:
|
||||
raise RPCException('Bot Background task already running')
|
||||
@@ -32,6 +33,10 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
|
||||
for setting in settings.keys():
|
||||
if settings[setting] is not None:
|
||||
btconfig[setting] = settings[setting]
|
||||
try:
|
||||
btconfig['stake_amount'] = float(btconfig['stake_amount'])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Force dry-run for backtesting
|
||||
btconfig['dry_run'] = True
|
||||
@@ -39,7 +44,8 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
|
||||
# Start backtesting
|
||||
# Initialize backtesting object
|
||||
def run_backtest():
|
||||
from freqtrade.optimize.optimize_reports import generate_backtest_stats
|
||||
from freqtrade.optimize.optimize_reports import (generate_backtest_stats,
|
||||
store_backtest_stats)
|
||||
from freqtrade.resolvers import StrategyResolver
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
try:
|
||||
@@ -56,8 +62,7 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
|
||||
):
|
||||
from freqtrade.optimize.backtesting import Backtesting
|
||||
ApiServer._bt = Backtesting(btconfig)
|
||||
if ApiServer._bt.timeframe_detail:
|
||||
ApiServer._bt.load_bt_data_detail()
|
||||
ApiServer._bt.load_bt_data_detail()
|
||||
else:
|
||||
ApiServer._bt.config = btconfig
|
||||
ApiServer._bt.init_backtest()
|
||||
@@ -76,13 +81,25 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
|
||||
lastconfig['enable_protections'] = btconfig.get('enable_protections')
|
||||
lastconfig['dry_run_wallet'] = btconfig.get('dry_run_wallet')
|
||||
|
||||
ApiServer._bt.abort = False
|
||||
min_date, max_date = ApiServer._bt.backtest_one_strategy(
|
||||
strat, ApiServer._bt_data, ApiServer._bt_timerange)
|
||||
ApiServer._bt.results = {}
|
||||
ApiServer._bt.load_prior_backtest()
|
||||
|
||||
ApiServer._bt.abort = False
|
||||
if (ApiServer._bt.results and
|
||||
strat.get_strategy_name() in ApiServer._bt.results['strategy']):
|
||||
# When previous result hash matches - reuse that result and skip backtesting.
|
||||
logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}')
|
||||
else:
|
||||
min_date, max_date = ApiServer._bt.backtest_one_strategy(
|
||||
strat, ApiServer._bt_data, ApiServer._bt_timerange)
|
||||
|
||||
ApiServer._bt.results = generate_backtest_stats(
|
||||
ApiServer._bt_data, ApiServer._bt.all_results,
|
||||
min_date=min_date, max_date=max_date)
|
||||
|
||||
if btconfig.get('export', 'none') == 'trades':
|
||||
store_backtest_stats(btconfig['exportfilename'], ApiServer._bt.results)
|
||||
|
||||
ApiServer._bt.results = generate_backtest_stats(
|
||||
ApiServer._bt_data, ApiServer._bt.all_results,
|
||||
min_date=min_date, max_date=max_date)
|
||||
logger.info("Backtest finished.")
|
||||
|
||||
except DependencyException as e:
|
||||
@@ -104,7 +121,7 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
|
||||
|
||||
|
||||
@router.get('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest'])
|
||||
def api_get_backtest():
|
||||
def api_get_backtest(ws_mode=Depends(is_webserver_mode)):
|
||||
"""
|
||||
Get backtesting result.
|
||||
Returns Result after backtesting has been ran.
|
||||
@@ -140,7 +157,7 @@ def api_get_backtest():
|
||||
|
||||
|
||||
@router.delete('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest'])
|
||||
def api_delete_backtest():
|
||||
def api_delete_backtest(ws_mode=Depends(is_webserver_mode)):
|
||||
"""Reset backtesting"""
|
||||
if ApiServer._bgtask_running:
|
||||
return {
|
||||
@@ -166,7 +183,7 @@ def api_delete_backtest():
|
||||
|
||||
|
||||
@router.get('/backtest/abort', response_model=BacktestResponse, tags=['webserver', 'backtest'])
|
||||
def api_backtest_abort():
|
||||
def api_backtest_abort(ws_mode=Depends(is_webserver_mode)):
|
||||
if not ApiServer._bgtask_running:
|
||||
return {
|
||||
"status": "not_running",
|
||||
|
@@ -109,7 +109,7 @@ class SellReason(BaseModel):
|
||||
|
||||
class Stats(BaseModel):
|
||||
sell_reasons: Dict[str, SellReason]
|
||||
durations: Dict[str, Union[str, float]]
|
||||
durations: Dict[str, Optional[float]]
|
||||
|
||||
|
||||
class DailyRecord(BaseModel):
|
||||
@@ -149,7 +149,7 @@ class ShowConfig(BaseModel):
|
||||
api_version: float
|
||||
dry_run: bool
|
||||
stake_currency: str
|
||||
stake_amount: Union[float, str]
|
||||
stake_amount: str
|
||||
available_capital: Optional[float]
|
||||
stake_currency_decimals: int
|
||||
max_open_trades: int
|
||||
@@ -173,6 +173,8 @@ class ShowConfig(BaseModel):
|
||||
bot_name: str
|
||||
state: str
|
||||
runmode: str
|
||||
position_adjustment_enable: bool
|
||||
max_entry_position_adjustment: int
|
||||
|
||||
|
||||
class TradeSchema(BaseModel):
|
||||
@@ -277,6 +279,8 @@ class ForceBuyPayload(BaseModel):
|
||||
pair: str
|
||||
price: Optional[float]
|
||||
ordertype: Optional[OrderTypeValues]
|
||||
stakeamount: Optional[float]
|
||||
entry_tag: Optional[str]
|
||||
|
||||
|
||||
class ForceSellPayload(BaseModel):
|
||||
@@ -362,7 +366,7 @@ class BacktestRequest(BaseModel):
|
||||
timeframe_detail: Optional[str]
|
||||
timerange: Optional[str]
|
||||
max_open_trades: Optional[int]
|
||||
stake_amount: Optional[Union[float, str]]
|
||||
stake_amount: Optional[str]
|
||||
enable_protections: bool
|
||||
dry_run_wallet: Optional[float]
|
||||
|
||||
@@ -381,3 +385,8 @@ class BacktestResponse(BaseModel):
|
||||
class SysInfo(BaseModel):
|
||||
cpu_pct: List[float]
|
||||
ram_pct: float
|
||||
|
||||
|
||||
class Health(BaseModel):
|
||||
last_process: datetime
|
||||
last_process_ts: int
|
||||
|
@@ -14,13 +14,13 @@ from freqtrade.rpc import RPC
|
||||
from freqtrade.rpc.api_server.api_schemas import (AvailablePairs, Balances, BlacklistPayload,
|
||||
BlacklistResponse, Count, Daily,
|
||||
DeleteLockRequest, DeleteTrade, ForceBuyPayload,
|
||||
ForceBuyResponse, ForceSellPayload, Locks, Logs,
|
||||
OpenTradeSchema, PairHistory, PerformanceEntry,
|
||||
Ping, PlotConfig, Profit, ResultMsg, ShowConfig,
|
||||
Stats, StatusMsg, StrategyListResponse,
|
||||
StrategyResponse, SysInfo, Version,
|
||||
WhitelistResponse)
|
||||
from freqtrade.rpc.api_server.deps import get_config, get_rpc, get_rpc_optional
|
||||
ForceBuyResponse, ForceSellPayload, Health, Locks,
|
||||
Logs, OpenTradeSchema, PairHistory,
|
||||
PerformanceEntry, Ping, PlotConfig, Profit,
|
||||
ResultMsg, ShowConfig, Stats, StatusMsg,
|
||||
StrategyListResponse, StrategyResponse, SysInfo,
|
||||
Version, WhitelistResponse)
|
||||
from freqtrade.rpc.api_server.deps import get_config, get_exchange, get_rpc, get_rpc_optional
|
||||
from freqtrade.rpc.rpc import RPCException
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ logger = logging.getLogger(__name__)
|
||||
# Version increments should happen in "small" steps (1.1, 1.12, ...) unless big changes happen.
|
||||
# 1.11: forcebuy and forcesell accept ordertype
|
||||
# 1.12: add blacklist delete endpoint
|
||||
API_VERSION = 1.12
|
||||
# 1.13: forcebuy supports stake_amount
|
||||
API_VERSION = 1.13
|
||||
|
||||
# Public API, requires no auth.
|
||||
router_public = APIRouter()
|
||||
@@ -134,7 +135,10 @@ def show_config(rpc: Optional[RPC] = Depends(get_rpc_optional), config=Depends(g
|
||||
@router.post('/forcebuy', response_model=ForceBuyResponse, tags=['trading'])
|
||||
def forcebuy(payload: ForceBuyPayload, rpc: RPC = Depends(get_rpc)):
|
||||
ordertype = payload.ordertype.value if payload.ordertype else None
|
||||
trade = rpc._rpc_forcebuy(payload.pair, payload.price, ordertype)
|
||||
stake_amount = payload.stakeamount if payload.stakeamount else None
|
||||
entry_tag = payload.entry_tag if payload.entry_tag else None
|
||||
|
||||
trade = rpc._rpc_forcebuy(payload.pair, payload.price, ordertype, stake_amount, entry_tag)
|
||||
|
||||
if trade:
|
||||
return ForceBuyResponse.parse_obj(trade.to_json())
|
||||
@@ -211,18 +215,21 @@ def reload_config(rpc: RPC = Depends(get_rpc)):
|
||||
|
||||
|
||||
@router.get('/pair_candles', response_model=PairHistory, tags=['candle data'])
|
||||
def pair_candles(pair: str, timeframe: str, limit: Optional[int], rpc: RPC = Depends(get_rpc)):
|
||||
def pair_candles(
|
||||
pair: str, timeframe: str, limit: Optional[int] = None, rpc: RPC = Depends(get_rpc)):
|
||||
return rpc._rpc_analysed_dataframe(pair, timeframe, limit)
|
||||
|
||||
|
||||
@router.get('/pair_history', response_model=PairHistory, tags=['candle data'])
|
||||
def pair_history(pair: str, timeframe: str, timerange: str, strategy: str,
|
||||
config=Depends(get_config)):
|
||||
config=Depends(get_config), exchange=Depends(get_exchange)):
|
||||
# The initial call to this endpoint can be slow, as it may need to initialize
|
||||
# the exchange class.
|
||||
config = deepcopy(config)
|
||||
config.update({
|
||||
'strategy': strategy,
|
||||
})
|
||||
return RPC._rpc_analysed_history_full(config, pair, timeframe, timerange)
|
||||
return RPC._rpc_analysed_history_full(config, pair, timeframe, timerange, exchange)
|
||||
|
||||
|
||||
@router.get('/plot_config', response_model=PlotConfig, tags=['candle data'])
|
||||
@@ -285,3 +292,8 @@ def list_available_pairs(timeframe: Optional[str] = None, stake_currency: Option
|
||||
@router.get('/sysinfo', response_model=SysInfo, tags=['info'])
|
||||
def sysinfo():
|
||||
return RPC._rpc_sysinfo()
|
||||
|
||||
|
||||
@router.get('/health', response_model=Health, tags=['info'])
|
||||
def health(rpc: RPC = Depends(get_rpc)):
|
||||
return rpc._health()
|
||||
|
@@ -1,5 +1,8 @@
|
||||
from typing import Any, Dict, Iterator, Optional
|
||||
|
||||
from fastapi import Depends
|
||||
|
||||
from freqtrade.enums import RunMode
|
||||
from freqtrade.persistence import Trade
|
||||
from freqtrade.rpc.rpc import RPC, RPCException
|
||||
|
||||
@@ -28,3 +31,17 @@ def get_config() -> Dict[str, Any]:
|
||||
|
||||
def get_api_config() -> Dict[str, Any]:
|
||||
return ApiServer._config['api_server']
|
||||
|
||||
|
||||
def get_exchange(config=Depends(get_config)):
|
||||
if not ApiServer._exchange:
|
||||
from freqtrade.resolvers import ExchangeResolver
|
||||
ApiServer._exchange = ExchangeResolver.load_exchange(
|
||||
config['exchange']['name'], config)
|
||||
return ApiServer._exchange
|
||||
|
||||
|
||||
def is_webserver_mode(config=Depends(get_config)):
|
||||
if config['runmode'] != RunMode.WEBSERVER:
|
||||
raise RPCException('Bot is not in the correct state')
|
||||
return None
|
||||
|
@@ -47,7 +47,7 @@ class UvicornServer(uvicorn.Server):
|
||||
else:
|
||||
asyncio.set_event_loop(uvloop.new_event_loop())
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# When running in a thread, we'll not have an eventloop yet.
|
||||
loop = asyncio.new_event_loop()
|
||||
|
@@ -41,6 +41,8 @@ class ApiServer(RPCHandler):
|
||||
_has_rpc: bool = False
|
||||
_bgtask_running: bool = False
|
||||
_config: Dict[str, Any] = {}
|
||||
# Exchange - only available in webserver mode.
|
||||
_exchange = None
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
"""
|
||||
|
@@ -17,6 +17,15 @@ from freqtrade.constants import SUPPORTED_FIAT
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Manually map symbol to ID for some common coins
|
||||
# with duplicate coingecko entries
|
||||
coingecko_mapping = {
|
||||
'eth': 'ethereum',
|
||||
'bnb': 'binancecoin',
|
||||
'sol': 'solana',
|
||||
}
|
||||
|
||||
|
||||
class CryptoToFiatConverter:
|
||||
"""
|
||||
Main class to initiate Crypto to FIAT.
|
||||
@@ -77,6 +86,10 @@ class CryptoToFiatConverter:
|
||||
else:
|
||||
return None
|
||||
found = [x for x in self._coinlistings if x['symbol'] == crypto_symbol]
|
||||
|
||||
if crypto_symbol in coingecko_mapping.keys():
|
||||
found = [x for x in self._coinlistings if x['id'] == coingecko_mapping[crypto_symbol]]
|
||||
|
||||
if len(found) == 1:
|
||||
return found[0]['id']
|
||||
|
||||
|
@@ -10,8 +10,9 @@ from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
import arrow
|
||||
import psutil
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from dateutil.tz import tzlocal
|
||||
from numpy import NAN, inf, int64, mean
|
||||
from pandas import DataFrame
|
||||
from pandas import DataFrame, NaT
|
||||
|
||||
from freqtrade import __version__
|
||||
from freqtrade.configuration.timerange import TimeRange
|
||||
@@ -111,7 +112,7 @@ class RPC:
|
||||
'dry_run': config['dry_run'],
|
||||
'stake_currency': config['stake_currency'],
|
||||
'stake_currency_decimals': decimals_per_coin(config['stake_currency']),
|
||||
'stake_amount': config['stake_amount'],
|
||||
'stake_amount': str(config['stake_amount']),
|
||||
'available_capital': config.get('available_capital'),
|
||||
'max_open_trades': (config['max_open_trades']
|
||||
if config['max_open_trades'] != float('inf') else -1),
|
||||
@@ -136,7 +137,12 @@ class RPC:
|
||||
'ask_strategy': config.get('ask_strategy', {}),
|
||||
'bid_strategy': config.get('bid_strategy', {}),
|
||||
'state': str(botstate),
|
||||
'runmode': config['runmode'].value
|
||||
'runmode': config['runmode'].value,
|
||||
'position_adjustment_enable': config.get('position_adjustment_enable', False),
|
||||
'max_entry_position_adjustment': (
|
||||
config.get('max_entry_position_adjustment', -1)
|
||||
if config.get('max_entry_position_adjustment') != float('inf')
|
||||
else -1)
|
||||
}
|
||||
return val
|
||||
|
||||
@@ -238,19 +244,29 @@ class RPC:
|
||||
profit_str += f" ({fiat_profit:.2f})"
|
||||
fiat_profit_sum = fiat_profit if isnan(fiat_profit_sum) \
|
||||
else fiat_profit_sum + fiat_profit
|
||||
trades_list.append([
|
||||
detail_trade = [
|
||||
trade.id,
|
||||
trade.pair + ('*' if (trade.open_order_id is not None
|
||||
and trade.close_rate_requested is None) else '')
|
||||
+ ('**' if (trade.close_rate_requested is not None) else ''),
|
||||
+ ('**' if (trade.close_rate_requested is not None) else ''),
|
||||
shorten_date(arrow.get(trade.open_date).humanize(only_distance=True)),
|
||||
profit_str
|
||||
])
|
||||
]
|
||||
if self._config.get('position_adjustment_enable', False):
|
||||
max_buy_str = ''
|
||||
if self._config.get('max_entry_position_adjustment', -1) > 0:
|
||||
max_buy_str = f"/{self._config['max_entry_position_adjustment'] + 1}"
|
||||
filled_buys = trade.nr_of_successful_buys
|
||||
detail_trade.append(f"{filled_buys}{max_buy_str}")
|
||||
trades_list.append(detail_trade)
|
||||
profitcol = "Profit"
|
||||
if self._fiat_converter:
|
||||
profitcol += " (" + fiat_display_currency + ")"
|
||||
|
||||
columns = ['ID', 'Pair', 'Since', profitcol]
|
||||
if self._config.get('position_adjustment_enable', False):
|
||||
columns = ['ID', 'Pair', 'Since', profitcol, '# Entries']
|
||||
else:
|
||||
columns = ['ID', 'Pair', 'Since', profitcol]
|
||||
return trades_list, columns, fiat_profit_sum
|
||||
|
||||
def _rpc_daily_profit(
|
||||
@@ -424,9 +440,9 @@ class RPC:
|
||||
trade_dur = (trade.close_date - trade.open_date).total_seconds()
|
||||
dur[trade_win_loss(trade)].append(trade_dur)
|
||||
|
||||
wins_dur = sum(dur['wins']) / len(dur['wins']) if len(dur['wins']) > 0 else 'N/A'
|
||||
draws_dur = sum(dur['draws']) / len(dur['draws']) if len(dur['draws']) > 0 else 'N/A'
|
||||
losses_dur = sum(dur['losses']) / len(dur['losses']) if len(dur['losses']) > 0 else 'N/A'
|
||||
wins_dur = sum(dur['wins']) / len(dur['wins']) if len(dur['wins']) > 0 else None
|
||||
draws_dur = sum(dur['draws']) / len(dur['draws']) if len(dur['draws']) > 0 else None
|
||||
losses_dur = sum(dur['losses']) / len(dur['losses']) if len(dur['losses']) > 0 else None
|
||||
|
||||
durations = {'wins': wins_dur, 'draws': draws_dur, 'losses': losses_dur}
|
||||
return {'sell_reasons': sell_reasons, 'durations': durations}
|
||||
@@ -583,15 +599,11 @@ class RPC:
|
||||
'est_stake': est_stake or 0,
|
||||
'stake': stake_currency,
|
||||
})
|
||||
if total == 0.0:
|
||||
if self._freqtrade.config['dry_run']:
|
||||
raise RPCException('Running in Dry Run, balances are not available.')
|
||||
else:
|
||||
raise RPCException('All balances are zero.')
|
||||
|
||||
value = self._fiat_converter.convert_amount(
|
||||
total, stake_currency, fiat_display_currency) if self._fiat_converter else 0
|
||||
|
||||
trade_count = len(Trade.get_trades_proxy())
|
||||
starting_capital_ratio = 0.0
|
||||
starting_capital_ratio = (total / starting_capital) - 1 if starting_capital else 0.0
|
||||
starting_cap_fiat_ratio = (value / starting_cap_fiat) - 1 if starting_cap_fiat else 0.0
|
||||
@@ -608,6 +620,7 @@ class RPC:
|
||||
'starting_capital_fiat': starting_cap_fiat,
|
||||
'starting_capital_fiat_ratio': starting_cap_fiat_ratio,
|
||||
'starting_capital_fiat_pct': round(starting_cap_fiat_ratio * 100, 2),
|
||||
'trade_count': trade_count,
|
||||
'note': 'Simulated balances' if self._freqtrade.config['dry_run'] else ''
|
||||
}
|
||||
|
||||
@@ -698,8 +711,9 @@ class RPC:
|
||||
self._freqtrade.wallets.update()
|
||||
return {'result': f'Created sell order for trade {trade_id}.'}
|
||||
|
||||
def _rpc_forcebuy(self, pair: str, price: Optional[float],
|
||||
order_type: Optional[str] = None) -> Optional[Trade]:
|
||||
def _rpc_forcebuy(self, pair: str, price: Optional[float], order_type: Optional[str] = None,
|
||||
stake_amount: Optional[float] = None,
|
||||
buy_tag: Optional[str] = None) -> Optional[Trade]:
|
||||
"""
|
||||
Handler for forcebuy <asset> <price>
|
||||
Buys a pair trade at the given or current price
|
||||
@@ -721,16 +735,19 @@ class RPC:
|
||||
# check if pair already has an open pair
|
||||
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
|
||||
if trade:
|
||||
raise RPCException(f'position for {pair} already open - id: {trade.id}')
|
||||
if not self._freqtrade.strategy.position_adjustment_enable:
|
||||
raise RPCException(f'position for {pair} already open - id: {trade.id}')
|
||||
|
||||
# gen stake amount
|
||||
stakeamount = self._freqtrade.wallets.get_trade_stake_amount(pair)
|
||||
if not stake_amount:
|
||||
# gen stake amount
|
||||
stake_amount = self._freqtrade.wallets.get_trade_stake_amount(pair)
|
||||
|
||||
# execute buy
|
||||
if not order_type:
|
||||
order_type = self._freqtrade.strategy.order_types.get(
|
||||
'forcebuy', self._freqtrade.strategy.order_types['buy'])
|
||||
if self._freqtrade.execute_entry(pair, stakeamount, price, ordertype=order_type):
|
||||
if self._freqtrade.execute_entry(pair, stake_amount, price,
|
||||
ordertype=order_type, trade=trade, buy_tag=buy_tag):
|
||||
Trade.commit()
|
||||
trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first()
|
||||
return trade
|
||||
@@ -942,8 +959,16 @@ class RPC:
|
||||
sell_mask = (dataframe['sell'] == 1)
|
||||
sell_signals = int(sell_mask.sum())
|
||||
dataframe.loc[sell_mask, '_sell_signal_close'] = dataframe.loc[sell_mask, 'close']
|
||||
dataframe = dataframe.replace([inf, -inf], NAN)
|
||||
dataframe = dataframe.replace({NAN: None})
|
||||
|
||||
# band-aid until this is fixed:
|
||||
# https://github.com/pandas-dev/pandas/issues/45836
|
||||
datetime_types = ['datetime', 'datetime64', 'datetime64[ns, UTC]']
|
||||
date_columns = dataframe.select_dtypes(include=datetime_types)
|
||||
for date_column in date_columns:
|
||||
# replace NaT with `None`
|
||||
dataframe[date_column] = dataframe[date_column].astype(object).replace({NaT: None})
|
||||
|
||||
dataframe = dataframe.replace({inf: None, -inf: None, NAN: None})
|
||||
|
||||
res = {
|
||||
'pair': pair,
|
||||
@@ -984,7 +1009,7 @@ class RPC:
|
||||
|
||||
@staticmethod
|
||||
def _rpc_analysed_history_full(config, pair: str, timeframe: str,
|
||||
timerange: str) -> Dict[str, Any]:
|
||||
timerange: str, exchange) -> Dict[str, Any]:
|
||||
timerange_parsed = TimeRange.parse_timerange(timerange)
|
||||
|
||||
_data = load_data(
|
||||
@@ -999,7 +1024,7 @@ class RPC:
|
||||
from freqtrade.data.dataprovider import DataProvider
|
||||
from freqtrade.resolvers.strategy_resolver import StrategyResolver
|
||||
strategy = StrategyResolver.load_strategy(config)
|
||||
strategy.dp = DataProvider(config, exchange=None, pairlists=None)
|
||||
strategy.dp = DataProvider(config, exchange=exchange, pairlists=None)
|
||||
|
||||
df_analyzed = strategy.analyze_ticker(_data[pair], {'pair': pair})
|
||||
|
||||
@@ -1018,3 +1043,11 @@ class RPC:
|
||||
"cpu_pct": psutil.cpu_percent(interval=1, percpu=True),
|
||||
"ram_pct": psutil.virtual_memory().percent
|
||||
}
|
||||
|
||||
def _health(self) -> Dict[str, Union[str, int]]:
|
||||
last_p = self._freqtrade.last_process
|
||||
return {
|
||||
'last_process': str(last_p),
|
||||
'last_process_loc': last_p.astimezone(tzlocal()).strftime(DATETIME_PRINT_FORMAT),
|
||||
'last_process_ts': int(last_p.timestamp()),
|
||||
}
|
||||
|
@@ -85,12 +85,14 @@ class RPCManager:
|
||||
timeframe = config['timeframe']
|
||||
exchange_name = config['exchange']['name']
|
||||
strategy_name = config.get('strategy', '')
|
||||
pos_adjust_enabled = 'On' if config['position_adjustment_enable'] else 'Off'
|
||||
self.send_msg({
|
||||
'type': RPCMessageType.STARTUP,
|
||||
'status': f'*Exchange:* `{exchange_name}`\n'
|
||||
f'*Stake per trade:* `{stake_amount} {stake_currency}`\n'
|
||||
f'*Minimum ROI:* `{minimal_roi}`\n'
|
||||
f'*{"Trailing " if trailing_stop else ""}Stoploss:* `{stoploss}`\n'
|
||||
f'*Position adjustment:* `{pos_adjust_enabled}`\n'
|
||||
f'*Timeframe:* `{timeframe}`\n'
|
||||
f'*Strategy:* `{strategy_name}`'
|
||||
})
|
||||
|
@@ -113,7 +113,7 @@ class Telegram(RPCHandler):
|
||||
r'/stopbuy$', r'/reload_config$', r'/show_config$',
|
||||
r'/logs$', r'/whitelist$', r'/blacklist$', r'/bl_delete$',
|
||||
r'/weekly$', r'/weekly \d+$', r'/monthly$', r'/monthly \d+$',
|
||||
r'/forcebuy$', r'/edge$', r'/help$', r'/version$']
|
||||
r'/forcebuy$', r'/edge$', r'/health$', r'/help$', r'/version$']
|
||||
# Create keys for generation
|
||||
valid_keys_print = [k.replace('$', '') for k in valid_keys]
|
||||
|
||||
@@ -173,6 +173,7 @@ class Telegram(RPCHandler):
|
||||
CommandHandler(['blacklist_delete', 'bl_delete'], self._blacklist_delete),
|
||||
CommandHandler('logs', self._logs),
|
||||
CommandHandler('edge', self._edge),
|
||||
CommandHandler('health', self._health),
|
||||
CommandHandler('help', self._help),
|
||||
CommandHandler('version', self._version),
|
||||
]
|
||||
@@ -199,8 +200,8 @@ class Telegram(RPCHandler):
|
||||
|
||||
self._updater.start_polling(
|
||||
bootstrap_retries=-1,
|
||||
timeout=30,
|
||||
read_latency=60,
|
||||
timeout=20,
|
||||
read_latency=60, # Assumed transmission latency
|
||||
drop_pending_updates=True,
|
||||
)
|
||||
logger.info(
|
||||
@@ -213,6 +214,7 @@ class Telegram(RPCHandler):
|
||||
Stops all running telegram threads.
|
||||
:return: None
|
||||
"""
|
||||
# This can take up to `timeout` from the call to `start_polling`.
|
||||
self._updater.stop()
|
||||
|
||||
def _format_buy_msg(self, msg: Dict[str, Any]) -> str:
|
||||
@@ -368,6 +370,52 @@ class Telegram(RPCHandler):
|
||||
else:
|
||||
return "\N{CROSS MARK}"
|
||||
|
||||
def _prepare_entry_details(self, filled_orders: List, base_currency: str, is_open: bool):
|
||||
"""
|
||||
Prepare details of trade with entry adjustment enabled
|
||||
"""
|
||||
lines: List[str] = []
|
||||
if len(filled_orders) > 0:
|
||||
first_avg = filled_orders[0]["safe_price"]
|
||||
|
||||
for x, order in enumerate(filled_orders):
|
||||
cur_entry_datetime = arrow.get(order["order_filled_date"])
|
||||
cur_entry_amount = order["amount"]
|
||||
cur_entry_average = order["safe_price"]
|
||||
lines.append(" ")
|
||||
if x == 0:
|
||||
lines.append(f"*Entry #{x+1}:*")
|
||||
lines.append(
|
||||
f"*Entry Amount:* {cur_entry_amount} ({order['cost']:.8f} {base_currency})")
|
||||
lines.append(f"*Average Entry Price:* {cur_entry_average}")
|
||||
else:
|
||||
sumA = 0
|
||||
sumB = 0
|
||||
for y in range(x):
|
||||
sumA += (filled_orders[y]["amount"] * filled_orders[y]["safe_price"])
|
||||
sumB += filled_orders[y]["amount"]
|
||||
prev_avg_price = sumA / sumB
|
||||
price_to_1st_entry = ((cur_entry_average - first_avg) / first_avg)
|
||||
minus_on_entry = 0
|
||||
if prev_avg_price:
|
||||
minus_on_entry = (cur_entry_average - prev_avg_price) / prev_avg_price
|
||||
|
||||
dur_entry = cur_entry_datetime - arrow.get(filled_orders[x-1]["order_filled_date"])
|
||||
days = dur_entry.days
|
||||
hours, remainder = divmod(dur_entry.seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
lines.append(f"*Entry #{x+1}:* at {minus_on_entry:.2%} avg profit")
|
||||
if is_open:
|
||||
lines.append("({})".format(cur_entry_datetime
|
||||
.humanize(granularity=["day", "hour", "minute"])))
|
||||
lines.append(
|
||||
f"*Entry Amount:* {cur_entry_amount} ({order['cost']:.8f} {base_currency})")
|
||||
lines.append(f"*Average Entry Price:* {cur_entry_average} "
|
||||
f"({price_to_1st_entry:.2%} from 1st entry rate)")
|
||||
lines.append(f"*Order filled at:* {order['order_filled_date']}")
|
||||
lines.append(f"({days}d {hours}h {minutes}m {seconds}s from previous entry)")
|
||||
return lines
|
||||
|
||||
@authorized_only
|
||||
def _status(self, update: Update, context: CallbackContext) -> None:
|
||||
"""
|
||||
@@ -391,37 +439,57 @@ class Telegram(RPCHandler):
|
||||
trade_ids = [int(i) for i in context.args if i.isnumeric()]
|
||||
|
||||
results = self._rpc._rpc_trade_status(trade_ids=trade_ids)
|
||||
|
||||
position_adjust = self._config.get('position_adjustment_enable', False)
|
||||
max_entries = self._config.get('max_entry_position_adjustment', -1)
|
||||
messages = []
|
||||
for r in results:
|
||||
r['open_date_hum'] = arrow.get(r['open_date']).humanize()
|
||||
r['num_entries'] = len(r['filled_entry_orders'])
|
||||
r['sell_reason'] = r.get('sell_reason', "")
|
||||
lines = [
|
||||
"*Trade ID:* `{trade_id}` `(since {open_date_hum})`",
|
||||
"*Trade ID:* `{trade_id}`" +
|
||||
("` (since {open_date_hum})`" if r['is_open'] else ""),
|
||||
"*Current Pair:* {pair}",
|
||||
"*Amount:* `{amount} ({stake_amount} {base_currency})`",
|
||||
"*Buy Tag:* `{buy_tag}`" if r['buy_tag'] else "",
|
||||
"*Entry Tag:* `{buy_tag}`" if r['buy_tag'] else "",
|
||||
"*Exit Reason:* `{sell_reason}`" if r['sell_reason'] else "",
|
||||
]
|
||||
|
||||
if position_adjust:
|
||||
max_buy_str = (f"/{max_entries + 1}" if (max_entries > 0) else "")
|
||||
lines.append("*Number of Entries:* `{num_entries}`" + max_buy_str)
|
||||
|
||||
lines.extend([
|
||||
"*Open Rate:* `{open_rate:.8f}`",
|
||||
"*Close Rate:* `{close_rate}`" if r['close_rate'] else "",
|
||||
"*Current Rate:* `{current_rate:.8f}`",
|
||||
"*Close Rate:* `{close_rate:.8f}`" if r['close_rate'] else "",
|
||||
"*Open Date:* `{open_date}`",
|
||||
"*Close Date:* `{close_date}`" if r['close_date'] else "",
|
||||
"*Current Rate:* `{current_rate:.8f}`" if r['is_open'] else "",
|
||||
("*Current Profit:* " if r['is_open'] else "*Close Profit: *")
|
||||
+ "`{profit_ratio:.2%}`",
|
||||
]
|
||||
if (r['stop_loss_abs'] != r['initial_stop_loss_abs']
|
||||
and r['initial_stop_loss_ratio'] is not None):
|
||||
# Adding initial stoploss only if it is different from stoploss
|
||||
lines.append("*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` "
|
||||
"`({initial_stop_loss_ratio:.2%})`")
|
||||
])
|
||||
|
||||
# Adding stoploss and stoploss percentage only if it is not None
|
||||
lines.append("*Stoploss:* `{stop_loss_abs:.8f}` " +
|
||||
("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else ""))
|
||||
lines.append("*Stoploss distance:* `{stoploss_current_dist:.8f}` "
|
||||
"`({stoploss_current_dist_ratio:.2%})`")
|
||||
if r['open_order']:
|
||||
if r['sell_order_status']:
|
||||
lines.append("*Open Order:* `{open_order}` - `{sell_order_status}`")
|
||||
else:
|
||||
lines.append("*Open Order:* `{open_order}`")
|
||||
if r['is_open']:
|
||||
if (r['stop_loss_abs'] != r['initial_stop_loss_abs']
|
||||
and r['initial_stop_loss_ratio'] is not None):
|
||||
# Adding initial stoploss only if it is different from stoploss
|
||||
lines.append("*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` "
|
||||
"`({initial_stop_loss_ratio:.2%})`")
|
||||
|
||||
# Adding stoploss and stoploss percentage only if it is not None
|
||||
lines.append("*Stoploss:* `{stop_loss_abs:.8f}` " +
|
||||
("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else ""))
|
||||
lines.append("*Stoploss distance:* `{stoploss_current_dist:.8f}` "
|
||||
"`({stoploss_current_dist_ratio:.2%})`")
|
||||
if r['open_order']:
|
||||
if r['sell_order_status']:
|
||||
lines.append("*Open Order:* `{open_order}` - `{sell_order_status}`")
|
||||
else:
|
||||
lines.append("*Open Order:* `{open_order}`")
|
||||
|
||||
lines_detail = self._prepare_entry_details(
|
||||
r['filled_entry_orders'], r['base_currency'], r['is_open'])
|
||||
lines.extend((lines_detail if (len(r['filled_entry_orders']) > 1) else ""))
|
||||
|
||||
# Filter empty lines using list-comprehension
|
||||
messages.append("\n".join([line for line in lines if line]).format(**r))
|
||||
@@ -702,9 +770,9 @@ class Telegram(RPCHandler):
|
||||
duration_msg = tabulate(
|
||||
[
|
||||
['Wins', str(timedelta(seconds=durations['wins']))
|
||||
if durations['wins'] != 'N/A' else 'N/A'],
|
||||
if durations['wins'] is not None else 'N/A'],
|
||||
['Losses', str(timedelta(seconds=durations['losses']))
|
||||
if durations['losses'] != 'N/A' else 'N/A']
|
||||
if durations['losses'] is not None else 'N/A']
|
||||
],
|
||||
headers=['', 'Avg. Duration']
|
||||
)
|
||||
@@ -726,12 +794,13 @@ class Telegram(RPCHandler):
|
||||
output = ''
|
||||
if self._config['dry_run']:
|
||||
output += "*Warning:* Simulated balances in Dry Mode.\n"
|
||||
|
||||
output += ("Starting capital: "
|
||||
f"`{result['starting_capital']}` {self._config['stake_currency']}"
|
||||
)
|
||||
output += (f" `{result['starting_capital_fiat']}` "
|
||||
f"{self._config['fiat_display_currency']}.\n"
|
||||
starting_cap = round_coin_value(
|
||||
result['starting_capital'], self._config['stake_currency'])
|
||||
output += f"Starting capital: `{starting_cap}`"
|
||||
starting_cap_fiat = round_coin_value(
|
||||
result['starting_capital_fiat'], self._config['fiat_display_currency']
|
||||
) if result['starting_capital_fiat'] > 0 else ''
|
||||
output += (f" `, {starting_cap_fiat}`.\n"
|
||||
) if result['starting_capital_fiat'] > 0 else '.\n'
|
||||
|
||||
total_dust_balance = 0
|
||||
@@ -764,14 +833,17 @@ class Telegram(RPCHandler):
|
||||
f"(< {balance_dust_level} {result['stake']}):*\n"
|
||||
f"\t`Est. {result['stake']}: "
|
||||
f"{round_coin_value(total_dust_balance, result['stake'], False)}`\n")
|
||||
tc = result['trade_count'] > 0
|
||||
stake_improve = f" `({result['starting_capital_ratio']:.2%})`" if tc else ''
|
||||
fiat_val = f" `({result['starting_capital_fiat_ratio']:.2%})`" if tc else ''
|
||||
|
||||
output += ("\n*Estimated Value*:\n"
|
||||
f"\t`{result['stake']}: "
|
||||
f"{round_coin_value(result['total'], result['stake'], False)}`"
|
||||
f" `({result['starting_capital_ratio']:.2%})`\n"
|
||||
f"{stake_improve}\n"
|
||||
f"\t`{result['symbol']}: "
|
||||
f"{round_coin_value(result['value'], result['symbol'], False)}`"
|
||||
f" `({result['starting_capital_fiat_ratio']:.2%})`\n")
|
||||
f"{fiat_val}\n")
|
||||
self._send_msg(output, reload_able=True, callback_path="update_balance",
|
||||
query=update.callback_query)
|
||||
except RPCException as e:
|
||||
@@ -847,10 +919,11 @@ class Telegram(RPCHandler):
|
||||
self._send_msg(str(e))
|
||||
|
||||
def _forcebuy_action(self, pair, price=None):
|
||||
try:
|
||||
self._rpc._rpc_forcebuy(pair, price)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
if pair != 'cancel':
|
||||
try:
|
||||
self._rpc._rpc_forcebuy(pair, price)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
def _forcebuy_inline(self, update: Update, _: CallbackContext) -> None:
|
||||
if update.callback_query:
|
||||
@@ -880,10 +953,13 @@ class Telegram(RPCHandler):
|
||||
self._forcebuy_action(pair, price)
|
||||
else:
|
||||
whitelist = self._rpc._rpc_whitelist()['whitelist']
|
||||
pairs = [InlineKeyboardButton(text=pair, callback_data=pair) for pair in whitelist]
|
||||
pair_buttons = [
|
||||
InlineKeyboardButton(text=pair, callback_data=pair) for pair in sorted(whitelist)]
|
||||
buttons_aligned = self._layout_inline_keyboard(pair_buttons)
|
||||
|
||||
buttons_aligned.append([InlineKeyboardButton(text='Cancel', callback_data='cancel')])
|
||||
self._send_msg(msg="Which pair?",
|
||||
keyboard=self._layout_inline_keyboard(pairs))
|
||||
keyboard=buttons_aligned)
|
||||
|
||||
@authorized_only
|
||||
def _trades(self, update: Update, context: CallbackContext) -> None:
|
||||
@@ -1278,6 +1354,7 @@ class Telegram(RPCHandler):
|
||||
"*/logs [limit]:* `Show latest logs - defaults to 10` \n"
|
||||
"*/count:* `Show number of active trades compared to allowed number of trades`\n"
|
||||
"*/edge:* `Shows validated pairs by Edge if it is enabled` \n"
|
||||
"*/health* `Show latest process timestamp - defaults to 1970-01-01 00:00:00` \n"
|
||||
|
||||
"_Statistics_\n"
|
||||
"------------\n"
|
||||
@@ -1305,6 +1382,19 @@ class Telegram(RPCHandler):
|
||||
|
||||
self._send_msg(message, parse_mode=ParseMode.MARKDOWN)
|
||||
|
||||
@authorized_only
|
||||
def _health(self, update: Update, context: CallbackContext) -> None:
|
||||
"""
|
||||
Handler for /health
|
||||
Shows the last process timestamp
|
||||
"""
|
||||
try:
|
||||
health = self._rpc._health()
|
||||
message = f"Last process: `{health['last_process_loc']}`"
|
||||
self._send_msg(message)
|
||||
except RPCException as e:
|
||||
self._send_msg(str(e))
|
||||
|
||||
@authorized_only
|
||||
def _version(self, update: Update, context: CallbackContext) -> None:
|
||||
"""
|
||||
@@ -1343,6 +1433,14 @@ class Telegram(RPCHandler):
|
||||
else:
|
||||
sl_info = f"*Stoploss:* `{val['stoploss']}`\n"
|
||||
|
||||
if val['position_adjustment_enable']:
|
||||
pa_info = (
|
||||
f"*Position adjustment:* On\n"
|
||||
f"*Max enter position adjustment:* `{val['max_entry_position_adjustment']}`\n"
|
||||
)
|
||||
else:
|
||||
pa_info = "*Position adjustment:* Off\n"
|
||||
|
||||
self._send_msg(
|
||||
f"*Mode:* `{'Dry-run' if val['dry_run'] else 'Live'}`\n"
|
||||
f"*Exchange:* `{val['exchange']}`\n"
|
||||
@@ -1352,6 +1450,7 @@ class Telegram(RPCHandler):
|
||||
f"*Ask strategy:* ```\n{json.dumps(val['ask_strategy'])}```\n"
|
||||
f"*Bid strategy:* ```\n{json.dumps(val['bid_strategy'])}```\n"
|
||||
f"{sl_info}"
|
||||
f"{pa_info}"
|
||||
f"*Timeframe:* `{val['timeframe']}`\n"
|
||||
f"*Strategy:* `{val['strategy']}`\n"
|
||||
f"*Current state:* `{val['state']}`"
|
||||
|
@@ -18,6 +18,7 @@ from freqtrade.exceptions import OperationalException, StrategyError
|
||||
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
|
||||
from freqtrade.exchange.exchange import timeframe_to_next_date
|
||||
from freqtrade.persistence import PairLocks, Trade
|
||||
from freqtrade.persistence.models import LocalTrade, Order
|
||||
from freqtrade.strategy.hyper import HyperStrategyMixin
|
||||
from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators,
|
||||
_create_and_merge_informative_pair,
|
||||
@@ -106,6 +107,10 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
sell_profit_offset: float
|
||||
ignore_roi_if_buy_signal: bool
|
||||
|
||||
# Position adjustment is disabled by default
|
||||
position_adjustment_enable: bool = False
|
||||
max_entry_position_adjustment: int = -1
|
||||
|
||||
# Number of seconds after which the candle will no longer result in a buy on expired candles
|
||||
ignore_buying_expired_candle_after: int = 0
|
||||
|
||||
@@ -185,7 +190,17 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
"""
|
||||
return dataframe
|
||||
|
||||
def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
|
||||
def bot_loop_start(self, **kwargs) -> None:
|
||||
"""
|
||||
Called at the start of the bot iteration (one loop).
|
||||
Might be used to perform pair-independent tasks
|
||||
(e.g. gather some remote resource for comparison)
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
"""
|
||||
pass
|
||||
|
||||
def check_buy_timeout(self, pair: str, trade: Trade, order: dict,
|
||||
current_time: datetime, **kwargs) -> bool:
|
||||
"""
|
||||
Check buy timeout function callback.
|
||||
This method can be used to override the buy-timeout.
|
||||
@@ -198,12 +213,14 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param pair: Pair the trade is for
|
||||
:param trade: trade object.
|
||||
:param order: Order dictionary as returned from CCXT.
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return bool: When True is returned, then the buy-order is cancelled.
|
||||
"""
|
||||
return False
|
||||
|
||||
def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
|
||||
def check_sell_timeout(self, pair: str, trade: Trade, order: dict,
|
||||
current_time: datetime, **kwargs) -> bool:
|
||||
"""
|
||||
Check sell timeout function callback.
|
||||
This method can be used to override the sell-timeout.
|
||||
@@ -216,22 +233,15 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param pair: Pair the trade is for
|
||||
:param trade: trade object.
|
||||
:param order: Order dictionary as returned from CCXT.
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return bool: When True is returned, then the sell-order is cancelled.
|
||||
"""
|
||||
return False
|
||||
|
||||
def bot_loop_start(self, **kwargs) -> None:
|
||||
"""
|
||||
Called at the start of the bot iteration (one loop).
|
||||
Might be used to perform pair-independent tasks
|
||||
(e.g. gather some remote resource for comparison)
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
"""
|
||||
pass
|
||||
|
||||
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
|
||||
time_in_force: str, current_time: datetime, **kwargs) -> bool:
|
||||
time_in_force: str, current_time: datetime, entry_tag: Optional[str],
|
||||
**kwargs) -> bool:
|
||||
"""
|
||||
Called right before placing a buy order.
|
||||
Timing for this function is critical, so avoid doing heavy computations or
|
||||
@@ -247,6 +257,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param rate: Rate that's going to be used when using limit orders
|
||||
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return bool: When True is returned, then the buy-order is placed on the exchange.
|
||||
False aborts the process
|
||||
@@ -304,7 +315,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
return self.stoploss
|
||||
|
||||
def custom_entry_price(self, pair: str, current_time: datetime, proposed_rate: float,
|
||||
**kwargs) -> float:
|
||||
entry_tag: Optional[str], **kwargs) -> float:
|
||||
"""
|
||||
Custom entry price logic, returning the new entry price.
|
||||
|
||||
@@ -315,6 +326,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param pair: Pair that's currently analyzed
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param proposed_rate: Rate, calculated based on pricing settings in ask_strategy.
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return float: New entry price value if provided
|
||||
"""
|
||||
@@ -366,7 +378,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
|
||||
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
|
||||
proposed_stake: float, min_stake: float, max_stake: float,
|
||||
**kwargs) -> float:
|
||||
entry_tag: Optional[str], **kwargs) -> float:
|
||||
"""
|
||||
Customize stake size for each new trade. This method is not called when edge module is
|
||||
enabled.
|
||||
@@ -377,10 +389,34 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
:param proposed_stake: A stake amount proposed by the bot.
|
||||
:param min_stake: Minimal stake size allowed by exchange.
|
||||
:param max_stake: Balance available for trading.
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:return: A stake size, which is between min_stake and max_stake.
|
||||
"""
|
||||
return proposed_stake
|
||||
|
||||
def adjust_trade_position(self, trade: Trade, current_time: datetime,
|
||||
current_rate: float, current_profit: float, min_stake: float,
|
||||
max_stake: float, **kwargs) -> Optional[float]:
|
||||
"""
|
||||
Custom trade adjustment logic, returning the stake amount that a trade should be increased.
|
||||
This means extra buy orders with additional fees.
|
||||
Only called when `position_adjustment_enable` is set to True.
|
||||
|
||||
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
|
||||
|
||||
When not implemented by a strategy, returns None
|
||||
|
||||
:param trade: trade object.
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param current_rate: Current buy rate.
|
||||
:param current_profit: Current profit (as ratio), calculated based on current_rate.
|
||||
:param min_stake: Minimal stake size allowed by exchange.
|
||||
:param max_stake: Balance available for trading.
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return float: Stake amount to adjust your trade
|
||||
"""
|
||||
return None
|
||||
|
||||
def informative_pairs(self) -> ListPairsWithTimeframes:
|
||||
"""
|
||||
Define additional, informative pair/interval combinations to be cached from the exchange.
|
||||
@@ -629,6 +665,9 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
|
||||
buy_tag = latest.get(SignalTagType.BUY_TAG.value, None)
|
||||
exit_tag = latest.get(SignalTagType.EXIT_TAG.value, None)
|
||||
# Tags can be None, which does not resolve to False.
|
||||
buy_tag = buy_tag if isinstance(buy_tag, str) else None
|
||||
exit_tag = exit_tag if isinstance(exit_tag, str) else None
|
||||
|
||||
logger.debug('trigger: %s (pair=%s) buy=%s sell=%s',
|
||||
latest['date'], pair, str(buy), str(sell))
|
||||
@@ -648,7 +687,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
else:
|
||||
return False
|
||||
|
||||
def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool,
|
||||
def should_sell(self, trade: Trade, rate: float, current_time: datetime, buy: bool,
|
||||
sell: bool, low: float = None, high: float = None,
|
||||
force_stoploss: float = 0) -> SellCheckTuple:
|
||||
"""
|
||||
@@ -665,7 +704,8 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
trade.adjust_min_max_rates(high or current_rate, low or current_rate)
|
||||
|
||||
stoplossflag = self.stop_loss_reached(current_rate=current_rate, trade=trade,
|
||||
current_time=date, current_profit=current_profit,
|
||||
current_time=current_time,
|
||||
current_profit=current_profit,
|
||||
force_stoploss=force_stoploss, low=low, high=high)
|
||||
|
||||
# Set current rate to high for backtesting sell
|
||||
@@ -675,7 +715,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
# if buy signal and ignore_roi is set, we don't need to evaluate min_roi.
|
||||
roi_reached = (not (buy and self.ignore_roi_if_buy_signal)
|
||||
and self.min_roi_reached(trade=trade, current_profit=current_profit,
|
||||
current_time=date))
|
||||
current_time=current_time))
|
||||
|
||||
sell_signal = SellType.NONE
|
||||
custom_reason = ''
|
||||
@@ -691,8 +731,8 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
sell_signal = SellType.SELL_SIGNAL
|
||||
else:
|
||||
custom_reason = strategy_safe_wrapper(self.custom_sell, default_retval=False)(
|
||||
pair=trade.pair, trade=trade, current_time=date, current_rate=current_rate,
|
||||
current_profit=current_profit)
|
||||
pair=trade.pair, trade=trade, current_time=current_time,
|
||||
current_rate=current_rate, current_profit=current_profit)
|
||||
if custom_reason:
|
||||
sell_signal = SellType.CUSTOM_SELL
|
||||
if isinstance(custom_reason, str):
|
||||
@@ -703,23 +743,21 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
custom_reason = custom_reason[:CUSTOM_SELL_MAX_LENGTH]
|
||||
else:
|
||||
custom_reason = None
|
||||
# TODO: return here if sell-signal should be favored over ROI
|
||||
if sell_signal in (SellType.CUSTOM_SELL, SellType.SELL_SIGNAL):
|
||||
logger.debug(f"{trade.pair} - Sell signal received. "
|
||||
f"sell_type=SellType.{sell_signal.name}" +
|
||||
(f", custom_reason={custom_reason}" if custom_reason else ""))
|
||||
return SellCheckTuple(sell_type=sell_signal, sell_reason=custom_reason)
|
||||
|
||||
# Start evaluations
|
||||
# Sequence:
|
||||
# ROI (if not stoploss)
|
||||
# Sell-signal
|
||||
# ROI (if not stoploss)
|
||||
# Stoploss
|
||||
if roi_reached and stoplossflag.sell_type != SellType.STOP_LOSS:
|
||||
logger.debug(f"{trade.pair} - Required profit reached. sell_type=SellType.ROI")
|
||||
return SellCheckTuple(sell_type=SellType.ROI)
|
||||
|
||||
if sell_signal != SellType.NONE:
|
||||
logger.debug(f"{trade.pair} - Sell signal received. "
|
||||
f"sell_type=SellType.{sell_signal.name}" +
|
||||
(f", custom_reason={custom_reason}" if custom_reason else ""))
|
||||
return SellCheckTuple(sell_type=sell_signal, sell_reason=custom_reason)
|
||||
|
||||
if stoplossflag.sell_flag:
|
||||
|
||||
logger.debug(f"{trade.pair} - Stoploss hit. sell_type={stoplossflag.sell_type}")
|
||||
@@ -826,6 +864,28 @@ class IStrategy(ABC, HyperStrategyMixin):
|
||||
else:
|
||||
return current_profit > roi
|
||||
|
||||
def ft_check_timed_out(self, side: str, trade: LocalTrade, order: Order,
|
||||
current_time: datetime) -> bool:
|
||||
"""
|
||||
FT Internal method.
|
||||
Check if timeout is active, and if the order is still open and timed out
|
||||
"""
|
||||
timeout = self.config.get('unfilledtimeout', {}).get(side)
|
||||
if timeout is not None:
|
||||
timeout_unit = self.config.get('unfilledtimeout', {}).get('unit', 'minutes')
|
||||
timeout_kwargs = {timeout_unit: -timeout}
|
||||
timeout_threshold = current_time + timedelta(**timeout_kwargs)
|
||||
timedout = (order.status == 'open' and order.side == side
|
||||
and order.order_date_utc < timeout_threshold)
|
||||
if timedout:
|
||||
return True
|
||||
time_method = self.check_sell_timeout if order.side == 'sell' else self.check_buy_timeout
|
||||
|
||||
return strategy_safe_wrapper(time_method,
|
||||
default_retval=False)(
|
||||
pair=trade.pair, trade=trade, order=order,
|
||||
current_time=current_time)
|
||||
|
||||
def advise_all_indicators(self, data: Dict[str, DataFrame]) -> Dict[str, DataFrame]:
|
||||
"""
|
||||
Populates indicators for given candle (OHLCV) data (for multiple pairs)
|
||||
|
@@ -15,7 +15,8 @@
|
||||
"cancel_open_orders_on_exit": false,
|
||||
"unfilledtimeout": {
|
||||
"buy": 10,
|
||||
"sell": 30,
|
||||
"sell": 10,
|
||||
"exit_timeout_count": 0,
|
||||
"unit": "minutes"
|
||||
},
|
||||
"bid_strategy": {
|
||||
|
@@ -12,9 +12,47 @@ def bot_loop_start(self, **kwargs) -> None:
|
||||
"""
|
||||
pass
|
||||
|
||||
def custom_entry_price(self, pair: str, current_time: 'datetime', proposed_rate: float,
|
||||
entry_tag: 'Optional[str]', **kwargs) -> float:
|
||||
"""
|
||||
Custom entry price logic, returning the new entry price.
|
||||
|
||||
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
|
||||
|
||||
When not implemented by a strategy, returns None, orderbook is used to set entry price
|
||||
|
||||
:param pair: Pair that's currently analyzed
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param proposed_rate: Rate, calculated based on pricing settings in ask_strategy.
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return float: New entry price value if provided
|
||||
"""
|
||||
return proposed_rate
|
||||
|
||||
def custom_exit_price(self, pair: str, trade: 'Trade',
|
||||
current_time: 'datetime', proposed_rate: float,
|
||||
current_profit: float, **kwargs) -> float:
|
||||
"""
|
||||
Custom exit price logic, returning the new exit price.
|
||||
|
||||
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
|
||||
|
||||
When not implemented by a strategy, returns None, orderbook is used to set exit price
|
||||
|
||||
:param pair: Pair that's currently analyzed
|
||||
:param trade: trade object.
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param proposed_rate: Rate, calculated based on pricing settings in ask_strategy.
|
||||
:param current_profit: Current profit (as ratio), calculated based on current_rate.
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return float: New exit price value if provided
|
||||
"""
|
||||
return proposed_rate
|
||||
|
||||
def custom_stake_amount(self, pair: str, current_time: 'datetime', current_rate: float,
|
||||
proposed_stake: float, min_stake: float, max_stake: float,
|
||||
**kwargs) -> float:
|
||||
entry_tag: 'Optional[str]', **kwargs) -> float:
|
||||
"""
|
||||
Customize stake size for each new trade. This method is not called when edge module is
|
||||
enabled.
|
||||
@@ -25,6 +63,7 @@ def custom_stake_amount(self, pair: str, current_time: 'datetime', current_rate:
|
||||
:param proposed_stake: A stake amount proposed by the bot.
|
||||
:param min_stake: Minimal stake size allowed by exchange.
|
||||
:param max_stake: Balance available for trading.
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:return: A stake size, which is between min_stake and max_stake.
|
||||
"""
|
||||
return proposed_stake
|
||||
@@ -78,7 +117,8 @@ def custom_sell(self, pair: str, trade: 'Trade', current_time: 'datetime', curre
|
||||
return None
|
||||
|
||||
def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float,
|
||||
time_in_force: str, current_time: 'datetime', **kwargs) -> bool:
|
||||
time_in_force: str, current_time: 'datetime', entry_tag: 'Optional[str]',
|
||||
**kwargs) -> bool:
|
||||
"""
|
||||
Called right before placing a buy order.
|
||||
Timing for this function is critical, so avoid doing heavy computations or
|
||||
@@ -94,6 +134,7 @@ def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: f
|
||||
:param rate: Rate that's going to be used when using limit orders
|
||||
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return bool: When True is returned, then the buy-order is placed on the exchange.
|
||||
False aborts the process
|
||||
@@ -167,3 +208,26 @@ def check_sell_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -
|
||||
:return bool: When True is returned, then the sell-order is cancelled.
|
||||
"""
|
||||
return False
|
||||
|
||||
def adjust_trade_position(self, trade: 'Trade', current_time: 'datetime',
|
||||
current_rate: float, current_profit: float, min_stake: float,
|
||||
max_stake: float, **kwargs) -> 'Optional[float]':
|
||||
"""
|
||||
Custom trade adjustment logic, returning the stake amount that a trade should be increased.
|
||||
This means extra buy orders with additional fees.
|
||||
Only called when `position_adjustment_enable` is set to True.
|
||||
|
||||
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
|
||||
|
||||
When not implemented by a strategy, returns None
|
||||
|
||||
:param trade: trade object.
|
||||
:param current_time: datetime object, containing the current datetime
|
||||
:param current_rate: Current buy rate.
|
||||
:param current_profit: Current profit (as ratio), calculated based on current_rate.
|
||||
:param min_stake: Minimal stake size allowed by exchange.
|
||||
:param max_stake: Balance available for trading.
|
||||
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
|
||||
:return float: Stake amount to adjust your trade
|
||||
"""
|
||||
return None
|
||||
|
@@ -3,7 +3,7 @@
|
||||
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
from typing import Any, Dict, NamedTuple
|
||||
from typing import Any, Dict, NamedTuple, Optional
|
||||
|
||||
import arrow
|
||||
|
||||
@@ -211,7 +211,7 @@ class Wallets:
|
||||
|
||||
return stake_amount
|
||||
|
||||
def get_trade_stake_amount(self, pair: str, edge=None) -> float:
|
||||
def get_trade_stake_amount(self, pair: str, edge=None, update: bool = True) -> float:
|
||||
"""
|
||||
Calculate stake amount for the trade
|
||||
:return: float: Stake amount
|
||||
@@ -219,7 +219,8 @@ class Wallets:
|
||||
"""
|
||||
stake_amount: float
|
||||
# Ensure wallets are uptodate.
|
||||
self.update()
|
||||
if update:
|
||||
self.update()
|
||||
val_tied_up = Trade.total_open_trades_stakes()
|
||||
available_amount = self.get_available_stake_amount()
|
||||
|
||||
@@ -238,14 +239,15 @@ class Wallets:
|
||||
|
||||
return self._check_available_stake_amount(stake_amount, available_amount)
|
||||
|
||||
def validate_stake_amount(self, pair, stake_amount, min_stake_amount):
|
||||
def validate_stake_amount(
|
||||
self, pair: str, stake_amount: Optional[float], min_stake_amount: Optional[float]):
|
||||
if not stake_amount:
|
||||
logger.debug(f"Stake amount is {stake_amount}, ignoring possible trade for {pair}.")
|
||||
return 0
|
||||
|
||||
max_stake_amount = self.get_available_stake_amount()
|
||||
|
||||
if min_stake_amount > max_stake_amount:
|
||||
if min_stake_amount is not None and min_stake_amount > max_stake_amount:
|
||||
if self._log:
|
||||
logger.warning("Minimum stake amount > available balance.")
|
||||
return 0
|
||||
|
@@ -23,6 +23,10 @@ exclude = '''
|
||||
line_length = 100
|
||||
multi_line_output=0
|
||||
lines_after_imports=2
|
||||
skip_glob = ["**/.env*", "**/env/*", "**/.venv/*", "**/docs/*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools >= 46.4.0", "wheel"]
|
||||
|
@@ -5,25 +5,25 @@
|
||||
|
||||
coveralls==3.3.1
|
||||
flake8==4.0.1
|
||||
flake8-tidy-imports==4.5.0
|
||||
mypy==0.930
|
||||
pytest==6.2.5
|
||||
pytest-asyncio==0.16.0
|
||||
flake8-tidy-imports==4.6.0
|
||||
mypy==0.931
|
||||
pytest==7.0.1
|
||||
pytest-asyncio==0.18.1
|
||||
pytest-cov==3.0.0
|
||||
pytest-mock==3.6.1
|
||||
pytest-mock==3.7.0
|
||||
pytest-random-order==1.0.4
|
||||
isort==5.10.1
|
||||
# For datetime mocking
|
||||
time-machine==2.5.0
|
||||
time-machine==2.6.0
|
||||
|
||||
# Convert jupyter notebooks to markdown documents
|
||||
nbconvert==6.3.0
|
||||
nbconvert==6.4.2
|
||||
|
||||
# mypy types
|
||||
types-cachetools==4.2.6
|
||||
types-filelock==3.2.1
|
||||
types-requests==2.26.2
|
||||
types-tabulate==0.8.3
|
||||
types-cachetools==4.2.9
|
||||
types-filelock==3.2.5
|
||||
types-requests==2.27.10
|
||||
types-tabulate==0.8.5
|
||||
|
||||
# Extensions to datetime library
|
||||
types-python-dateutil==2.8.4
|
||||
types-python-dateutil==2.8.9
|
@@ -2,10 +2,9 @@
|
||||
-r requirements.txt
|
||||
|
||||
# Required for hyperopt
|
||||
scipy==1.7.3
|
||||
scipy==1.8.0
|
||||
scikit-learn==1.0.2
|
||||
scikit-optimize==0.9.0
|
||||
filelock==3.4.2
|
||||
filelock==3.6.0
|
||||
joblib==1.1.0
|
||||
psutil==5.8.0
|
||||
progressbar2==3.55.0
|
||||
progressbar2==4.0.0
|
||||
|
@@ -1,5 +1,5 @@
|
||||
# Include all requirements to run the bot.
|
||||
-r requirements.txt
|
||||
|
||||
plotly==5.5.0
|
||||
plotly==5.6.0
|
||||
|
||||
|
@@ -1,46 +1,46 @@
|
||||
numpy==1.21.5
|
||||
pandas==1.3.5
|
||||
numpy==1.22.2
|
||||
pandas==1.4.1
|
||||
pandas-ta==0.3.14b
|
||||
|
||||
ccxt==1.65.25
|
||||
ccxt==1.73.70
|
||||
# Pin cryptography for now due to rust build errors with piwheels
|
||||
cryptography==36.0.1
|
||||
aiohttp==3.8.1
|
||||
SQLAlchemy==1.4.29
|
||||
python-telegram-bot==13.9
|
||||
arrow==1.2.1
|
||||
SQLAlchemy==1.4.31
|
||||
python-telegram-bot==13.11
|
||||
arrow==1.2.2
|
||||
cachetools==4.2.2
|
||||
requests==2.26.0
|
||||
urllib3==1.26.7
|
||||
jsonschema==4.3.2
|
||||
TA-Lib==0.4.22
|
||||
requests==2.27.1
|
||||
urllib3==1.26.8
|
||||
jsonschema==4.4.0
|
||||
TA-Lib==0.4.24
|
||||
technical==1.3.0
|
||||
tabulate==0.8.9
|
||||
pycoingecko==2.2.0
|
||||
jinja2==3.0.3
|
||||
tables==3.6.1
|
||||
tables==3.7.0
|
||||
blosc==1.10.6
|
||||
|
||||
# find first, C search in arrays
|
||||
py_find_1st==1.1.5
|
||||
|
||||
# Load ticker files 30% faster
|
||||
python-rapidjson==1.5
|
||||
python-rapidjson==1.6
|
||||
|
||||
# Notify systemd
|
||||
sdnotify==0.3.2
|
||||
|
||||
# API Server
|
||||
fastapi==0.70.1
|
||||
uvicorn==0.16.0
|
||||
fastapi==0.74.0
|
||||
uvicorn==0.17.5
|
||||
pyjwt==2.3.0
|
||||
aiofiles==0.8.0
|
||||
psutil==5.8.0
|
||||
psutil==5.9.0
|
||||
|
||||
# Support for colorized terminal output
|
||||
colorama==0.4.4
|
||||
# Building config files interactively
|
||||
questionary==1.10.0
|
||||
prompt-toolkit==3.0.24
|
||||
prompt-toolkit==3.0.28
|
||||
# Extensions to datetime library
|
||||
python-dateutil==2.8.2
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user